areaFluForecast 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. areafluforecast/__init__.py +28 -0
  2. areafluforecast/adjacency.py +172 -0
  3. areafluforecast/backtest.py +247 -0
  4. areafluforecast/checkpoint.py +41 -0
  5. areafluforecast/city.py +337 -0
  6. areafluforecast/cli.py +114 -0
  7. areafluforecast/climatology.py +78 -0
  8. areafluforecast/configs.py +225 -0
  9. areafluforecast/datasets.py +82 -0
  10. areafluforecast/engine.py +359 -0
  11. areafluforecast/forecast.py +175 -0
  12. areafluforecast/graphs.py +157 -0
  13. areafluforecast/intervals.py +410 -0
  14. areafluforecast/models.py +227 -0
  15. areafluforecast/plots.py +45 -0
  16. areafluforecast/progress.py +55 -0
  17. areafluforecast/sample_data/boston/README.md +15 -0
  18. areafluforecast/sample_data/boston/centroids.csv +15 -0
  19. areafluforecast/sample_data/boston/city.json +20 -0
  20. areafluforecast/sample_data/boston/edges.csv +45 -0
  21. areafluforecast/sample_data/boston/rates.csv +437 -0
  22. areafluforecast/sample_data/boston/weather.csv +6105 -0
  23. areafluforecast/sample_data/chicago/README.md +27 -0
  24. areafluforecast/sample_data/chicago/centroids.csv +59 -0
  25. areafluforecast/sample_data/chicago/city.json +15 -0
  26. areafluforecast/sample_data/chicago/edges.csv +116 -0
  27. areafluforecast/sample_data/chicago/rates.csv +366 -0
  28. areafluforecast/sample_data/chicago/weather.csv +21171 -0
  29. areafluforecast/samples.py +214 -0
  30. areafluforecast/scoring.py +178 -0
  31. areafluforecast/timing.py +142 -0
  32. areafluforecast/training.py +253 -0
  33. areafluforecast/tune.py +53 -0
  34. areafluforecast/weather.py +146 -0
  35. areafluforecast/windows.py +120 -0
  36. areafluforecast-0.1.0.dist-info/METADATA +251 -0
  37. areafluforecast-0.1.0.dist-info/RECORD +41 -0
  38. areafluforecast-0.1.0.dist-info/WHEEL +5 -0
  39. areafluforecast-0.1.0.dist-info/entry_points.txt +2 -0
  40. areafluforecast-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. areafluforecast-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,28 @@
