Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 }}
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,9 @@ logs/

# Sphinx build output and autosummary stubs (regenerated at build time)
docs/build/
docs/source/_autosummary/
docs/source/_autosummary/
# Test / coverage artifacts
.coverage
coverage.xml
htmlcov/
.pytest_cache/
2 changes: 1 addition & 1 deletion graphtoolbox/data/preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ dependencies = [
"torch-geometric",
"tqdm",
"cvxopt",
"basemap",
"optuna",
"optuna-dashboard",
"jupyter",
Expand All @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions tests/test_aggregation.py
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions tests/test_extra_models.py
Original file line number Diff line number Diff line change
@@ -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)
75 changes: 75 additions & 0 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions tests/test_import.py
Original file line number Diff line number Diff line change
@@ -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,
))
132 changes: 132 additions & 0 deletions tests/test_integration_pipeline.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading