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.
- areafluforecast/__init__.py +28 -0
- areafluforecast/adjacency.py +172 -0
- areafluforecast/backtest.py +247 -0
- areafluforecast/checkpoint.py +41 -0
- areafluforecast/city.py +337 -0
- areafluforecast/cli.py +114 -0
- areafluforecast/climatology.py +78 -0
- areafluforecast/configs.py +225 -0
- areafluforecast/datasets.py +82 -0
- areafluforecast/engine.py +359 -0
- areafluforecast/forecast.py +175 -0
- areafluforecast/graphs.py +157 -0
- areafluforecast/intervals.py +410 -0
- areafluforecast/models.py +227 -0
- areafluforecast/plots.py +45 -0
- areafluforecast/progress.py +55 -0
- areafluforecast/sample_data/boston/README.md +15 -0
- areafluforecast/sample_data/boston/centroids.csv +15 -0
- areafluforecast/sample_data/boston/city.json +20 -0
- areafluforecast/sample_data/boston/edges.csv +45 -0
- areafluforecast/sample_data/boston/rates.csv +437 -0
- areafluforecast/sample_data/boston/weather.csv +6105 -0
- areafluforecast/sample_data/chicago/README.md +27 -0
- areafluforecast/sample_data/chicago/centroids.csv +59 -0
- areafluforecast/sample_data/chicago/city.json +15 -0
- areafluforecast/sample_data/chicago/edges.csv +116 -0
- areafluforecast/sample_data/chicago/rates.csv +366 -0
- areafluforecast/sample_data/chicago/weather.csv +21171 -0
- areafluforecast/samples.py +214 -0
- areafluforecast/scoring.py +178 -0
- areafluforecast/timing.py +142 -0
- areafluforecast/training.py +253 -0
- areafluforecast/tune.py +53 -0
- areafluforecast/weather.py +146 -0
- areafluforecast/windows.py +120 -0
- areafluforecast-0.1.0.dist-info/METADATA +251 -0
- areafluforecast-0.1.0.dist-info/RECORD +41 -0
- areafluforecast-0.1.0.dist-info/WHEEL +5 -0
- areafluforecast-0.1.0.dist-info/entry_points.txt +2 -0
- areafluforecast-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|