1
+ """areaFluForecast: neighborhood-scale influenza forecasts for health departments.
2
+
3
+ import areafluforecast as af
4
+
5
+ city = af.City.from_files("rates.csv", areas="areas.geojson", id_col="name")
6
+ city.fetch_weather() # optional
7
+ bt = af.backtest(city) # would it have worked here?
8
+ fc = af.forecast(city) # next 1-4 weeks, with a trust check
9
+ fc.to_csv("forecast.csv")
10
+
11
+ The models are the graph neural network evaluated in the accompanying paper, a
12
+ multivariate LSTM and Cola-GNN, each compared with persistence ("next week
13
+ looks like this week") on your own past seasons before you rely on it.
14
+ """
15
+
16
+ from .backtest import BacktestResult, backtest, latest_backtest
17
+ from .checkpoint import load_checkpoint, save_checkpoint
18
+ from .city import City, DataError, rates_from_counts, read_rates_csv
19
+ from .configs import PRESETS, ColaGNNConfig, GNNConfig, LSTMConfig, resolve
20
+ from .datasets import load_city, load_sample, save_city
21
+ from .forecast import ForecastResult, TrustWarning, forecast
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = ["City", "DataError", "backtest", "forecast", "load_sample", "load_city", "save_city", "BacktestResult",
26
+ "ForecastResult", "TrustWarning", "GNNConfig", "LSTMConfig", "ColaGNNConfig",
27
+ "PRESETS", "resolve", "latest_backtest", "save_checkpoint", "load_checkpoint",
28
+ "rates_from_counts", "read_rates_csv", "__version__"]
@@ -0,0 +1,172 @@
1
+ """Build the area graph from boundary polygons.
2
+
3
+ Two areas are neighbours when their boundaries share a border. The default rule
4
+ needs no GIS library: in a topologically clean boundary file (Census TIGER, most
5
+ city open-data portals) two polygons that share a border share the *identical*
6
+ vertex coordinates, so two areas are adjacent exactly when they share at least
7
+ two boundary vertices. Two rather than one, because a single shared vertex is a
8
+ corner touch, not a border.
9
+
10
+ For files that are not topologically clean (areas digitised separately, slivers
11
+ between them), `method="touches"` uses shapely with a snapping tolerance. Check
12
+ the result: the edges can change with the tolerance.
13
+
14
+ In the research behind this package, a graph derived this way from boundary
15
+ files forecast as well as a hand-drawn map, and the hand-drawn maps were only
16
+ ~85% right.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from collections import defaultdict
23
+ from pathlib import Path
24
+ from typing import Callable, Iterable
25
+
26
+ import pandas as pd
27
+
28
+ PRECISION = 6 # decimal degrees, ~0.1 m
29
+ MIN_SHARED_VERTICES = 2
30
+
31
+
32
+ def read_geojson(path: str | Path) -> list[dict]:
33
+ """The features of a GeoJSON FeatureCollection (lon/lat, EPSG:4326)."""
34
+ payload = json.loads(Path(path).read_text())
35
+ features = payload.get("features") if isinstance(payload, dict) else None
36
+ if not features:
37
+ raise ValueError(f"{path} has no GeoJSON features.")
38
+ return features
39
+
40
+
41
+ def _id_getter(id_of: str | Callable[[dict], object]) -> Callable[[dict], str]:
42
+ if callable(id_of):
43
+ return lambda feature: str(id_of(feature))
44
+
45
+ def get(feature: dict) -> str:
46
+ properties = feature.get("properties") or {}
47
+ if id_of not in properties:
48
+ raise KeyError(f"Feature has no property {id_of!r}. Available: {sorted(properties)}")
49
+ return str(properties[id_of])
50
+ return get
51
+
52
+
53
+ def _rings(geometry: dict) -> list[list]:
54
+ if geometry is None:
55
+ return []
56
+ kind = geometry["type"]
57
+ if kind == "Polygon":
58
+ return list(geometry["coordinates"])
59
+ if kind == "MultiPolygon":
60
+ return [ring for polygon in geometry["coordinates"] for ring in polygon]
61
+ if kind == "GeometryCollection":
62
+ return [ring for part in geometry["geometries"] for ring in _rings(part)]
63
+ raise ValueError(f"Unsupported geometry type {kind!r}; expected polygons.")
64
+
65
+
66
+ def _vertices(geometry: dict, precision: int) -> set[tuple[float, float]]:
67
+ return {(round(x, precision), round(y, precision))
68
+ for ring in _rings(geometry) for x, y, *_ in ring}
69
+
70
+
71
+ def adjacency_from_polygons(features: Iterable[dict], id_of: str | Callable[[dict], object], *,
72
+ method: str = "vertices", precision: int = PRECISION,
73
+ min_shared: int = MIN_SHARED_VERTICES,
74
+ tolerance: float = 1e-5) -> list[tuple[str, str]]:
75
+ """Sorted list of (a, b) neighbour pairs, a < b.
76
+
77
+ Several features may carry the same id (ZIP codes rolled up to an area, or
78
+ tracts to a neighbourhood); they are merged into one node, and borders inside
79
+ one node are ignored.
80
+
81
+ method="vertices" (default) is the shared-vertex rule above. method="touches"
82
+ needs shapely: two areas are neighbours when their polygons, each buffered by
83
+ `tolerance` degrees, overlap along a line rather than at a point.
84
+ """
85
+ get_id = _id_getter(id_of)
86
+ features = list(features)
87
+ if method == "vertices":
88
+ owners: dict[tuple[float, float], set[str]] = defaultdict(set)
89
+ for feature in features:
90
+ node = get_id(feature)
91
+ for vertex in _vertices(feature["geometry"], precision):
92
+ owners[vertex].add(node)
93
+ shared: dict[tuple[str, str], int] = defaultdict(int)
94
+ for nodes in owners.values():
95
+ ordered = sorted(nodes)
96
+ for i, a in enumerate(ordered):
97
+ for b in ordered[i + 1:]:
98
+ shared[(a, b)] += 1
99
+ return sorted(pair for pair, count in shared.items() if count >= min_shared)
100
+ if method == "touches":
101
+ return _touches_adjacency(features, get_id, tolerance)
102
+ raise ValueError(f"Unknown method {method!r}; expected 'vertices' or 'touches'.")
103
+
104
+
105
+ def _touches_adjacency(features, get_id, tolerance) -> list[tuple[str, str]]:
106
+ try:
107
+ from shapely.geometry import shape
108
+ from shapely.ops import unary_union
109
+ except ImportError as exc: # pragma: no cover
110
+ raise ImportError("method='touches' needs shapely: pip install 'areaFluForecast[geo]'") from exc
111
+ parts: dict[str, list] = defaultdict(list)
112
+ for feature in features:
113
+ parts[get_id(feature)].append(shape(feature["geometry"]))
114
+ merged = {node: unary_union(geoms).buffer(tolerance) for node, geoms in parts.items()}
115
+ nodes = sorted(merged)
116
+ pairs = []
117
+ for i, a in enumerate(nodes):
118
+ for b in nodes[i + 1:]:
119
+ overlap = merged[a].intersection(merged[b])
120
+ # A corner touch buffered by `tolerance` is a blob ~tolerance wide; a
121
+ # shared border is a strip much longer than it is wide.
122
+ if not overlap.is_empty and overlap.length > 8 * tolerance:
123
+ pairs.append((a, b))
124
+ return pairs
125
+
126
+
127
+ def centroids_from_polygons(features: Iterable[dict],
128
+ id_of: str | Callable[[dict], object]) -> dict[str, tuple[float, float]]:
129
+ """(lat, lon) per area: the area-weighted centroid of its outer rings.
130
+
131
+ Used to fetch weather at each area. Planar arithmetic on lon/lat is fine at
132
+ city scale.
133
+ """
134
+ get_id = _id_getter(id_of)
135
+ sums: dict[str, list[float]] = defaultdict(lambda: [0.0, 0.0, 0.0])
136
+ for feature in features:
137
+ node = get_id(feature)
138
+ geometry = feature["geometry"]
139
+ polygons = ([geometry["coordinates"]] if geometry["type"] == "Polygon"
140
+ else geometry["coordinates"] if geometry["type"] == "MultiPolygon" else [])
141
+ for polygon in polygons:
142
+ ring = polygon[0]
143
+ area = cx = cy = 0.0
144
+ for (x0, y0, *_), (x1, y1, *_) in zip(ring, ring[1:] + ring[:1]):
145
+ cross = x0 * y1 - x1 * y0
146
+ area += cross
147
+ cx += (x0 + x1) * cross
148
+ cy += (y0 + y1) * cross
149
+ if abs(area) < 1e-15:
150
+ continue
151
+ area *= 0.5
152
+ weight = abs(area)
153
+ sums[node][0] += weight * cx / (6.0 * area)
154
+ sums[node][1] += weight * cy / (6.0 * area)
155
+ sums[node][2] += weight
156
+ return {node: (lat / w, lon / w) for node, (lon, lat, w) in sums.items() if w > 0}
157
+
158
+
159
+ def read_edges_csv(path: str | Path) -> list[tuple[str, str]]:
160
+ """Edges from a CSV whose first two columns are the two area ids."""
161
+ frame = pd.read_csv(path, dtype=str)
162
+ if frame.shape[1] < 2:
163
+ raise ValueError(f"{path} needs at least two columns (the two areas of each edge).")
164
+ a, b = frame.columns[:2]
165
+ return sorted({tuple(sorted((str(x).strip(), str(y).strip())))
166
+ for x, y in zip(frame[a], frame[b]) if str(x).strip() != str(y).strip()})
167
+
168
+
169
+ def isolated(nodes: Iterable[str], edges: Iterable[tuple[str, str]]) -> list[str]:
170
+ """Nodes that appear in no edge."""
171
+ touched = {n for pair in edges for n in pair}
172
+ return [n for n in nodes if n not in touched]
@@ -0,0 +1,247 @@
1
+ """Would these models have worked here? Rolling-origin backtest on past seasons.
2
+
3
+ For each past season the models are trained only on weeks before it, then
4
+ forecast every week of it, exactly as they would have in real time (minus
5
+ reporting delays; see the README). Every model is compared with persistence
6
+ ("next week looks like this week") on the same weeks.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ from .city import City
20
+ from .configs import BASELINES, DEFAULT_MODELS, ModelConfig, is_learned, resolve, to_json
21
+ from .engine import run_model, units
22
+ from .progress import Progress
23
+ from .scoring import add_cell_scores, compare_to_reference
24
+ from .timing import describe as describe_timing, timing_table
25
+ from .tune import tune_config, tuning_units
26
+
27
+ SIGNIFICANCE = 0.05
28
+ DEFAULT_RUN_DIR = Path("areaflu_runs")
29
+
30
+
31
+ def _verdict(row) -> str:
32
+ if not np.isfinite(row["relative_wis"]):
33
+ return "not enough data"
34
+ clear = np.isfinite(row["p_holm"]) and row["p_holm"] < SIGNIFICANCE
35
+ if row["relative_wis"] < 1.0:
36
+ return "better than persistence" if clear else "not clearly better than persistence"
37
+ return "worse than persistence" if clear else "no better than persistence"
38
+
39
+
40
+ @dataclass
41
+ class BacktestResult:
42
+ city: str
43
+ predictions: pd.DataFrame
44
+ comparison: pd.DataFrame
45
+ scores: pd.DataFrame
46
+ timing: pd.DataFrame = field(default_factory=pd.DataFrame)
47
+ configs: dict = field(default_factory=dict)
48
+ tuning: list = field(default_factory=list)
49
+ meta: dict = field(default_factory=dict)
50
+ path: Path | None = None
51
+
52
+ def verdict(self, model: str, horizon: int) -> str | None:
53
+ rows = self.comparison[(self.comparison["model"] == model)
54
+ & (self.comparison["horizon"] == horizon)]
55
+ return None if rows.empty else str(rows.iloc[0]["verdict"])
56
+
57
+ def report(self, *, show: bool = True) -> str:
58
+ lines = [f"# Backtest: {self.city}", "",
59
+ f"Seasons: {', '.join(self.meta.get('season_labels', []))}. "
60
+ "Every number is from weeks the models never trained on.", "",
61
+ "**Relative WIS** is the model's weighted interval score divided by "
62
+ "persistence's on the same weeks: below 1 means it beat \"next week looks "
63
+ "like this week\". **p (Holm)** asks whether the weekly gap is bigger "
64
+ "than week-to-week noise, adjusted for the number of comparisons.", ""]
65
+ table = self.comparison.copy()
66
+ if not table.empty:
67
+ table = table.sort_values(["horizon", "relative_wis"])
68
+ lines.append("| weeks ahead | model | WIS | relative WIS | p (Holm) | verdict |")
69
+ lines.append("|---|---|---|---|---|---|")
70
+ for _, r in table.iterrows():
71
+ lines.append(f"| {r['horizon']} | {r['model']} | {r['wis']:.2f} | "
72
+ f"{r['relative_wis']:.2f} | {r['p_holm']:.3f} | {r['verdict']} |")
73
+ if not self.timing.empty:
74
+ lines += ["", "**Timing.** Is the forecast late? *Weeks behind the curve* is how far "
75
+ "the forecast would have to be slid earlier to fit best; persistence is exactly "
76
+ "h weeks behind by construction. See areafluforecast.timing for why every "
77
+ "model here tends to run late.", ""]
78
+ ordered = self.timing.sort_values(["horizon", "model"])
79
+ lines += [f"- {describe_timing(r)}" for _, r in ordered.iterrows()]
80
+ lines += ["", "Coverage of the 95% band (should be near 95%):", ""]
81
+ cover = (self.scores.groupby(["horizon", "model"])["coverage95"].mean() * 100).round(1)
82
+ lines += [f"- {m}, {h} wk: {v}%" for (h, m), v in cover.items()]
83
+ text = "\n".join(lines)
84
+ if show:
85
+ print(text)
86
+ return text
87
+
88
+ def save(self, path: str | Path) -> Path:
89
+ path = Path(path)
90
+ path.mkdir(parents=True, exist_ok=True)
91
+ self.predictions.to_csv(path / "predictions.csv", index=False)
92
+ self.comparison.to_csv(path / "comparison.csv", index=False)
93
+ self.scores.to_csv(path / "scores.csv", index=False)
94
+ self.timing.to_csv(path / "timing.csv", index=False)
95
+ if self.tuning:
96
+ pd.DataFrame(self.tuning).to_csv(path / "tuning.csv", index=False)
97
+ (path / "report.md").write_text(self.report(show=False) + "\n")
98
+ (path / "backtest.json").write_text(json.dumps(
99
+ {"city": self.city, "configs": self.configs, **self.meta}, indent=2, default=str))
100
+ self.path = path
101
+ return path
102
+
103
+ @classmethod
104
+ def load(cls, path: str | Path) -> "BacktestResult":
105
+ path = Path(path)
106
+ meta = json.loads((path / "backtest.json").read_text())
107
+ predictions = pd.read_csv(path / "predictions.csv",
108
+ parse_dates=["origin_date", "target_date"])
109
+ tuning_file = path / "tuning.csv"
110
+ timing_file = path / "timing.csv"
111
+ timing = pd.read_csv(timing_file) if timing_file.exists() else pd.DataFrame()
112
+ return cls(city=meta.pop("city"), predictions=predictions,
113
+ comparison=pd.read_csv(path / "comparison.csv"),
114
+ scores=pd.read_csv(path / "scores.csv"), timing=timing,
115
+ configs=meta.pop("configs", {}),
116
+ tuning=pd.read_csv(tuning_file).to_dict("records") if tuning_file.exists() else [],
117
+ meta=meta, path=path)
118
+
119
+
120
+ def _seasons(city: City, seasons) -> list[int]:
121
+ available = city.complete_seasons()
122
+ if seasons is None:
123
+ seasons = 2
124
+ if isinstance(seasons, int):
125
+ if not available:
126
+ raise ValueError(f"{city.label} has no complete season with two years of history "
127
+ "before it, so there is nothing to backtest yet.")
128
+ return available[-seasons:]
129
+ chosen = [int(s) for s in seasons]
130
+ return chosen
131
+
132
+
133
+ def backtest(city: City, models=DEFAULT_MODELS, *, seasons=None, horizons=(1, 2, 3, 4),
134
+ configs: dict | None = None, tune: bool = False, out_dir: str | Path | None = DEFAULT_RUN_DIR,
135
+ init_from: str | Path | None = None, zero_shot: bool = True,
136
+ progress: bool = True, device: str | None = None) -> BacktestResult:
137
+ """Train on the weeks before each past season and forecast that season.
138
+
139
+ models preset names ("gnn_st", "lstm", "colagnn", ...) or config objects.
140
+ Persistence and the same-week-last-year baseline are always added.
141
+ seasons an int (the last n complete seasons, default 2) or season years
142
+ (2023 = the season scored June 2023 - May 2024 for an Oct-Mar city).
143
+ configs {model name: {setting: value}} overrides, e.g. {"gnn_st": {"n_seeds": 3}}.
144
+ tune pick each learned model's settings per season from 12 candidates on
145
+ validation weeks (12x the training time), as the paper did.
146
+ init_from a saved graph-model checkpoint (e.g. one trained in another city) to
147
+ backtest alongside the other models, as "pretrained". zero_shot=True
148
+ applies it with no training here; False fine-tunes it on each season's
149
+ training weeks.
150
+ """
151
+ configs = configs or {}
152
+ resolved, init_for = {}, {}
153
+ for model in [*models, *BASELINES]:
154
+ config = resolve(model, configs.get(model if isinstance(model, str) else model.kind))
155
+ resolved[config.kind] = config
156
+ if init_from is not None:
157
+ from .checkpoint import load_checkpoint
158
+ loaded, members, _ = load_checkpoint(init_from)
159
+ resolved["pretrained"] = resolve(loaded, configs.get("pretrained"))
160
+ init_for["pretrained"] = members
161
+ years = _seasons(city, seasons)
162
+ horizons = tuple(int(h) for h in horizons)
163
+
164
+ total = sum((units(c) + (tuning_units(c) if tune else 0)) for c in resolved.values())
165
+ total *= len(years) * len(horizons)
166
+ bar = Progress(total, enabled=progress).start()
167
+ if progress:
168
+ print(city.summary(), flush=True)
169
+
170
+ frames, tuning_rows, chosen = [], [], {}
171
+ for year in years:
172
+ start, end = city.season_window(year)
173
+ label = city.season_label(year)
174
+ for horizon in horizons:
175
+ for name, config in resolved.items():
176
+ use = config
177
+ if tune and is_learned(config) and name not in init_for:
178
+ use, rows = tune_config(city, config, horizon=horizon, test_start=start,
179
+ test_end=end, on_unit=bar.step)
180
+ tuning_rows += [{"season": label, "horizon": horizon, "model": name, **r}
181
+ for r in rows]
182
+ output = run_model(city, use, horizon=horizon, test_start=start, test_end=end,
183
+ init_members=init_for.get(name),
184
+ zero_shot=zero_shot and name in init_for,
185
+ on_unit=lambda m, l=label, h=horizon: bar.step(f"{l} {h}wk {m}"),
186
+ device=device)
187
+ frame = output.test.assign(model=name, season=label)
188
+ frames.append(frame)
189
+ chosen[f"{name}|{label}|{horizon}"] = to_json(use)
190
+
191
+ predictions = pd.concat(frames, ignore_index=True)
192
+ result = _summarize(
193
+ city.name, predictions, configs=chosen, tuning=tuning_rows,
194
+ meta={"seasons": years, "season_labels": [city.season_label(y) for y in years],
195
+ "horizons": list(horizons), "models": list(resolved), "tuned": tune,
196
+ "created": time.strftime("%Y-%m-%dT%H:%M:%S"),
197
+ "last_week": str(city.rates.index.max().date())})
198
+ if out_dir is not None:
199
+ stamp = time.strftime("%Y%m%d-%H%M%S")
200
+ result.save(Path(out_dir) / city.name / f"backtest_{stamp}")
201
+ bar.finish(result.path)
202
+ if progress:
203
+ result.report()
204
+ return result
205
+
206
+
207
+ def _summarize(city: str, predictions: pd.DataFrame, *, configs: dict, tuning: list,
208
+ meta: dict) -> BacktestResult:
209
+ scored = add_cell_scores(predictions)
210
+ comparison = compare_to_reference(scored, "persistence")
211
+ if not comparison.empty:
212
+ comparison["verdict"] = comparison.apply(_verdict, axis=1)
213
+ scores = (scored.groupby(["model", "season", "horizon"])
214
+ .agg(n_cells=("wis", lambda s: int(np.isfinite(s).sum())), wis=("wis", "mean"),
215
+ mae=("abs_error", "mean"), coverage95=("covered95", "mean"),
216
+ coverage50=("covered50", "mean"))
217
+ .reset_index())
218
+ return BacktestResult(city=city, predictions=predictions, comparison=comparison,
219
+ scores=scores, timing=timing_table(predictions), configs=configs,
220
+ tuning=tuning, meta=meta)
221
+
222
+
223
+ def combine(parts: list[BacktestResult]) -> BacktestResult:
224
+ """One backtest from several run separately (e.g. one cluster task per season and
225
+ horizon). The comparison with persistence is recomputed on the pooled weeks, so
226
+ it matches what a single backtest() over all of them would report."""
227
+ if not parts:
228
+ raise ValueError("Nothing to combine.")
229
+ if len({p.city for p in parts}) != 1:
230
+ raise ValueError(f"Parts come from different cities: {sorted({p.city for p in parts})}.")
231
+ seasons = sorted({s for p in parts for s in p.meta.get("seasons", [])})
232
+ labels = sorted({s for p in parts for s in p.meta.get("season_labels", [])})
233
+ horizons = sorted({h for p in parts for h in p.meta.get("horizons", [])})
234
+ models = list(dict.fromkeys(m for p in parts for m in p.meta.get("models", [])))
235
+ configs = {k: v for p in parts for k, v in p.configs.items()}
236
+ meta = {**parts[0].meta, "seasons": seasons, "season_labels": labels,
237
+ "horizons": horizons, "models": models, "combined_from": len(parts),
238
+ "created": time.strftime("%Y-%m-%dT%H:%M:%S")}
239
+ predictions = pd.concat([p.predictions for p in parts], ignore_index=True)
240
+ return _summarize(parts[0].city, predictions, configs=configs,
241
+ tuning=[r for p in parts for r in p.tuning], meta=meta)
242
+
243
+
244
+ def latest_backtest(city: City, out_dir: str | Path = DEFAULT_RUN_DIR) -> BacktestResult | None:
245
+ root = Path(out_dir) / city.name
246
+ runs = sorted(root.glob("backtest_*/backtest.json")) if root.exists() else []
247
+ return BacktestResult.load(runs[-1].parent) if runs else None
@@ -0,0 +1,41 @@
1
+ """Saving trained models and loading them as a starting point elsewhere.
2
+
3
+ A checkpoint holds every ensemble member's weights plus the configuration and
4
+ the city it was trained on. Applying it to another city (with or without further
5
+ training) re-derives that city's normalisation, seasonal baseline and interval
6
+ calibration from the city's own data, as the research transfer experiments did.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import torch
14
+
15
+ from .configs import ModelConfig, from_json, to_json
16
+
17
+ FORMAT = "areafluforecast-checkpoint-1"
18
+
19
+
20
+ def save_checkpoint(path: str | Path, output, *, city_name: str, areas: list[str]) -> Path:
21
+ path = Path(path)
22
+ path.parent.mkdir(parents=True, exist_ok=True)
23
+ torch.save({"format": FORMAT, "config": to_json(output.config),
24
+ "member_state_dicts": output.members, "city": city_name, "areas": areas,
25
+ "meta": output.meta}, path)
26
+ return path
27
+
28
+
29
+ def load_checkpoint(path: str | Path) -> tuple[ModelConfig, list[dict], dict]:
30
+ """(config, member state_dicts, everything else) from a package or research checkpoint."""
31
+ payload = torch.load(path, map_location="cpu", weights_only=False)
32
+ if payload.get("format") == FORMAT:
33
+ return from_json(payload["config"]), payload["member_state_dicts"], payload
34
+ # A research run_gnn.py checkpoint.
35
+ members = payload.get("member_state_dicts") or [payload["model_state_dict"]]
36
+ from .configs import GNNConfig
37
+ train = (payload.get("experiment") or {}).get("train", {})
38
+ config = GNNConfig(kind="gnn_st_noadapt", adaptive=bool(train.get("adaptive", False)),
39
+ channels=int(train.get("channels", 24)),
40
+ dropout=float(train.get("dropout", 0.25)))
41
+ return config, members, payload