"""PyTorch learnable attention ensemble for DEB. Tiny MLP (~5K params) that learns per-city model weighting from cross-city training data. Trains in seconds on CPU, <1ms inference. Usage: python -m src.analysis.deb_attention --train python -m src.analysis.deb_attention --eval """ from __future__ import annotations import math import os import random import torch import torch.nn as nn import torch.nn.functional as F def _load_training_data(history, min_models=3): samples = [] for city, days in (history or {}).items(): for date_str, record in days.items(): forecasts = record.get("forecasts", {}) actual = record.get("actual_high") if actual is None: continue valid = {m: float(v) for m, v in forecasts.items() if v is not None} if len(valid) < min_models: continue samples.append({"city": city, "forecasts": valid, "actual": float(actual), "date": date_str}) return samples def _build_vocabs(samples, min_model_freq=2): from collections import Counter mf = Counter[str]() cities = set() for s in samples: cities.add(s["city"]) for m in s["forecasts"]: mf[m] += 1 mv = {m: i for i, m in enumerate(sorted([m for m, c in mf.items() if c >= min_model_freq], key=lambda m: (-mf[m], m)))} cv = {c: i for i, c in enumerate(sorted(cities))} return mv, cv class DebAttention(nn.Module): """Learnable attention: per-model scorer + city bias correction. Input: per-model forecast values + city id Output: weighted prediction (learned attention weights) + city bias """ def __init__(self, n_models, n_cities, d_hidden=32, dropout=0.1): super().__init__() self.n_models = n_models self.n_cities = n_cities # City embedding for context self.city_emb = nn.Embedding(n_cities + 1, 16, padding_idx=0) # Per-model scorer: [fc_norm, city_emb] → attention score self.scorer = nn.Sequential( nn.Linear(3 + 16, d_hidden), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_hidden, d_hidden // 2), nn.GELU(), nn.Linear(d_hidden // 2, 1), ) # City-level bias self.city_bias = nn.Embedding(n_cities + 1, 1, padding_idx=0) self.register_buffer("fc_mean", torch.tensor(0.0)) self.register_buffer("fc_std", torch.tensor(10.0)) def forward(self, forecasts, city_idx, mask): """(B, M), (B,), (B, M) → (B,), (B, M)""" batch, max_m = forecasts.shape fc_norm = (forecasts - self.fc_mean) / self.fc_std.clamp_min(0.1) # Consensus: trimmed mean of valid forecasts fc_masked = forecasts * mask.float() n_valid = mask.sum(dim=1, keepdim=True).clamp_min(1) consensus = fc_masked.sum(dim=1) / n_valid.squeeze() # (B,) # Deviation from consensus dev = fc_norm - consensus.unsqueeze(1) * mask.float() / self.fc_std.clamp_min(0.1) # Feature: [fc_norm, dev_from_consensus, is_extreme] is_extreme = (dev.abs() > 1.5).float() # flag if >1.5 std away feats = torch.stack([fc_norm, dev, is_extreme], dim=-1) # (B, M, 3) city = self.city_emb(city_idx.clamp(0, self.n_cities)) # (B, 16) city_tiled = city.unsqueeze(1).expand(-1, max_m, -1) # (B, M, 16) feats = torch.cat([feats, city_tiled], dim=-1) # (B, M, 19) scores = self.scorer(feats).squeeze(-1) # (B, M) scores = scores.masked_fill(~mask, float("-inf")) attn = F.softmax(scores, dim=-1) pred = (forecasts * attn).sum(dim=-1) bias = self.city_bias(city_idx.clamp(0, self.n_cities)).squeeze(-1) pred = pred + bias * 0.3 return pred, attn def train_model(history, *, epochs=200, batch_size=128, lr=0.002, patience=30, checkpoint_dir="data", seed=42): torch.manual_seed(seed) random.seed(seed) samples = _load_training_data(history) if len(samples) < 50: raise ValueError(f"Need >=50 samples, got {len(samples)}") mv, cv = _build_vocabs(samples) max_models = len(mv) + 4 samples.sort(key=lambda s: s["date"]) split = int(len(samples) * 0.8) train_s = samples[:split] val_s = samples[split:] all_fc = [v for s in train_s for v in s["forecasts"].values()] fc_mean = sum(all_fc) / len(all_fc) fc_std = max(1.0, math.sqrt(sum((v - fc_mean) ** 2 for v in all_fc) / len(all_fc))) def precompute(s_list): X_fc, X_cid, X_mask, Y = [], [], [], [] for s in s_list: fc_list = [s["forecasts"][m] for m in sorted(s["forecasts"]) if m in mv] n = len(fc_list) X_fc.append(fc_list + [0.0] * (max_models - n)) X_cid.append(cv.get(s["city"], 0)) X_mask.append([1.0] * n + [0.0] * (max_models - n)) Y.append(s["actual"]) return ( torch.tensor(X_fc, dtype=torch.float32), torch.tensor(X_cid, dtype=torch.long), torch.tensor(X_mask, dtype=torch.bool), torch.tensor(Y, dtype=torch.float32), ) train_X = precompute(train_s) val_X = precompute(val_s) train_loader = torch.utils.data.DataLoader(torch.utils.data.TensorDataset(*train_X), batch_size=batch_size, shuffle=True) val_loader = torch.utils.data.DataLoader(torch.utils.data.TensorDataset(*val_X), batch_size=batch_size * 2, shuffle=False) model = DebAttention(max_models, len(cv) + 1) model.fc_mean.fill_(fc_mean) model.fc_std.fill_(fc_std) opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-5) sched = torch.optim.lr_scheduler.ReduceLROnPlateau(opt, mode="min", factor=0.5, patience=10) best_val = float("inf") best_state = None no_imp = 0 print(f"Training: {len(train_s)} train, {len(val_s)} val, models={len(mv)}, cities={len(cv)}") for epoch in range(epochs): model.train() tr_loss = 0.0 for fc, cid, mask, y in train_loader: pred, _ = model(fc, cid, mask) loss = F.mse_loss(pred, y) opt.zero_grad() loss.backward() opt.step() tr_loss += loss.item() * fc.size(0) tr_loss /= len(train_X[0]) model.eval() val_loss = 0.0 with torch.no_grad(): for fc, cid, mask, y in val_loader: pred, _ = model(fc, cid, mask) loss = F.mse_loss(pred, y) val_loss += loss.item() * fc.size(0) val_loss /= len(val_X[0]) sched.step(val_loss) if val_loss < best_val: best_val = val_loss best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()} no_imp = 0 else: no_imp += 1 if epoch % 20 == 0 or epoch == epochs - 1: print(f" epoch {epoch:3d} tr={math.sqrt(tr_loss):.3f} val={math.sqrt(val_loss):.3f} lr={opt.param_groups[0]['lr']:.1e}") if no_imp >= patience: print(f" early stop {epoch}") break if best_state: model.load_state_dict(best_state) os.makedirs(checkpoint_dir, exist_ok=True) ckpt = os.path.join(checkpoint_dir, "deb_attention.pt") torch.save({"state": model.state_dict(), "mv": mv, "cv": cv, "fc_mean": fc_mean, "fc_std": fc_std, "max_m": max_models}, ckpt) print(f" saved {ckpt}") return model, mv, cv def load_model(path): ckpt = torch.load(path, map_location="cpu", weights_only=False) model = DebAttention(ckpt["max_m"], len(ckpt["cv"]) + 1) model.load_state_dict(ckpt["state"]) model.fc_mean.fill_(ckpt["fc_mean"]) model.fc_std.fill_(ckpt["fc_std"]) model.eval() return model, ckpt["mv"], ckpt["cv"] def predict(model, forecasts, city, mv, cv): model.eval() fc_list = [forecasts[m] for m in sorted(forecasts) if m in mv] if not fc_list: return None, {} n = len(fc_list) max_m = model.n_models fc_t = torch.zeros(max_m) mask_t = torch.zeros(max_m, dtype=torch.bool) fc_t[:n] = torch.tensor(fc_list) mask_t[:n] = True cid = cv.get(city, 0) with torch.no_grad(): pred, attn = model(fc_t.unsqueeze(0), torch.tensor([cid]), mask_t.unsqueeze(0)) pred = round(pred.item(), 1) models = [m for m in sorted(forecasts) if m in mv] weights = {models[i]: attn[0, i].item() for i in range(n)} total = sum(weights.values()) if total > 0: weights = {m: w / total for m, w in weights.items()} return pred, weights if __name__ == "__main__": import sys import statistics from src.analysis.deb_algorithm import load_history as lh, calculate_dynamic_weights from src.data_collection.city_registry import CITY_REGISTRY root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) hp = os.path.join(root, "data", "daily_records.json") history = lh(hp) if "--train" in sys.argv: m, mv, cv = train_model(history) print("Done.") elif "--eval" in sys.argv: ckpt = os.path.join(root, "data", "deb_attention.pt") if not os.path.exists(ckpt): print("No checkpoint. Run --train first.") sys.exit(1) m, mv, cv = load_model(ckpt) base: list = [] attn_errs: list = [] for city in [c for c in CITY_REGISTRY if c in history]: cd = history.get(city, {}) for d in sorted(cd.keys(), reverse=True)[:5]: rec = cd[d] fc = rec.get("forecasts", {}) actual = rec.get("actual_high") if not fc or actual is None: continue try: bp, _ = calculate_dynamic_weights(city, fc) if bp: base.append(abs(bp - float(actual))) except Exception: pass try: ap, _ = predict(m, fc, city, mv, cv) if ap: attn_errs.append(abs(ap - float(actual))) except Exception: pass if base and attn_errs: print(f"Baseline MAE: {statistics.mean(base):.3f}") print(f"Attention MAE: {statistics.mean(attn_errs):.3f}") else: print("python -m src.analysis.deb_attention --train | --eval")