#!/usr/bin/env python3 """ Training Pipeline for Spatial-Temporal Transformer + GCN Model. Simplified approach: use global weather mean per day as node features, scaled by per-node spatial features (elevation, population density). """ import os import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import warnings warnings.filterwarnings('ignore') import numpy as np import pandas as pd import torch import torch.nn as nn import torch.optim as optim import mlflow from pathlib import Path from datetime import datetime from models.spatiotemporal_gcn.model import SpatialTemporalGCN DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu') print(f"Using device: {DEVICE}") PROCESSED_DIR = Path('processed') MODEL_DIR = Path('models/spatiotemporal_gcn') MODEL_DIR.mkdir(exist_ok=True) LEARNING_RATE = 1e-4 WEIGHT_DECAY = 0.01 PATIENCE = 15 MAX_EPOCHS = 200 BATCH_SIZE = 1024 # Data split (medical data only available in December) TRAIN_START = '2022-12-01' TRAIN_END = '2022-12-31' VAL_START = '2023-12-01' VAL_END = '2023-12-31' # Baseline MAE from compute_baseline_mae.py BASELINE_MAE = {'1-day': 0.2314, '3-day': 0.5424, '7-day': 0.6391} SEED = 42 np.random.seed(SEED) torch.manual_seed(SEED) def load_all_data(): """Load all processed data.""" print("Loading data...") # Graph adj = np.load(PROCESSED_DIR / 'graph' / 'adjacency_matrix.npz') from scipy.sparse import csr_matrix sp_adj = csr_matrix((adj['data'], adj['indices'], adj['indptr']), shape=tuple(adj['shape'])) sp_adj_coo = sp_adj.tocoo() edge_index = torch.tensor( np.stack([sp_adj_coo.row, sp_adj_coo.col]), dtype=torch.long ) # Keep on CPU for subgraph operations # Node metadata nodes = pd.read_parquet(PROCESSED_DIR / 'graph' / 'node_features.parquet') n_nodes = len(nodes) print(f" Graph: {n_nodes} nodes, {edge_index.shape[1]} edges") # Weather lag features (station-level daily) lf = pd.read_parquet(PROCESSED_DIR / 'weather' / 'lag_features.parquet') lf['date'] = pd.to_datetime(lf['date']) lf = lf.sort_values('date') print(f" Weather: {len(lf)} records, {lf['station_id'].nunique()} stations") # Medical targets (district-level daily) out = pd.read_csv(PROCESSED_DIR / 'medical' / 'outpatient_daily.csv', parse_dates=['date']) inp = pd.read_csv(PROCESSED_DIR / 'medical' / 'inpatient_daily.csv', parse_dates=['date']) out['weight'] = 1 inp['weight'] = 3 combined = pd.concat([out, inp]) combined['weighted_cases'] = combined['case_count'] * combined['weight'] medical = combined.groupby(['date', 'district']).agg( weighted_cases=('weighted_cases', 'sum') ).reset_index() medical['risk'] = medical.groupby('district')['weighted_cases'].transform( lambda x: x / x.mean() ) print(f" Medical: {len(medical)} district-day records") return edge_index, nodes, lf, medical def build_global_weather_timeseries(lf): """ Build global mean weather per day: [T, 48] """ feat_cols = [c for c in lf.columns if c not in ('date', 'station_id')] daily_mean = lf.groupby('date')[feat_cols].mean() daily_mean = daily_mean.sort_index() dates = daily_mean.index.tolist() x_global = daily_mean.values.astype(np.float32) # [T, 48] return x_global, dates def build_node_targets(nodes, medical, dates): """ Build per-node risk target per day: [N, T] Use district-level medical risk, tiled to all nodes in district. District assignment from node lat/lon nearest centroid (simplified: use 'unknown'). For nodes with no district match, use global mean risk. """ n_nodes = len(nodes) n_days = len(dates) # Global mean risk per day global_risk = medical.groupby('date')['risk'].mean() global_risk_dict = global_risk.to_dict() # For each node, assign a district based on nearest centroid # (simplified: just use global risk for all nodes) targets = np.full((n_nodes, n_days), np.nan, dtype=np.float32) for i, d in enumerate(dates): if d in global_risk_dict: targets[:, i] = global_risk_dict[d] # Normalize per node node_means = np.nanmean(targets, axis=1, keepdims=True) node_means[node_means == 0] = 1 targets = targets / (node_means + 1e-8) return targets, dates def build_spatial_scalars(nodes): """ Pre-compute per-node spatial scaling factors (small, O(N)). Returns: elev_scale [N], pop_scale [N] """ elev = nodes['elevation_m'].values pop = nodes['pop_density'].values elev_norm = (elev - elev.mean()) / (elev.std() + 1e-8) pop_norm = (pop - pop.mean()) / (pop.std() + 1e-8) # Scaling factors elev_scale = 1.0 + 0.1 * elev_norm elev_scale = np.clip(elev_scale, 0.5, 2.0).astype(np.float32) # Pop scale (optional, can be 1.0 if not used) pop_scale = np.ones_like(elev_scale) # or add similar modulation if needed return elev_scale, pop_scale def get_batch_features(elev_scale, x_global, node_indices): """ Compute features for a batch of nodes on-the-fly. elev_scale: [N] pre-computed spatial scalars x_global: [T, F] global weather per day node_indices: list/array of node indices to fetch Returns: [len(node_indices), T, F] """ batch_size = len(node_indices) T, F = x_global.shape # Get spatial scales for batch batch_elev = elev_scale[node_indices] # Tile global weather for batch: [T, F] -> [batch, T, F] x = np.tile(x_global[np.newaxis, :, :], (batch_size, 1, 1)) # Apply spatial scaling x = x * batch_elev[:, np.newaxis, np.newaxis] return x.astype(np.float32) def make_time_windows_lazy(x_global, elev_scale, targets, dates, window=14): """ Create time window metadata without materializing full [N, T, F] tensor. Returns list of (time_start, node_indices) tuples for lazy feature fetching. x_global: [T, F] global weather elev_scale: [N] spatial scaling per node targets: [N, T] target values window: input window size """ N = len(elev_scale) T = x_global.shape[0] # Store window metadata: which time steps and which nodes windows_meta = [] for t in range(T - window + 1): # All nodes for this time window windows_meta.append({ 'time_start': t, 'time_end': t + window, 'target_time': t + window - 1, }) return windows_meta def train_epoch_lazy(model, windows_meta, x_global, elev_scale, y, edge_index, optimizer, criterion, batch_size=1024): """ Train one epoch using lazy feature computation. For each window, sample a batch of nodes and compute features on-the-fly. """ from torch_geometric.utils import subgraph model.train() total_loss = 0 n_batches = 0 n_windows = len(windows_meta) n_nodes = len(elev_scale) # Process each time window for window_meta in windows_meta: t_start = window_meta['time_start'] t_end = window_meta['time_end'] t_target = window_meta['target_time'] node_indices = np.random.choice(n_nodes, size=min(batch_size, n_nodes), replace=False) node_indices_torch = torch.tensor(node_indices, dtype=torch.long) x_batch = get_batch_features(elev_scale, x_global[t_start:t_end], node_indices) y_batch = y[node_indices, t_target] # Filter out NaN targets valid_mask = ~np.isnan(y_batch) if valid_mask.sum() == 0: continue # Extract subgraph and manually remap indices to ensure correctness sub_edge_index, edge_mask = subgraph(node_indices_torch, edge_index, relabel_nodes=False) # Create remapping: global_id -> local_idx (0 to batch_size-1) # Use index_put for efficient remapping local_idx = torch.arange(len(node_indices), dtype=torch.long) remap_tensor = torch.full((n_nodes,), -1, dtype=torch.long) remap_tensor[node_indices_torch] = local_idx # Remap edge indices sub_edge_index = remap_tensor[sub_edge_index] # Validate: all indices should be in [0, batch_size) assert sub_edge_index.min() >= 0 and sub_edge_index.max() < len(node_indices), \ f"Edge index out of bounds: min={sub_edge_index.min()}, max={sub_edge_index.max()}" x_batch = torch.FloatTensor(x_batch).to(DEVICE) y_batch = torch.FloatTensor(y_batch).to(DEVICE) sub_edge_index = sub_edge_index.to(DEVICE) # Forward pass - only compute loss on valid samples optimizer.zero_grad() out = model(x_batch, sub_edge_index) loss = criterion(out[valid_mask, 1], y_batch[valid_mask]) loss.backward() optimizer.step() total_loss += loss.item() n_batches += 1 return total_loss / max(n_batches, 1) def evaluate_lazy(model, x_global, elev_scale, y, edge_index, window=14): """ Evaluate MAE per horizon using lazy feature computation. """ from torch_geometric.utils import subgraph model.eval() T = x_global.shape[0] n_nodes = len(elev_scale) horizons = {'1-day': 1, '3-day': 3, '7-day': 7} results = {} with torch.no_grad(): for name, h in horizons.items(): if h > T - window: results[name] = float('nan') continue preds_all = [] acts_all = [] for t in range(window, T - h + 1, 5): node_indices = np.random.choice(n_nodes, size=min(100, n_nodes), replace=False) node_indices_torch = torch.tensor(node_indices, dtype=torch.long) x_win = get_batch_features(elev_scale, x_global[t-window:t], node_indices) x_win = torch.FloatTensor(x_win).to(DEVICE) y_actual = y[node_indices, t+h-1] y_actual = torch.FloatTensor(y_actual).to(DEVICE) # Extract subgraph and manually remap indices sub_edge_index, _ = subgraph(node_indices_torch, edge_index, relabel_nodes=False) # Remap global IDs to local indices local_idx = torch.arange(len(node_indices), dtype=torch.long) remap_tensor = torch.full((n_nodes,), -1, dtype=torch.long) remap_tensor[node_indices_torch] = local_idx sub_edge_index = remap_tensor[sub_edge_index] sub_edge_index = sub_edge_index.to(DEVICE) # Filter out NaN targets valid_mask = ~torch.isnan(y_actual) if valid_mask.sum() == 0: continue pred = model(x_win, sub_edge_index)[valid_mask, 1] preds_all.append(pred.mean()) acts_all.append(y_actual[valid_mask].mean()) if preds_all: preds = torch.stack(preds_all).mean() acts = torch.stack(acts_all).mean() results[name] = torch.mean(torch.abs(preds - acts)).item() else: results[name] = float('nan') return results def main(): print(f"\n=== Training Pipeline === {datetime.now()}") edge_index, nodes, lf, medical = load_all_data() n_nodes = len(nodes) # Build time series - global weather only (small: [T, 48]) x_global, weather_dates = build_global_weather_timeseries(lf) targets, _ = build_node_targets(nodes, medical, weather_dates) # Pre-compute spatial scalars (small: O(N)) elev_scale, pop_scale = build_spatial_scalars(nodes) print(f"\nGlobal weather: {x_global.shape} [T, F]") print(f"Targets: {targets.shape} [N, T]") print(f"Spatial scalars: {len(elev_scale)} nodes") # Align to training period dates_arr = pd.to_datetime(weather_dates) train_mask = (dates_arr >= TRAIN_START) & (dates_arr <= TRAIN_END) val_mask = (dates_arr >= VAL_START) & (dates_arr <= VAL_END) x_global_train = x_global[train_mask] y_train = targets[:, train_mask] x_global_val = x_global[val_mask] y_val = targets[:, val_mask] train_days = len(x_global_train) val_days = len(x_global_val) print(f"Train: {train_days} steps, Val: {val_days} steps") # Make training windows (metadata only, no large tensors) WINDOW = 14 print("Building training windows (metadata)...") windows_meta = make_time_windows_lazy(x_global_train, elev_scale, y_train, pd.to_datetime(weather_dates)[train_mask].tolist(), window=WINDOW) print(f" {len(windows_meta)} windows") # Model model = SpatialTemporalGCN( node_features=48, temporal_heads=4, temporal_layers=3, gcn_hidden=128, gcn_output=64, dropout=0.2 ).to(DEVICE) print(f"\nModel params: {sum(p.numel() for p in model.parameters()):,}") optimizer = optim.AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=WEIGHT_DECAY) criterion = nn.L1Loss() scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', patience=5, factor=0.5) # Truncate edge_index for batch processing edge_idx_trunc = edge_index[:, :min(edge_index.shape[1], n_nodes * 4)].contiguous() mlflow.set_experiment("wuhan_respiratory_training") with mlflow.start_run(run_name=f"train_{datetime.now().strftime('%Y%m%d_%H%M%S')}"): mlflow.log_params({ "learning_rate": LEARNING_RATE, "weight_decay": WEIGHT_DECAY, "patience": PATIENCE, "max_epochs": MAX_EPOCHS, "window": WINDOW, "n_nodes": n_nodes, "train_start": TRAIN_START, "train_end": TRAIN_END, "val_start": VAL_START, "val_end": VAL_END, "baseline_mae_1d": BASELINE_MAE['1-day'], "baseline_mae_3d": BASELINE_MAE['3-day'], "baseline_mae_7d": BASELINE_MAE['7-day'], }) best_val_mae = float('inf') patience_counter = 0 best_state = None for epoch in range(1, MAX_EPOCHS + 1): train_loss = train_epoch_lazy(model, windows_meta, x_global_train, elev_scale, y_train, edge_idx_trunc, optimizer, criterion, BATCH_SIZE) val_mae_h = evaluate_lazy(model, x_global_val, elev_scale, y_val, edge_idx_trunc, WINDOW) val_mae = np.nanmean(list(val_mae_h.values())) scheduler.step(val_mae) if epoch % 5 == 0 or val_mae < best_val_mae: print(f"Epoch {epoch:3d} | Loss: {train_loss:.4f} | Val MAE: {val_mae:.4f} " f"| 1d:{val_mae_h.get('1-day', 0):.4f} " f"3d:{val_mae_h.get('3-day', 0):.4f} " f"7d:{val_mae_h.get('7-day', 0):.4f}") mlflow.log_metrics({ "train_loss": train_loss, f"val_mae_1d": val_mae_h.get('1-day', float('nan')), f"val_mae_3d": val_mae_h.get('3-day', float('nan')), f"val_mae_7d": val_mae_h.get('7-day', float('nan')), }, step=epoch) if val_mae < best_val_mae: best_val_mae = val_mae best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()} patience_counter = 0 else: patience_counter += 1 if patience_counter >= PATIENCE: print(f"\nEarly stopping at epoch {epoch}") break # Save model.load_state_dict(best_state) torch.save(best_state, MODEL_DIR / 'best_model.pt') mlflow.log_artifact(MODEL_DIR / 'best_model.pt') # Beat-baseline check beat_count = sum( val_mae_h.get(h, float('inf')) < 0.9 * BASELINE_MAE[h] for h in ('1-day', '3-day', '7-day') ) print(f"\nBest Val MAE: {best_val_mae:.4f}") print(f"Baseline 1d/3d/7d: {BASELINE_MAE['1-day']:.4f}/{BASELINE_MAE['3-day']:.4f}/{BASELINE_MAE['7-day']:.4f}") print(f"Beats baseline at 0.9x: {beat_count}/3 horizons") print(f"\nDone! {datetime.now()}") if __name__ == '__main__': main()