From e0ecf36d3849bce3ca8213afde9bb908f0755bba Mon Sep 17 00:00:00 2001 From: Eloi Campagne Date: Fri, 10 Jul 2026 13:12:28 +0200 Subject: [PATCH 1/4] build(deps): make basemap an optional 'maps' extra and add pytest-cov Basemap is deprecated and lacks wheels for every Python version, so keeping it in the core dependencies breaks 'pip install' on newer interpreters. Move it to an optional [maps] extra and add pytest-cov for coverage reporting. --- pyproject.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c43dc28..4c45372 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ dependencies = [ "torch-geometric", "tqdm", "cvxopt", - "basemap", "optuna", "optuna-dashboard", "jupyter", @@ -48,7 +47,11 @@ include = ["graphtoolbox*"] [project.optional-dependencies] -dev = ["pytest", "black", "flake8", "mypy"] +# 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"] docs = [ "sphinx", "sphinx-rtd-theme", From 9af7695678fd8f5f019488a93a065db0b74240e8 Mon Sep 17 00:00:00 2001 From: Eloi Campagne Date: Fri, 10 Jul 2026 13:12:39 +0200 Subject: [PATCH 2/4] test: add unit and integration test suite Adds pytest coverage (~53%) across the pipeline: metrics, aggregation, models (myGNN/ConvAdapter, additive, gated, temporal), the significance module, the ALE interpretability functions, and utility helpers. An end-to-end integration test writes a synthetic panel, builds DataClass/GraphDataset on an identity graph, and trains myGNN with and without MinT reconciliation, with attention collection, and through the RollingTrainer. A single Optuna trial exercises the optimizer. Test and coverage artifacts are added to .gitignore. --- .gitignore | 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 ++++++++++++++++ 12 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 tests/test_aggregation.py create mode 100644 tests/test_extra_models.py create mode 100644 tests/test_helpers.py create mode 100644 tests/test_import.py create mode 100644 tests/test_integration_pipeline.py create mode 100644 tests/test_interpretability.py create mode 100644 tests/test_metrics.py create mode 100644 tests/test_models.py create mode 100644 tests/test_optimizer.py create mode 100644 tests/test_significance.py create mode 100644 tests/test_temporal.py diff --git a/.gitignore b/.gitignore index 043ca9a..00dc403 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,9 @@ logs/ # Sphinx build output and autosummary stubs (regenerated at build time) docs/build/ -docs/source/_autosummary/ \ No newline at end of file +docs/source/_autosummary/ +# Test / coverage artifacts +.coverage +coverage.xml +htmlcov/ +.pytest_cache/ diff --git a/tests/test_aggregation.py b/tests/test_aggregation.py new file mode 100644 index 0000000..bcb8bff --- /dev/null +++ b/tests/test_aggregation.py @@ -0,0 +1,34 @@ +"""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 new file mode 100644 index 0000000..69d197d --- /dev/null +++ b/tests/test_extra_models.py @@ -0,0 +1,37 @@ +"""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 new file mode 100644 index 0000000..0504718 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,75 @@ +"""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 new file mode 100644 index 0000000..12d4fb3 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,20 @@ +"""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 new file mode 100644 index 0000000..4f1f23a --- /dev/null +++ b/tests/test_integration_pipeline.py @@ -0,0 +1,132 @@ +"""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 new file mode 100644 index 0000000..2266083 --- /dev/null +++ b/tests/test_interpretability.py @@ -0,0 +1,116 @@ +"""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 new file mode 100644 index 0000000..4627589 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,34 @@ +"""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 new file mode 100644 index 0000000..ccffc1d --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,39 @@ +"""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 new file mode 100644 index 0000000..41ba886 --- /dev/null +++ b/tests/test_optimizer.py @@ -0,0 +1,55 @@ +"""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 new file mode 100644 index 0000000..e3cba24 --- /dev/null +++ b/tests/test_significance.py @@ -0,0 +1,43 @@ +"""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 new file mode 100644 index 0000000..0c1a5c4 --- /dev/null +++ b/tests/test_temporal.py @@ -0,0 +1,75 @@ +"""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) From d91db0ea39ad1be096804eb4e81110546a6f428e Mon Sep 17 00:00:00 2001 From: Eloi Campagne Date: Fri, 10 Jul 2026 13:12:39 +0200 Subject: [PATCH 3/4] ci: add GitHub Actions workflow with lint, tests, and coverage floor Runs on push and pull requests to main across Python 3.10-3.12: installs CPU PyTorch, error-only flake8, and pytest with a 50% coverage floor, plus an optional Codecov upload. --- .github/workflows/ci.yml | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f20c6a4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +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 }} From d9752f4e846323f3a410d586813f1eaba9040391 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:30:35 +0000 Subject: [PATCH 4/4] fix: remove invalid pandas drop axis usage --- graphtoolbox/data/preprocessing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphtoolbox/data/preprocessing.py b/graphtoolbox/data/preprocessing.py index 0a5ddb5..fdac327 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, axis=1) + new_df = new_df.drop(columns=var_names) return new_df