From 1aedc5691fa95e44a69e1050cd829528cee0f603 Mon Sep 17 00:00:00 2001 From: Eloi Campagne <78951592+eloicampagne@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:39:34 +0200 Subject: [PATCH] Revert "Test/suite and ci" --- .github/workflows/ci.yml | 52 ------------ .gitignore | 7 +- graphtoolbox/data/preprocessing.py | 2 +- pyproject.toml | 7 +- tests/test_aggregation.py | 34 -------- tests/test_extra_models.py | 37 -------- tests/test_helpers.py | 75 ---------------- tests/test_import.py | 20 ----- tests/test_integration_pipeline.py | 132 ----------------------------- tests/test_interpretability.py | 116 ------------------------- tests/test_metrics.py | 34 -------- tests/test_models.py | 39 --------- tests/test_optimizer.py | 55 ------------ tests/test_significance.py | 43 ---------- tests/test_temporal.py | 75 ---------------- 15 files changed, 4 insertions(+), 724 deletions(-) delete mode 100644 .github/workflows/ci.yml delete mode 100644 tests/test_aggregation.py delete mode 100644 tests/test_extra_models.py delete mode 100644 tests/test_helpers.py delete mode 100644 tests/test_import.py delete mode 100644 tests/test_integration_pipeline.py delete mode 100644 tests/test_interpretability.py delete mode 100644 tests/test_metrics.py delete mode 100644 tests/test_models.py delete mode 100644 tests/test_optimizer.py delete mode 100644 tests/test_significance.py delete mode 100644 tests/test_temporal.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index f20c6a4..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -# Cancel superseded runs on the same ref to save minutes. -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12"] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: pip - - - name: Install CPU-only PyTorch - # Avoids pulling the large CUDA build on the runner. - run: pip install torch --index-url https://download.pytorch.org/whl/cpu - - - name: Install package with dev extras - run: pip install -e ".[dev]" pytest-cov - - - name: Lint for errors (not style) - # Only real problems fail CI: syntax errors and undefined names. - run: flake8 graphtoolbox --count --select=E9,F63,F7,F82 --show-source --statistics - - - name: Run tests with coverage - run: pytest -q --cov=graphtoolbox --cov-report=term-missing --cov-report=xml --cov-fail-under=50 - - - name: Upload coverage to Codecov - if: matrix.python-version == '3.12' - uses: codecov/codecov-action@v4 - with: - files: coverage.xml - fail_ci_if_error: false - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 00dc403..043ca9a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,9 +35,4 @@ logs/ # Sphinx build output and autosummary stubs (regenerated at build time) docs/build/ -docs/source/_autosummary/ -# Test / coverage artifacts -.coverage -coverage.xml -htmlcov/ -.pytest_cache/ +docs/source/_autosummary/ \ No newline at end of file diff --git a/graphtoolbox/data/preprocessing.py b/graphtoolbox/data/preprocessing.py index fdac327..0a5ddb5 100644 --- a/graphtoolbox/data/preprocessing.py +++ b/graphtoolbox/data/preprocessing.py @@ -88,5 +88,5 @@ def extract_dummies(df: pd.DataFrame, var_names: np.ndarray) -> pd.DataFrame: new_df = df.copy() list_to_concat = [new_df] + [pd.get_dummies(new_df[var_name], prefix=var_name, drop_first=True, dtype=float) for var_name in var_names] new_df = pd.concat(list_to_concat, axis=1) - new_df = new_df.drop(columns=var_names) + new_df = new_df.drop(columns=var_names, axis=1) return new_df diff --git a/pyproject.toml b/pyproject.toml index 4c45372..c43dc28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "torch-geometric", "tqdm", "cvxopt", + "basemap", "optuna", "optuna-dashboard", "jupyter", @@ -47,11 +48,7 @@ include = ["graphtoolbox*"] [project.optional-dependencies] -# Geographic-map figures only. Basemap is deprecated and lacks wheels for every -# Python version, so it is kept out of the core dependencies; install with -# `pip install GraphToolbox[maps]` when you need those plots. -maps = ["basemap"] -dev = ["pytest", "pytest-cov", "black", "flake8", "mypy"] +dev = ["pytest", "black", "flake8", "mypy"] docs = [ "sphinx", "sphinx-rtd-theme", diff --git a/tests/test_aggregation.py b/tests/test_aggregation.py deleted file mode 100644 index bcb8bff..0000000 --- a/tests/test_aggregation.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Tests for online expert aggregation (MLpol) on synthetic experts.""" -import numpy as np - -from graphtoolbox.aggregation import Aggregation - - -def _experts(seed=0, T=96): - rng = np.random.default_rng(seed) - y = 50.0 + 10.0 * np.sin(np.arange(T) * 0.2) - # three experts of decreasing quality - X = np.column_stack([y + rng.standard_normal(T) * s for s in (0.5, 2.0, 6.0)]) - return X, y - - -def _rmse(p, y): - return float(np.sqrt(np.mean((np.asarray(p) - y) ** 2))) - - -def test_mlpol_shape_and_convex_weights(): - X, y = _experts() - agg = Aggregation(model="MLpol", loss="square").run(X, y, block_size=48) - pred = np.asarray(agg.prediction_) - assert pred.shape == (X.shape[0],) - w = np.asarray(agg.coefficients_) - assert w.shape == (X.shape[1],) - assert np.all(w >= -1e-8) and abs(w.sum() - 1.0) < 1e-6 - - -def test_mlpol_favours_best_expert_and_beats_worst(): - X, y = _experts() - agg = Aggregation(model="MLpol", loss="square").run(X, y, block_size=48) - w = np.asarray(agg.coefficients_) - assert w.argmax() == 0 # lowest-noise expert gets most weight - assert _rmse(agg.prediction_, y) <= _rmse(X[:, -1], y) # beats the worst expert diff --git a/tests/test_extra_models.py b/tests/test_extra_models.py deleted file mode 100644 index 69d197d..0000000 --- a/tests/test_extra_models.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Forward/backward tests for the additive and gated multi-branch models.""" -import torch -from torch_geometric.nn.conv import GCNConv, GATConv - -from graphtoolbox.models import AdditiveGraphModel, GatedMultiConvGNN - - -def _graph(n=10, f=30, e=36): - return torch.randn(n, f), torch.randint(0, n, (2, e)), torch.rand(e) - - -def test_additive_forward_and_group_outputs(): - x, ei, ew = _graph(f=30) - groups = {"a": 10, "b": 12, "c": 8} # sums to 30 - model = AdditiveGraphModel(feature_group_dims=groups, num_layers=2, - hidden_channels=16, out_channels=6, - conv_class=GCNConv, conv_kwargs={}) - out = model(x, ei, edge_weight=ew) - assert out.shape == (10, 6) - y_hat, group_out = model(x, ei, edge_weight=ew, return_group_outputs=True) - assert set(group_out) == set(groups) - assert all(v.shape == (10, 6) for v in group_out.values()) - out.sum().backward() - assert any(p.grad is not None for p in model.parameters()) - - -def test_gated_multiconv_mixes_branches(): - x, ei, ew = _graph(f=30) - model = GatedMultiConvGNN(in_channels=30, hidden_channels=16, out_channels=6, - conv_classes=[GATConv, GCNConv], num_layers=2, - conv_kwargs_list=[{"heads": 4}, {}]) - out = model(x, ei, edge_weight=ew) - assert out.shape == (10, 6) - y_hat, gate = model(x, ei, edge_weight=ew, return_attention=True) - # per-node convex gate over the two branches - assert gate.shape == (10, 2) - assert torch.allclose(gate.sum(dim=-1), torch.ones(10), atol=1e-5) diff --git a/tests/test_helpers.py b/tests/test_helpers.py deleted file mode 100644 index 0504718..0000000 --- a/tests/test_helpers.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Unit tests for the pure utility helpers.""" -import os - -import numpy as np -import pytest -import torch - -from graphtoolbox.utils import ( - parser_config, extract_parameters, get_geodesic_distance, - get_exponential_similarity, build_adjacency_matrix, clean_dir, -) - - -def test_parser_config_types(): - d = parser_config('{"model_name": "GCN", "num_epochs": "100", "lr": "0.001"}') - assert d == {"model_name": "GCN", "num_epochs": 100, "lr": 0.001} - - -def test_extract_parameters_match_and_miss(): - d = extract_parameters("batch16_hidden256_layers2_epochs300") - assert d == {"batch_size": 16, "hidden_channels": 256, "num_layers": 2} - assert extract_parameters("not_a_checkpoint_name") is None - - -def test_geodesic_distance(): - paris = (2.35, 48.85) - london = (-0.13, 51.51) - assert get_geodesic_distance(paris, paris) == pytest.approx(0.0, abs=1e-6) - d = get_geodesic_distance(paris, london) - assert 300 < d < 400 # ~344 km - - -def test_exponential_similarity_kernel(): - dist = np.array([0.0, 1.0, 5.0]) - sim = get_exponential_similarity(dist, bandwidth=2.0, threshold=0.1) - assert sim[0] == pytest.approx(1.0) # zero distance -> similarity 1 - assert sim[2] == 0.0 # far pair thresholded to 0 - assert np.all(np.diff(sim[:2]) <= 0) # decreasing with distance - - -def test_build_adjacency_matrix(): - edge_index = torch.tensor([[0, 1, 2], [1, 2, 0]]) - edge_weight = torch.tensor([0.5, 1.0, 2.0]) - A = build_adjacency_matrix(edge_index, edge_weight) - assert A.shape == (3, 3) - assert A[0, 1] == pytest.approx(0.5) - assert A[2, 0] == pytest.approx(2.0) - assert A[1, 0] == 0.0 # directed, no reverse edge - - -def test_clean_dir(tmp_path): - for name in ("a.txt", "b.txt"): - (tmp_path / name).write_text("x") - clean_dir(str(tmp_path)) - assert os.listdir(tmp_path) == [] - - -def test_spectral_helpers(): - from graphtoolbox.utils import ( - normalized_laplacian, spectral_embedding, cosine_similarity_matrix, - ) - # simple 4-node ring adjacency - A = torch.tensor([[0., 1, 0, 1], [1, 0, 1, 0], [0, 1, 0, 1], [1, 0, 1, 0]]) - L = normalized_laplacian(A) - assert L.shape == (4, 4) - assert torch.allclose(L, L.T, atol=1e-5) # symmetric - - emb = spectral_embedding(L, k=2) - assert emb.shape[0] == 4 and emb.shape[1] == 2 - - X = torch.randn(5, 8) - S = cosine_similarity_matrix(X) - assert S.shape == (5, 5) - assert torch.allclose(torch.diagonal(S), torch.ones(5), atol=1e-5) # self-similarity 1 - assert float(S.max()) <= 1.0 + 1e-5 and float(S.min()) >= -1.0 - 1e-5 diff --git a/tests/test_import.py b/tests/test_import.py deleted file mode 100644 index 12d4fb3..0000000 --- a/tests/test_import.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Smoke tests: the package and its public API import without heavy data.""" - - -def test_package_version(): - import graphtoolbox - assert isinstance(graphtoolbox.__version__, str) and graphtoolbox.__version__ - - -def test_public_symbols_import(): - from graphtoolbox.models import myGNN, ConvAdapter, TemporalGNN, AdditiveGraphModel - from graphtoolbox.training import Trainer, MAPE, RMSE, set_device - from graphtoolbox.evaluation import ( - diebold_mariano, bootstrap_metric, model_confidence_set, pairwise_dm, - ) - # reference them so linters do not flag unused imports - assert all(obj is not None for obj in ( - myGNN, ConvAdapter, TemporalGNN, AdditiveGraphModel, - Trainer, MAPE, RMSE, set_device, - diebold_mariano, bootstrap_metric, model_confidence_set, pairwise_dm, - )) diff --git a/tests/test_integration_pipeline.py b/tests/test_integration_pipeline.py deleted file mode 100644 index 4f1f23a..0000000 --- a/tests/test_integration_pipeline.py +++ /dev/null @@ -1,132 +0,0 @@ -"""End-to-end integration test on a synthetic panel. - -Exercises the full pipeline with no external files: a synthetic CSV is written to a -temp directory, read by DataClass, turned into GraphDatasets (identity graph, since -no adjacency file exists), and trained through the Trainer, with and without MinT -reconciliation. This covers the data path, the training loop, metric evaluation, -and the reconciliation projection in one pass. -""" -import numpy as np -import pandas as pd -import pytest -from torch_geometric.nn.conv import GCNConv, GATConv -from torch_geometric import seed_everything - -from graphtoolbox.data import DataClass, GraphDataset -from graphtoolbox.models import myGNN -from graphtoolbox.training import Trainer, RollingTrainer, set_device - -NODES = ["A", "B", "C"] -OUT_CHANNELS = 2 - - -def _panel(dates, seed): - rng = np.random.default_rng(seed) - rows = [] - for j, node in enumerate(NODES): - temp = 15 + 5 * np.sin(np.arange(len(dates)) * 0.2) + rng.standard_normal(len(dates)) - load = 50 + 5 * j + 0.8 * temp + rng.standard_normal(len(dates)) - for i, d in enumerate(dates): - rows.append({"date": str(d.date()), "node": node, - "temp": float(temp[i]), "load": float(load[i])}) - return pd.DataFrame(rows) - - -@pytest.fixture -def datasets(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) # keep checkpoints / files out of the repo - set_device("cpu") - seed_everything(0) - _panel(pd.date_range("2020-01-01", periods=60, freq="D"), 0).to_csv("train.csv", index=False) - _panel(pd.date_range("2020-03-05", periods=15, freq="D"), 1).to_csv("test.csv", index=False) - - data_kwargs = { - "node_var": "node", "dummies": [], "features_to_lag": {"load": (1, 2)}, - "day_inf_train": "2020-01-01", "day_sup_train": "2020-02-10", - "day_inf_val": "2020-02-11", "day_sup_val": "2020-02-29", - "day_inf_test": "2020-03-05", "day_sup_test": "2020-03-19", - } - dataset_kwargs = { - "batch_size": 8, "adj_matrix": "eye", - "features_base": ["temp", "load_l1", "load_l2"], "target_base": "load", - } - data = DataClass(path_train="train.csv", path_test="test.csv", - data_kwargs=data_kwargs, folder_config=".") - common = dict(graph_folder="./no_graphs", dataset_kwargs=dataset_kwargs, - out_channels=OUT_CHANNELS) - train = GraphDataset(data=data, period="train", **common) - val = GraphDataset(data=data, period="val", scalers_feat=train.scalers_feat, - scalers_target=train.scalers_target, **common) - test = GraphDataset(data=data, period="test", scalers_feat=train.scalers_feat, - scalers_target=train.scalers_target, **common) - return train, val, test - - -def test_graphdataset_shapes(datasets): - train, val, test = datasets - assert train.num_nodes == len(NODES) - assert train.num_node_features == 3 # temp, load_l1, load_l2 - assert len(train) > 0 and len(test) > 0 - sample = train[0] - assert sample.x.shape == (len(NODES), 3) - assert sample.y.shape == (len(NODES), OUT_CHANNELS) - - -def _train_once(datasets, reconcile, top_level_model=None): - train, val, test = datasets - seed_everything(0) - model = myGNN(in_channels=train.num_node_features, num_layers=1, - hidden_channels=8, out_channels=OUT_CHANNELS, conv_class=GCNConv) - trainer = Trainer(model=model, dataset_train=train, dataset_val=val, dataset_test=test, - batch_size=8, model_kwargs={"lr": 1e-2, "num_epochs": 1}, - reconcile=reconcile, top_level_model=top_level_model) - pred, target, *_ = trainer.train(force_training=True, patience=1) - return pred, target - - -def test_pipeline_trains_without_reconciliation(datasets): - pred, target = _train_once(datasets, reconcile=False) - assert pred.shape == target.shape - assert pred.shape[0] == len(NODES) - assert bool(np.isfinite(pred.detach().cpu().numpy()).all()) - - -def test_pipeline_trains_with_mint_reconciliation(datasets): - pred, target = _train_once(datasets, reconcile=True, top_level_model="ridge") - assert pred.shape[0] == len(NODES) - assert bool(np.isfinite(pred.detach().cpu().numpy()).all()) - - -def test_pipeline_collects_attention(datasets): - train, val, test = datasets - seed_everything(0) - model = myGNN(in_channels=train.num_node_features, num_layers=1, hidden_channels=8, - out_channels=OUT_CHANNELS, conv_class=GATConv, - conv_kwargs={"heads": 2}, heads=2) - trainer = Trainer(model=model, dataset_train=train, dataset_val=val, dataset_test=test, - batch_size=8, model_kwargs={"lr": 1e-2, "num_epochs": 1}, - reconcile=False, return_attention=True) - # with return_attention, train() returns (preds, targets, edge_index, attention_maps) - pred, target, edge_index, attention = trainer.train(force_training=True, patience=1) - assert pred.shape[0] == len(NODES) - assert bool(np.isfinite(pred.detach().cpu().numpy()).all()) - - -def test_rolling_trainer_rolls_over_windows(datasets): - train, val, test = datasets - set_device("cpu") - seed_everything(0) - model_kwargs = dict(in_channels=train.num_node_features, num_layers=1, - hidden_channels=8, out_channels=OUT_CHANNELS, conv_class=GCNConv) - roller = RollingTrainer( - dataset_train_0=train, dataset_val_0=val, dataset_test_full=test, - model_class=myGNN, model_kwargs=model_kwargs, - window_size=6, step_size=6, batch_size=8, reconcile=False, - num_epochs_initial=1, num_epochs_update=1, - trainer_kwargs={"lr": 1e-2, "force_training": True, "patience": 1}, - ) - results = roller.run() - assert len(results) >= 2 # expanding-window rolling forecast - for r in results: - assert r["preds"].shape[0] == len(NODES) - assert bool(np.isfinite(r["preds"].detach().cpu().numpy()).all()) diff --git a/tests/test_interpretability.py b/tests/test_interpretability.py deleted file mode 100644 index 2266083..0000000 --- a/tests/test_interpretability.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Tests for the ALE interpretability functions. - -The array-level ALE computations and their plots are exercised directly on -synthetic tensors, and the group-level feature-importance path is run end to end -on a small synthetic panel with fake per-group contributions. The heavier -explanation-graph maps (which require Basemap) are out of scope here. -""" -import matplotlib -matplotlib.use("Agg") # headless backend for CI - -import numpy as np -import pandas as pd -import pytest -import torch - -from graphtoolbox.interpretability import ( - compute_ALE, compute_ALE_avg_over_instants, compute_ALE_per_node, - compute_ALE_per_node_avg_over_instants, ale_scalar_importance, - plot_ALE, plot_ALE_avg, plot_ALE_nodes, plot_feature_importance_bar, - get_group_feature_mats, compute_feature_importances_from_ALE, -) -from graphtoolbox.data import DataClass, GraphDataset - - -def _values_contrib(n=6, T=96, seed=0): - rng = np.random.default_rng(seed) - values = torch.tensor(rng.uniform(0, 10, size=(n, T)), dtype=torch.float32) - contrib = torch.tanh(values) + torch.tensor(rng.standard_normal((n, T)) * 0.1, dtype=torch.float32) - return values, contrib - - -def test_compute_ale_shapes_and_centering(): - values, contrib = _values_contrib() - x, y, x_mid, ale = compute_ALE(values, contrib, n_bins=20) - assert x.shape == y.shape == (values.numel(),) - assert x_mid.shape == ale.shape - assert abs(float(ale.mean())) < 1e-5 # ALE is centered - - -def test_compute_ale_variants_run(): - values, contrib = _values_contrib(T=96) - xm, mean, std = compute_ALE_avg_over_instants(values, contrib, n_bins=10, period=48) - assert xm.shape == mean.shape - xmn, mat, counts = compute_ALE_per_node(values, contrib, n_bins=10) - assert mat.shape[0] == values.shape[0] - xmp, node_ale, counts2 = compute_ALE_per_node_avg_over_instants( - values, contrib, n_bins=10, period=48) - assert node_ale.shape[0] == values.shape[0] - - -@pytest.mark.parametrize("method", ["rms", "range", "tv"]) -def test_ale_scalar_importance(method): - _, _, x_mid, ale = compute_ALE(*_values_contrib(), n_bins=20) - imp = ale_scalar_importance(ale, counts=None, method=method) - assert np.isfinite(imp) - - -def test_plots_do_not_raise(): - values, contrib = _values_contrib() - x, y, x_mid, ale = compute_ALE(values, contrib, n_bins=15) - plot_ALE(x, y, x_mid, ale, label="temp") - xm, mean, std = compute_ALE_avg_over_instants(values, contrib, n_bins=10, period=48) - plot_ALE_avg(xm, mean, std, period=48, label="temp") - xmn, mat, counts = compute_ALE_per_node(values, contrib, n_bins=10) - plot_ALE_nodes(xmn, mat, counts=counts) - df = pd.DataFrame({"feature": ["a", "b", "c"], "importance": [3.0, 1.0, 2.0]}) - plot_feature_importance_bar(df, top_k=2) - matplotlib.pyplot.close("all") - - -# --------------------------------------------------------------------------- # -# End-to-end feature importance on a small synthetic panel -# --------------------------------------------------------------------------- # -def _panel(dates, seed): - rng = np.random.default_rng(seed) - rows = [] - for j, node in enumerate(["A", "B", "C"]): - temp = 15 + 5 * np.sin(np.arange(len(dates)) * 0.2) + rng.standard_normal(len(dates)) - load = 50 + 5 * j + 0.8 * temp + rng.standard_normal(len(dates)) - for i, d in enumerate(dates): - rows.append({"date": str(d.date()), "node": node, - "temp": float(temp[i]), "load": float(load[i])}) - return pd.DataFrame(rows) - - -def test_feature_importances_from_ale_end_to_end(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _panel(pd.date_range("2020-01-01", periods=60, freq="D"), 0).to_csv("train.csv", index=False) - _panel(pd.date_range("2020-03-05", periods=20, freq="D"), 1).to_csv("test.csv", index=False) - - data_kwargs = {"node_var": "node", "dummies": [], "features_to_lag": {"load": (1, 2)}, - "day_inf_train": "2020-01-01", "day_sup_train": "2020-02-10", - "day_inf_val": "2020-02-11", "day_sup_val": "2020-02-29", - "day_inf_test": "2020-03-05", "day_sup_test": "2020-03-24"} - feature_groups = {"weather": ["temp"], "lags": ["load_l1", "load_l2"]} - dataset_kwargs = {"batch_size": 8, "adj_matrix": "eye", "target_base": "load", - "features_base": ["temp", "load_l1", "load_l2"], - "feature_groups": feature_groups} - data = DataClass(path_train="train.csv", path_test="test.csv", - data_kwargs=data_kwargs, folder_config=".") - common = dict(graph_folder="./no_graphs", dataset_kwargs=dataset_kwargs, out_channels=2) - train = GraphDataset(data=data, period="train", **common) - test = GraphDataset(data=data, period="test", scalers_feat=train.scalers_feat, - scalers_target=train.scalers_target, **common) - - # get_group_feature_mats resolves feature columns from data.df_test - mats = get_group_feature_mats("weather", data, train, test) - assert "temp" in mats - - n_nodes, T = test.num_nodes, mats["temp"].shape[1] - group_outputs = {g: torch.randn(n_nodes, T) for g in feature_groups} - df = compute_feature_importances_from_ALE(group_outputs, data, train, test, - n_bins=10, mode="global") - assert not df.empty - assert set(df["group"]) <= set(feature_groups) - assert (df["importance"] >= 0).all() diff --git a/tests/test_metrics.py b/tests/test_metrics.py deleted file mode 100644 index 4627589..0000000 --- a/tests/test_metrics.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Direct unit tests for the forecasting metrics, on numpy and torch inputs.""" -import numpy as np -import pytest -import torch - -from graphtoolbox.training import MAE, NMAE, MAPE, RMSE, BIAS - - -@pytest.mark.parametrize("wrap", [np.asarray, torch.tensor]) -def test_metric_values(wrap): - preds = wrap([1.0, 2.0, 3.0]) - targets = wrap([1.0, 1.0, 1.0]) - # preds-targets = [0, 1, 2]; BIAS uses the target-minus-pred convention. - assert float(MAE(preds, targets)) == pytest.approx(1.0) - assert float(RMSE(preds, targets)) == pytest.approx(np.sqrt(5.0 / 3.0)) - assert float(BIAS(preds, targets)) == pytest.approx(-1.0) - assert float(NMAE(preds, targets)) == pytest.approx(1.0) # mean|e| / mean|t| = 1/1 - assert float(MAPE(preds, targets)) == pytest.approx(1.0) # |(t-p)/t| mean = 1 - - -def test_perfect_forecast_is_zero_error(): - y = np.array([3.0, -2.0, 5.0]) - assert float(MAE(y, y)) == pytest.approx(0.0) - assert float(RMSE(y, y)) == pytest.approx(0.0) - assert float(BIAS(y, y)) == pytest.approx(0.0) - - -def test_numpy_torch_agree(): - rng = np.random.default_rng(0) - p, t = rng.standard_normal(50), rng.standard_normal(50) - for metric in (MAE, RMSE, MAPE, NMAE, BIAS): - a = float(metric(p, t)) - b = float(metric(torch.tensor(p), torch.tensor(t))) - assert a == pytest.approx(b, rel=1e-6) diff --git a/tests/test_models.py b/tests/test_models.py deleted file mode 100644 index ccffc1d..0000000 --- a/tests/test_models.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Smoke tests for myGNN and the convolution adapter on a synthetic graph.""" -import torch -from torch_geometric.nn.conv import GCNConv, GATConv, LEConv, ChebConv - -from graphtoolbox.models import myGNN - - -def _graph(n=12, f=20, e=40): - x = torch.randn(n, f) - edge_index = torch.randint(0, n, (2, e)) - edge_weight = torch.rand(e) - return x, edge_index, edge_weight - - -def test_mygnn_forward_backward(): - x, edge_index, edge_weight = _graph() - model = myGNN(20, 2, 32, 8, conv_class=GCNConv) - out = model(x, edge_index, edge_weight=edge_weight) - assert out.shape == (12, 8) - out.sum().backward() - grads = [p.grad for p in model.parameters() if p.requires_grad] - assert grads and all(g is not None and torch.isfinite(g).all() for g in grads) - - -def test_heads_split_keeps_width_fixed(): - # Multi-head convs split the latent width across heads (attention-is-all-you-need - # convention), so the parameter count does not grow with the head count. - p1 = sum(p.numel() for p in myGNN(20, 2, 32, 8, conv_class=GATConv, heads=1).parameters()) - p4 = sum(p.numel() for p in myGNN(20, 2, 32, 8, conv_class=GATConv, heads=4).parameters()) - assert p1 == p4 - - -def test_adapter_runs_diverse_operators(): - x, edge_index, edge_weight = _graph() - cases = [(GCNConv, {}), (LEConv, {}), (ChebConv, {"K": 3}), (GATConv, {"heads": 2})] - for cls, kw in cases: - model = myGNN(20, 2, 32, 8, conv_class=cls, conv_kwargs=kw, heads=kw.get("heads", 1)) - out = model(x, edge_index, edge_weight=edge_weight) - assert out.shape == (12, 8), cls.__name__ diff --git a/tests/test_optimizer.py b/tests/test_optimizer.py deleted file mode 100644 index 41ba886..0000000 --- a/tests/test_optimizer.py +++ /dev/null @@ -1,55 +0,0 @@ -"""A single-trial Optuna run over the synthetic pipeline (fast smoke test).""" -import numpy as np -import pandas as pd -from torch_geometric.nn.conv import GCNConv - -from graphtoolbox.data import DataClass, GraphDataset -from graphtoolbox.models import myGNN -from graphtoolbox.optim import Optimizer -from graphtoolbox.training import set_device - - -def _panel(dates, seed): - rng = np.random.default_rng(seed) - rows = [] - for j, node in enumerate(["A", "B", "C"]): - temp = 15 + 5 * np.sin(np.arange(len(dates)) * 0.2) + rng.standard_normal(len(dates)) - load = 50 + 5 * j + 0.8 * temp + rng.standard_normal(len(dates)) - for i, d in enumerate(dates): - rows.append({"date": str(d.date()), "node": node, - "temp": float(temp[i]), "load": float(load[i])}) - return pd.DataFrame(rows) - - -def test_optimizer_single_trial(tmp_path, monkeypatch): - import optuna - optuna.logging.set_verbosity(optuna.logging.WARNING) - monkeypatch.chdir(tmp_path) - set_device("cpu") - # The objective samples adj_matrix from the graph folder, so it must exist; - # the empty 'eye' subdir triggers the identity-matrix fallback. - (tmp_path / "graphs" / "eye").mkdir(parents=True) - - _panel(pd.date_range("2020-01-01", periods=60, freq="D"), 0).to_csv("train.csv", index=False) - _panel(pd.date_range("2020-03-05", periods=15, freq="D"), 1).to_csv("test.csv", index=False) - - data_kwargs = {"node_var": "node", "dummies": [], "features_to_lag": {"load": (1, 2)}, - "day_inf_train": "2020-01-01", "day_sup_train": "2020-02-10", - "day_inf_val": "2020-02-11", "day_sup_val": "2020-02-29", - "day_inf_test": "2020-03-05", "day_sup_test": "2020-03-19"} - dataset_kwargs = {"batch_size": 8, "adj_matrix": "eye", "target_base": "load", - "features_base": ["temp", "load_l1", "load_l2"]} - data = DataClass("train.csv", "test.csv", data_kwargs=data_kwargs, folder_config=".") - common = dict(graph_folder="graphs", dataset_kwargs=dataset_kwargs, out_channels=2) - train = GraphDataset(data=data, period="train", **common) - val = GraphDataset(data=data, period="val", scalers_feat=train.scalers_feat, - scalers_target=train.scalers_target, **common) - - optimizer = Optimizer(model=myGNN, dataset_train=train, dataset_val=val, out_channels=2, - conv_class=GCNConv, num_epochs=1, - optim_kwargs={"num_layers": (1, 2), "hidden_channels": (8, 16), - "lr": (1e-3, 1e-2)}) - optimizer.optimize(n_trials=1) - assert optimizer.is_optimized - assert np.isfinite(optimizer.study.best_value) - assert "num_layers" in optimizer.study.best_trial.params diff --git a/tests/test_significance.py b/tests/test_significance.py deleted file mode 100644 index e3cba24..0000000 --- a/tests/test_significance.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Smoke tests for the significance module on synthetic forecasts with a known order.""" -import numpy as np - -from graphtoolbox.evaluation import ( - diebold_mariano, bootstrap_metric, model_confidence_set, pairwise_dm, -) - - -def _forecasts(seed=0): - rng = np.random.default_rng(seed) - T = 2000 - y = 500.0 + 100.0 * np.sin(np.arange(T) * 0.1) - good = y + rng.standard_normal(T) * 2.0 - bad = y + rng.standard_normal(T) * 8.0 - good2 = y + np.random.default_rng(seed + 1).standard_normal(T) * 2.0 - return y, good, bad, good2 - - -def test_dm_detects_clear_difference(): - y, good, bad, _ = _forecasts() - r = diebold_mariano(good, bad, y, loss="squared", h=1, names=("good", "bad")) - assert r.pvalue < 0.01 - assert r.better == "good" - - -def test_bootstrap_metric_interval_brackets_point(): - y, good, _, _ = _forecasts() - r = bootstrap_metric(good, y, metric="rmse", n_boot=200, block_len=48, seed=0) - assert r.se > 0 - assert r.ci_low <= r.point <= r.ci_high - - -def test_pairwise_dm_and_mcs_exclude_the_bad_model(): - y, good, bad, good2 = _forecasts() - preds = {"good": good, "bad": bad, "good2": good2} - - pw = pairwise_dm(preds, y, loss="squared", h=1, correction="holm") - assert pw["pvalue_adjusted"].loc["good", "bad"] < 0.05 - - mcs = model_confidence_set(preds, y, loss="squared", alpha=0.10, - n_boot=300, block_len=48, seed=0) - assert "bad" not in mcs.included - assert "good" in mcs.included diff --git a/tests/test_temporal.py b/tests/test_temporal.py deleted file mode 100644 index 0c1a5c4..0000000 --- a/tests/test_temporal.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Tests for TemporalGNN / ConvAdapterTemporal using fake PyG-Temporal cells. - -The real recurrent cells live in the optional torch_geometric_temporal package, so -these tests fake the two calling conventions (step-recurrent and windowed) to -exercise the adapter and the lag/static split without that dependency. -""" -import re - -import torch -import torch.nn as nn - -from graphtoolbox.models import TemporalGNN, lag_sequence_indices - - -class _FakeStepCell(nn.Module): - """Mimics GConvGRU/DCRNN/TGCN: forward(x_t, edge_index, edge_weight, H).""" - def __init__(self, in_channels, out_channels, K=2): - super().__init__() - self.out_channels = out_channels - self.lin = nn.Linear(in_channels + out_channels, out_channels) - - def forward(self, x, edge_index, edge_weight=None, H=None): - if H is None: - H = x.new_zeros(x.size(0), self.out_channels) - return torch.tanh(self.lin(torch.cat([x, H], dim=1))) - - -class _FakeWindowCell(nn.Module): - """Mimics A3TGCN: forward(x_window[N, C, periods], edge_index, edge_weight).""" - def __init__(self, in_channels, out_channels, periods): - super().__init__() - self.out_channels = out_channels - self.lin = nn.Linear(in_channels * periods, out_channels) - - def forward(self, x, edge_index, edge_weight=None, H=None): - return torch.tanh(self.lin(x.reshape(x.size(0), -1))) - - -def _features(): - # 5 static features, then 8 ordered load lags - return ["temp", "nebu", "wind", "cal1", "cal2"] + [f"load_l{t}" for t in range(1, 9)] - - -def _graph(n=12, e=40): - feats = _features() - x = torch.randn(n, len(feats)) - return feats, x, torch.randint(0, n, (2, e)), torch.rand(e) - - -def test_lag_sequence_indices_orders_oldest_first(): - feats = _features() - seq, static = lag_sequence_indices(feats, target="load") - assert len(seq) == 8 and len(static) == 5 - assert feats[seq[0]] == "load_l8" and feats[seq[-1]] == "load_l1" - - -def test_temporalgnn_step_cell_forward_backward(): - feats, x, ei, ew = _graph() - model = TemporalGNN.from_features(feats, cell_class=_FakeStepCell, - hidden_channels=16, out_channels=6, - cell_kwargs={"K": 2}) - assert model.adapter.windowed is False - out = model(x, ei, ew) - assert out.shape == (12, 6) - out.sum().backward() - assert any(p.grad is not None for p in model.parameters()) - - -def test_temporalgnn_windowed_cell_sets_periods(): - feats, x, ei, ew = _graph() - model = TemporalGNN.from_features(feats, cell_class=_FakeWindowCell, - hidden_channels=16, out_channels=6) - assert model.adapter.windowed is True - out = model(x, ei, ew) - assert out.shape == (12, 6)