Files
CA/scripts/train_model.py

461 lines
16 KiB
Python
Raw Permalink Normal View History

#!/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()