feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality data with children's respiratory disease incidence across Wuhan. Approach: FastAPI backend serving PostGIS spatial queries, React frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline for multi-day (1d/3d/7d) risk prediction. Changes: - backend/ — FastAPI API with auth (JWT), alerts, risk analysis, geocoded case data, grid statistics, and report endpoints - frontend/ — React dashboard with interactive risk maps, alert monitoring, district comparison charts, and timeline player - models/ — SpatialTemporalGCN model with trained weights and ONNX export for inference - scripts/ — ETL pipeline for weather + medical data, grid generation, feature engineering, training, and daily inference - deploy/ — Docker Compose configs for backend, frontend, and MLflow - docs/ — API docs, deployment guide, user guide, and code review Impact: Enables spatial risk visualization, alert monitoring, and ML-driven health risk forecasting for environmental health teams.
This commit is contained in:
39
models/CLAUDE.md
Normal file
39
models/CLAUDE.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Models — SpatialTemporalGCN
|
||||
|
||||
## Architecture
|
||||
|
||||
Spatiotemporal GCN for Wuhan respiratory disease risk prediction:
|
||||
|
||||
- **Temporal**: Transformer encoder (3 layers, 4 heads) over 14-day weather windows
|
||||
- **Spatial**: 2-layer GCN (48→128→64) with elevation/population scaling
|
||||
- **Output**: `[N, 3]` risk probabilities (1-day, 3-day, 7-day horizons)
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
models/spatiotemporal_gcn/
|
||||
model.py # SpatialTemporalGCN class + ONNX export
|
||||
sampler.py # Graph sampling utilities
|
||||
best_model.pt # Trained weights (gitignored)
|
||||
```
|
||||
|
||||
## Input Shape
|
||||
|
||||
- Node features: `[N, T=14, 48]` — N nodes, 14 timesteps, 48 weather features
|
||||
- Edge index: `[2, E]` — sparse adjacency from 100m grid graph
|
||||
- Spatial scalars: elevation + population density per node
|
||||
|
||||
## Training
|
||||
|
||||
```bash
|
||||
python scripts/train_model.py # Full pipeline with MLflow tracking
|
||||
```
|
||||
|
||||
Baseline MAE targets: 1-day=0.2314, 3-day=0.5424, 7-day=0.6391
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Don't change model architecture without updating `scripts/train_model.py` and `scripts/inference_*.py`
|
||||
- Don't load `best_model.pt` without matching the exact `SpatialTemporalGCN` constructor args
|
||||
- Don't skip ONNX export validation after architecture changes
|
||||
- Don't train without MLflow logging
|
||||
BIN
models/spatiotemporal_gcn/best_model.pt
Normal file
BIN
models/spatiotemporal_gcn/best_model.pt
Normal file
Binary file not shown.
149
models/spatiotemporal_gcn/model.py
Normal file
149
models/spatiotemporal_gcn/model.py
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Spatial-Temporal Transformer + GCN Model for Wuhan Respiratory Disease Risk Prediction.
|
||||
Architecture per PRD acceptance criteria:
|
||||
- Temporal Transformer: 3 layers, 4 heads
|
||||
- GCN: 2 layers [GCNConv(48, 128) → ReLU → Dropout(0.2) → GCNConv(128, 64)]
|
||||
- Input: [N, T, 48] node features, [N, N] adjacency
|
||||
- Output: [N, 3] risk values (1-day, 3-day, 7-day)
|
||||
"""
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch_geometric.nn import GCNConv
|
||||
from torch_geometric.utils import add_self_loops
|
||||
|
||||
|
||||
class SpatialTemporalGCN(nn.Module):
|
||||
"""
|
||||
Spatial-Temporal Graph Convolutional Network with Transformer encoder.
|
||||
|
||||
Args:
|
||||
node_features (int): Number of input node features (default: 48)
|
||||
temporal_heads (int): Number of attention heads in Transformer (default: 4)
|
||||
temporal_layers (int): Number of Transformer layers (default: 3)
|
||||
gcn_hidden (int): Hidden dimension for GCN layers (default: 128)
|
||||
gcn_output (int): Output dimension of GCN (default: 64)
|
||||
dropout (float): Dropout rate (default: 0.2)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
node_features: int = 48,
|
||||
temporal_heads: int = 4,
|
||||
temporal_layers: int = 3,
|
||||
gcn_hidden: int = 128,
|
||||
gcn_output: int = 64,
|
||||
dropout: float = 0.2,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
# Temporal Transformer encoder
|
||||
encoder_layer = nn.TransformerEncoderLayer(
|
||||
d_model=node_features,
|
||||
nhead=temporal_heads,
|
||||
dim_feedforward=node_features * 4,
|
||||
dropout=dropout,
|
||||
activation='gelu',
|
||||
batch_first=True,
|
||||
norm_first=True,
|
||||
)
|
||||
self.temporal_transformer = nn.TransformerEncoder(
|
||||
encoder_layer,
|
||||
num_layers=temporal_layers,
|
||||
)
|
||||
|
||||
# GCN layers
|
||||
self.conv1 = GCNConv(node_features, gcn_hidden)
|
||||
self.conv2 = GCNConv(gcn_hidden, gcn_output)
|
||||
|
||||
self.dropout = nn.Dropout(dropout)
|
||||
self.relu = nn.ReLU()
|
||||
|
||||
# Output head: 3 risk horizons (1-day, 3-day, 7-day)
|
||||
self.risk_head = nn.Linear(gcn_output, 3)
|
||||
|
||||
def forward(self, x: torch.Tensor, edge_index: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Forward pass.
|
||||
|
||||
Args:
|
||||
x: Node features [N, T, 48] — N nodes, T time steps, 48 features
|
||||
edge_index: Graph connectivity [2, E]
|
||||
|
||||
Returns:
|
||||
Risk predictions [N, 3] — 1-day, 3-day, 7-day risk
|
||||
"""
|
||||
N, T, F = x.shape
|
||||
|
||||
# Temporal Transformer: process each node's time series
|
||||
# Input [N, T, 48] → Transformer → [N, T, 48]
|
||||
x_temporal = self.temporal_transformer(x)
|
||||
|
||||
# Take the last time step as the spatial representation
|
||||
x_spatial = x_temporal[:, -1, :] # [N, 48]
|
||||
|
||||
# Add self-loops for GCN
|
||||
edge_index, _ = add_self_loops(edge_index, num_nodes=N)
|
||||
|
||||
# GCN layer 1: [N, 48] → [N, 128]
|
||||
x_gcn = self.conv1(x_spatial, edge_index)
|
||||
x_gcn = self.relu(x_gcn)
|
||||
x_gcn = self.dropout(x_gcn)
|
||||
|
||||
# GCN layer 2: [N, 128] → [N, 64]
|
||||
x_gcn = self.conv2(x_gcn, edge_index)
|
||||
x_gcn = self.relu(x_gcn)
|
||||
x_gcn = self.dropout(x_gcn)
|
||||
|
||||
# Risk prediction head: [N, 64] → [N, 3]
|
||||
risk = self.risk_head(x_gcn)
|
||||
|
||||
# Clamp output to [0, 1] range (risk probability)
|
||||
risk = torch.sigmoid(risk)
|
||||
|
||||
return risk
|
||||
|
||||
|
||||
def export_onnx(model, output_path: str, node_features: int = 48):
|
||||
"""Export model to ONNX format for inference."""
|
||||
model.eval()
|
||||
N = 512 # Dummy batch size for export
|
||||
|
||||
# Dummy inputs matching expected shapes
|
||||
dummy_x = torch.randn(N, 14, node_features) # [N, T=14, 48]
|
||||
dummy_edge_index = torch.randint(0, N, (2, N * 4)) # Sparse edges
|
||||
|
||||
torch.onnx.export(
|
||||
model,
|
||||
(dummy_x, dummy_edge_index),
|
||||
output_path,
|
||||
input_names=['node_features', 'edge_index'],
|
||||
output_names=['risk'],
|
||||
dynamic_axes={
|
||||
'node_features': {0: 'num_nodes'},
|
||||
'edge_index': {1: 'num_edges'},
|
||||
'risk': {0: 'num_nodes'},
|
||||
},
|
||||
opset_version=17,
|
||||
)
|
||||
print(f"ONNX model exported to {output_path}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Quick forward pass test on dummy data
|
||||
model = SpatialTemporalGCN()
|
||||
|
||||
# Dummy input: [512 nodes, 14 time steps, 48 features]
|
||||
N, T, F = 512, 14, 48
|
||||
x = torch.randn(N, T, F)
|
||||
edge_index = torch.randint(0, N, (2, N * 4))
|
||||
|
||||
risk = model(x, edge_index)
|
||||
print(f"Input: {x.shape}")
|
||||
print(f"Edge index: {edge_index.shape}")
|
||||
print(f"Output risk: {risk.shape} — 1d:{risk[:,0].mean():.3f}, 3d:{risk[:,1].mean():.3f}, 7d:{risk[:,2].mean():.3f}")
|
||||
|
||||
# ONNX export
|
||||
export_onnx(model, 'models/spatiotemporal_gcn/model_1_3_7.onnx')
|
||||
BIN
models/spatiotemporal_gcn/model_1_3_7.onnx
Normal file
BIN
models/spatiotemporal_gcn/model_1_3_7.onnx
Normal file
Binary file not shown.
BIN
models/spatiotemporal_gcn/model_1_3_7.onnx.data
Normal file
BIN
models/spatiotemporal_gcn/model_1_3_7.onnx.data
Normal file
Binary file not shown.
244
models/spatiotemporal_gcn/sampler.py
Normal file
244
models/spatiotemporal_gcn/sampler.py
Normal file
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GraphSAINT-style Sampler for PyTorch Geometric.
|
||||
|
||||
Mini-batch sampler for large graphs (140k+ nodes) using neighbor sampling.
|
||||
Compatible with base PyG installation (no torch-sparse or pyg-lib required).
|
||||
|
||||
Usage:
|
||||
from models.spatiotemporal_gcn.sampler import GraphSAINTSampler
|
||||
|
||||
sampler = GraphSAINTSampler(
|
||||
data=data,
|
||||
batch_size=256,
|
||||
num_neighbors=[256, 128, 64]
|
||||
)
|
||||
"""
|
||||
|
||||
import torch
|
||||
from torch.utils.data import DataLoader, Dataset
|
||||
from torch_geometric.data import Data
|
||||
from torch_geometric.utils import subgraph
|
||||
|
||||
|
||||
class GraphSAINTDataset(Dataset):
|
||||
"""Dataset that samples node indices for mini-batching."""
|
||||
|
||||
def __init__(self, num_nodes: int, num_steps: int = 10):
|
||||
self.num_nodes = num_nodes
|
||||
self.num_steps = num_steps
|
||||
|
||||
def __len__(self):
|
||||
return self.num_steps
|
||||
|
||||
def __getitem__(self, idx):
|
||||
return torch.randint(0, self.num_nodes, (1,))
|
||||
|
||||
|
||||
class GraphSAINTSampler:
|
||||
"""
|
||||
GraphSAINT-style mini-batch sampler for large graphs.
|
||||
|
||||
Implements neighbor sampling to create subgraphs that fit in GPU memory.
|
||||
For each batch, samples seed nodes and their multi-hop neighbors.
|
||||
|
||||
Args:
|
||||
data: Full graph with edge_index and node features.
|
||||
batch_size: Seed nodes per batch (default: 256).
|
||||
num_neighbors: Neighbors per layer [layer0, layer1, ...].
|
||||
Default: [256, 128, 64] for 3-layer GCN.
|
||||
num_steps: Batches per epoch (default: 10).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: Data,
|
||||
batch_size: int = 256,
|
||||
num_neighbors: list = None,
|
||||
num_steps: int = 10,
|
||||
):
|
||||
if num_neighbors is None:
|
||||
num_neighbors = [256, 128, 64]
|
||||
|
||||
self.data = data
|
||||
self.batch_size = batch_size
|
||||
self.num_neighbors = num_neighbors
|
||||
self.num_steps = num_steps
|
||||
self.num_nodes = data.num_nodes
|
||||
self.edge_index = data.edge_index
|
||||
|
||||
if data.num_nodes > 100000:
|
||||
print(f"Sampler for large graph: {data.num_nodes:,} nodes")
|
||||
print(f" Batch size: {batch_size}")
|
||||
print(f" Layer depths: {num_neighbors}")
|
||||
|
||||
def _sample_neighbors(self, seed_nodes: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Sample multi-hop neighbors for seed nodes.
|
||||
|
||||
Args:
|
||||
seed_nodes: Initial node indices.
|
||||
|
||||
Returns:
|
||||
All sampled node indices (seed + neighbors).
|
||||
"""
|
||||
sampled = seed_nodes.unique()
|
||||
|
||||
for num_neighbors in self.num_neighbors:
|
||||
if len(sampled) == 0:
|
||||
break
|
||||
|
||||
mask = torch.isin(self.edge_index[0], sampled)
|
||||
neighbor_edges = self.edge_index[:, mask]
|
||||
|
||||
if neighbor_edges.shape[1] == 0:
|
||||
break
|
||||
|
||||
neighbors = neighbor_edges[1]
|
||||
|
||||
if len(neighbors) > num_neighbors:
|
||||
neighbors = neighbors[torch.randperm(len(neighbors))[:num_neighbors]]
|
||||
|
||||
sampled = torch.cat([sampled, neighbors]).unique()
|
||||
|
||||
return sampled
|
||||
|
||||
def _create_subgraph(self, node_indices: torch.Tensor) -> Data:
|
||||
edge_index, _, edge_mask = subgraph(
|
||||
node_indices,
|
||||
self.edge_index,
|
||||
relabel_nodes=True,
|
||||
return_edge_mask=True,
|
||||
)
|
||||
|
||||
subgraph_data = Data(
|
||||
x=self.data.x[node_indices],
|
||||
edge_index=edge_index,
|
||||
n_id=node_indices,
|
||||
)
|
||||
|
||||
if hasattr(self.data, 'y') and self.data.y is not None:
|
||||
subgraph_data.y = self.data.y[node_indices]
|
||||
|
||||
return subgraph_data
|
||||
|
||||
def __iter__(self):
|
||||
for _ in range(self.num_steps):
|
||||
seed_nodes = torch.randint(0, self.num_nodes, (self.batch_size,))
|
||||
sampled_nodes = self._sample_neighbors(seed_nodes)
|
||||
batch = self._create_subgraph(sampled_nodes)
|
||||
yield batch
|
||||
|
||||
def __len__(self):
|
||||
return self.num_steps
|
||||
|
||||
|
||||
class GraphSAINTConfig:
|
||||
"""Configuration for GraphSAINT-style sampling."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
batch_size: int = 256,
|
||||
num_neighbors: list = None,
|
||||
num_steps: int = 10,
|
||||
):
|
||||
self.batch_size = batch_size
|
||||
self.num_neighbors = num_neighbors if num_neighbors is not None else [256, 128, 64]
|
||||
self.num_steps = num_steps
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"GraphSAINTConfig(\n"
|
||||
f" batch_size={self.batch_size},\n"
|
||||
f" num_neighbors={self.num_neighbors},\n"
|
||||
f" num_steps={self.num_steps}\n"
|
||||
f")"
|
||||
)
|
||||
|
||||
|
||||
def create_graph_saint_loader(
|
||||
data: Data,
|
||||
batch_size: int = 256,
|
||||
num_neighbors: list = None,
|
||||
num_steps: int = 10,
|
||||
):
|
||||
"""
|
||||
Create a GraphSAINT-style sampler for large graph training.
|
||||
|
||||
Args:
|
||||
data: Full graph data with edge_index and features.
|
||||
batch_size: Seed nodes per batch (default: 256).
|
||||
num_neighbors: Layer-wise neighbor counts (default: [256, 128, 64]).
|
||||
num_steps: Batches per epoch (default: 10).
|
||||
|
||||
Returns:
|
||||
GraphSAINTSampler: Mini-batch iterator.
|
||||
"""
|
||||
return GraphSAINTSampler(
|
||||
data=data,
|
||||
batch_size=batch_size,
|
||||
num_neighbors=num_neighbors,
|
||||
num_steps=num_steps,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Example usage with dummy data."""
|
||||
print("=" * 60)
|
||||
print("GraphSAINT-style Sampler Demo")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nCreating dummy graph (10k nodes)...")
|
||||
N = 10000
|
||||
num_features = 48
|
||||
|
||||
edge_index = torch.randint(0, N, (2, N * 3))
|
||||
x = torch.randn(N, num_features)
|
||||
y = torch.randint(0, 3, (N,))
|
||||
|
||||
data = Data(x=x, y=y, edge_index=edge_index)
|
||||
print(f" Nodes: {data.num_nodes:,}")
|
||||
print(f" Edges: {data.num_edges:,}")
|
||||
print(f" Features: {data.num_node_features}")
|
||||
|
||||
print("\nCreating sampler...")
|
||||
config = GraphSAINTConfig(
|
||||
batch_size=256,
|
||||
num_neighbors=[256, 128, 64],
|
||||
num_steps=5,
|
||||
)
|
||||
print(config)
|
||||
|
||||
loader = create_graph_saint_loader(
|
||||
data=data,
|
||||
batch_size=config.batch_size,
|
||||
num_neighbors=config.num_neighbors,
|
||||
num_steps=config.num_steps,
|
||||
)
|
||||
|
||||
print(f"\nIterating through {len(loader)} batches...")
|
||||
for i, batch in enumerate(loader):
|
||||
print(f" Batch {i+1}/{len(loader)}:")
|
||||
print(f" Nodes: {batch.num_nodes:,}")
|
||||
print(f" Edges: {batch.num_edges:,}")
|
||||
print(f" Features: {batch.x.shape}")
|
||||
print(f" Node IDs: {batch.n_id.shape}")
|
||||
|
||||
if i >= 2:
|
||||
break
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Sampler ready for training!")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nFor your 140k node graph:")
|
||||
print(" 1. Load graph: data = load_your_graph()")
|
||||
print(" 2. Create loader: loader = create_graph_saint_loader(data, batch_size=256)")
|
||||
print(" 3. Train: for batch in loader: out = model(batch.x, batch.edge_index)")
|
||||
print("\nRecommended for 4GB GPU:")
|
||||
print(" - batch_size: 256")
|
||||
print(" - num_neighbors: [256, 128, 64]")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user