naidusai's picture
Initial deploy: MicroPlastiNet Dash dashboard (synthetic data, honest disclosure)
3a5b233 verified
Raw
History Blame Contribute Delete
13.6 kB
"""
train.py β€” Training Pipeline for M3 Graph GNN
==============================================
Trains GraphSAGE and GAT on node-level log-concentration regression
using a TEMPORAL SPLIT:
- Train: 2015–2020 (roughly 75% of records)
- Val: 2021–2022 (~15%)
- Test: 2023 (~10%)
This respects the arrow of time β€” the model is trained on historical data
and evaluated on future observations, the only meaningful evaluation for
environmental monitoring systems.
Saves:
- checkpoints/graphsage_best.pt
- checkpoints/gat_best.pt
- training_history.json
- classical_baseline.pkl
Usage:
python train.py
"""
import json
import time
from pathlib import Path
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.metrics import r2_score
# Local modules
import sys
sys.path.insert(0, str(Path(__file__).parent))
from model import GraphSAGERegressor, GATRegressor, ClassicalBaseline, build_node_regression_targets
SEED = 42
torch.manual_seed(SEED)
np.random.seed(SEED)
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Training device: {DEVICE}")
DATA_DIR = Path("/home/user/workspace/MicroPlastiNet/data/processed/m3")
CKPT_DIR = Path("/home/user/workspace/MicroPlastiNet/src/m3_graph_gnn/checkpoints")
CKPT_DIR.mkdir(parents=True, exist_ok=True)
# ──────────────────────────────────────────────────────────────────────────────
# Data loading
# ──────────────────────────────────────────────────────────────────────────────
def load_data():
print("Loading graph and concentration data...")
data = torch.load(DATA_DIR / "flow_graph.pt", weights_only=False)
data = data.to(DEVICE)
train_df = pd.read_csv(DATA_DIR / "train.csv")
val_df = pd.read_csv(DATA_DIR / "val.csv")
test_df = pd.read_csv(DATA_DIR / "test.csv")
station_ids = data.station_ids
# Per-station mean log-concentration for each split
y_train, mask_train = build_node_regression_targets(train_df, data, station_ids)
y_val, mask_val = build_node_regression_targets(val_df, data, station_ids)
y_test, mask_test = build_node_regression_targets(test_df, data, station_ids)
y_train = y_train.to(DEVICE)
y_val = y_val.to(DEVICE)
y_test = y_test.to(DEVICE)
mask_train = mask_train.to(DEVICE)
mask_val = mask_val.to(DEVICE)
mask_test = mask_test.to(DEVICE)
print(f" Train stations with data: {mask_train.sum().item()}")
print(f" Val stations with data: {mask_val.sum().item()}")
print(f" Test stations with data: {mask_test.sum().item()}")
return data, y_train, y_val, y_test, mask_train, mask_val, mask_test
# ──────────────────────────────────────────────────────────────────────────────
# Training loop (shared)
# ──────────────────────────────────────────────────────────────────────────────
def train_gnn(
model_name: str,
model: nn.Module,
data,
y_train, y_val,
mask_train, mask_val,
n_epochs: int = 300,
lr: float = 1e-3,
weight_decay: float = 1e-4,
patience: int = 40,
) -> dict:
"""Generic training loop for any GNN regressor."""
optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode="min", factor=0.5, patience=15
)
criterion = nn.MSELoss()
history = {
"train_loss": [], "val_loss": [],
"train_r2": [], "val_r2": [],
}
best_val_loss = float("inf")
best_epoch = 0
patience_counter = 0
print(f"\n{'='*60}")
print(f"Training {model_name} for {n_epochs} epochs")
print(f"{'='*60}")
t0 = time.time()
for epoch in range(1, n_epochs + 1):
# ── Train ──────────────────────────────────────────────────────────
model.train()
optimizer.zero_grad()
pred = model(data.x, data.edge_index, data.edge_attr)
loss = criterion(pred[mask_train], y_train[mask_train])
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
# ── Validate ───────────────────────────────────────────────────────
model.eval()
with torch.no_grad():
val_pred = model(data.x, data.edge_index, data.edge_attr)
val_loss = criterion(val_pred[mask_val], y_val[mask_val])
# RΒ² scores
train_r2 = r2_score(
y_train[mask_train].cpu().numpy(),
pred[mask_train].detach().cpu().numpy()
)
val_r2 = r2_score(
y_val[mask_val].cpu().numpy(),
val_pred[mask_val].cpu().numpy()
)
scheduler.step(val_loss)
history["train_loss"].append(float(loss))
history["val_loss"].append(float(val_loss))
history["train_r2"].append(float(train_r2))
history["val_r2"].append(float(val_r2))
# Early stopping
if val_loss < best_val_loss:
best_val_loss = float(val_loss)
best_epoch = epoch
patience_counter = 0
torch.save(model.state_dict(), CKPT_DIR / f"{model_name}_best.pt")
else:
patience_counter += 1
if epoch % 50 == 0 or patience_counter >= patience:
elapsed = time.time() - t0
print(
f" Epoch {epoch:4d} | "
f"train_loss={loss:.4f} | val_loss={val_loss:.4f} | "
f"train_RΒ²={train_r2:.4f} | val_RΒ²={val_r2:.4f} | "
f"t={elapsed:.1f}s"
)
if patience_counter >= patience:
print(f" Early stopping at epoch {epoch} (best was epoch {best_epoch})")
break
print(f" Best val loss: {best_val_loss:.4f} at epoch {best_epoch}")
history["best_epoch"] = best_epoch
history["best_val_loss"] = best_val_loss
return history
# ──────────────────────────────────────────────────────────────────────────────
# Test evaluation
# ──────────────────────────────────────────────────────────────────────────────
def evaluate_on_test(model_name, model, data, y_test, mask_test) -> dict:
"""Load best checkpoint and evaluate on test set."""
ckpt = CKPT_DIR / f"{model_name}_best.pt"
model.load_state_dict(torch.load(ckpt, map_location=DEVICE, weights_only=True))
model.eval()
with torch.no_grad():
pred = model(data.x, data.edge_index, data.edge_attr)
y_np = y_test[mask_test].cpu().numpy()
p_np = pred[mask_test].cpu().numpy()
test_r2 = r2_score(y_np, p_np)
test_mse = float(np.mean((y_np - p_np) ** 2))
test_mae = float(np.mean(np.abs(y_np - p_np)))
print(f"\n[{model_name}] TEST RESULTS:")
print(f" RΒ² = {test_r2:.4f}")
print(f" MSE = {test_mse:.4f}")
print(f" MAE = {test_mae:.4f}")
return {"r2": test_r2, "mse": test_mse, "mae": test_mae}
# ──────────────────────────────────────────────────────────────────────────────
# Classical baseline
# ──────────────────────────────────────────────────────────────────────────────
def train_classical_baseline(data, y_train, y_val, y_test, mask_train, mask_val, mask_test):
"""Compute centrality features and train Ridge regression baseline."""
import pickle
import networkx as nx
print("\nTraining classical baseline (centrality + Ridge regression)...")
# Reconstruct NetworkX graph
ei = data.edge_index.cpu().numpy()
G_nx = nx.DiGraph()
G_nx.add_nodes_from(range(200))
for i in range(ei.shape[1]):
G_nx.add_edge(int(ei[0, i]), int(ei[1, i]))
baseline = ClassicalBaseline(alpha=1.0)
centrality = baseline.compute_centrality_features(G_nx, num_nodes=200)
x_np = data.x.cpu().numpy()
y_train_np = y_train.cpu().numpy().ravel()
y_val_np = y_val.cpu().numpy().ravel()
y_test_np = y_test.cpu().numpy().ravel()
mask_train_np = mask_train.cpu().numpy()
mask_val_np = mask_val.cpu().numpy()
mask_test_np = mask_test.cpu().numpy()
# Fit on train
baseline.fit(x_np, centrality, y_train_np, mask_train_np)
# Evaluate
val_preds = baseline.predict(x_np, centrality, mask_val_np)
val_r2 = r2_score(y_val_np[mask_val_np], val_preds)
test_preds = baseline.predict(x_np, centrality, mask_test_np)
test_r2 = r2_score(y_test_np[mask_test_np], test_preds)
test_mse = float(np.mean((y_test_np[mask_test_np] - test_preds) ** 2))
test_mae = float(np.mean(np.abs(y_test_np[mask_test_np] - test_preds)))
print(f"\n[Classical Baseline] RESULTS:")
print(f" Val RΒ² = {val_r2:.4f}")
print(f" Test RΒ² = {test_r2:.4f}")
print(f" Test MSE = {test_mse:.4f}")
print(f" Test MAE = {test_mae:.4f}")
# Save baseline model
with open(CKPT_DIR / "classical_baseline.pkl", "wb") as f:
pickle.dump({"model": baseline, "centrality": centrality}, f)
return {
"val_r2": val_r2,
"test_r2": test_r2,
"test_mse": test_mse,
"test_mae": test_mae,
}
# ──────────────────────────────────────────────────────────────────────────────
# Main entry point
# ──────────────────────────────────────────────────────────────────────────────
def main():
# Load
data, y_train, y_val, y_test, mask_train, mask_val, mask_test = load_data()
# ── GraphSAGE ──────────────────────────────────────────────────────────
sage_model = GraphSAGERegressor(
in_channels=9, hidden_channels=128, num_layers=3, dropout=0.3
).to(DEVICE)
sage_history = train_gnn(
"graphsage", sage_model, data,
y_train, y_val, mask_train, mask_val,
n_epochs=300, lr=1e-3, patience=40
)
sage_test = evaluate_on_test("graphsage", sage_model, data, y_test, mask_test)
# ── GAT ───────────────────────────────────────────────────────────────
gat_model = GATRegressor(
in_channels=9, hidden_channels=64, heads=8, dropout=0.3
).to(DEVICE)
gat_history = train_gnn(
"gat", gat_model, data,
y_train, y_val, mask_train, mask_val,
n_epochs=300, lr=5e-4, patience=40
)
gat_test = evaluate_on_test("gat", gat_model, data, y_test, mask_test)
# ── Classical Baseline ─────────────────────────────────────────────────
classical_results = train_classical_baseline(
data, y_train, y_val, y_test, mask_train, mask_val, mask_test
)
# ── Save combined results ──────────────────────────────────────────────
results = {
"graphsage": {
"history": sage_history,
"test": sage_test,
},
"gat": {
"history": gat_history,
"test": gat_test,
},
"classical_baseline": classical_results,
}
out_path = CKPT_DIR / "training_results.json"
with open(out_path, "w") as f:
json.dump(results, f, indent=2)
print(f"\nAll results saved β†’ {out_path}")
print("\n" + "="*60)
print("SUMMARY")
print("="*60)
print(f" GraphSAGE Test RΒ²: {sage_test['r2']:.4f}")
print(f" GAT Test RΒ²: {gat_test['r2']:.4f}")
print(f" Classical Baseline RΒ²: {classical_results['test_r2']:.4f}")
print(f" GNN vs Classical gain: {((sage_test['r2'] - classical_results['test_r2']) / max(classical_results['test_r2'], 1e-6) * 100):.1f}%")
return results
if __name__ == "__main__":
main()