fix: backtest/review findings in the flood-ML package
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 23s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.12) (push) Failing after 26s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Build Docker Image (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Integration Test with Services (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Staging (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Production (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Performance Test (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 13s
Documentation / Validate Documentation (push) Failing after 8s
Documentation / Generate API Documentation (push) Successful in 14s
Documentation / Build Sphinx Documentation (push) Successful in 18s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 3s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 23s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.12) (push) Failing after 26s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Build Docker Image (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Integration Test with Services (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Staging (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Production (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Performance Test (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 13s
Documentation / Validate Documentation (push) Failing after 8s
Documentation / Generate API Documentation (push) Successful in 14s
Documentation / Build Sphinx Documentation (push) Successful in 18s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 3s
From the adversarial review and threshold backtest (swarm verification): - predict.py: when a bundle's trained thresholds differ from the current config (deploy before retrain), skip its stale classifier heads and derive p_warning/p_danger from the regression + sigma against the CURRENT thresholds - the dashboard can no longer show contradictory old-threshold classifier output next to new-threshold stages - features.py: decouple the low-coverage regression-label rescue from the warning threshold (now the station's own p97.5 level); the old coupling silently dropped 34% of P.5's regression training rows and cost +46% MAE when its threshold rose - features.py: P.82 danger 3.80 -> 3.75 (3.80 was above the station's 8-year maximum of 3.78, so danger could never train or fire) - data.py / predict.py: anchor models/cache paths to the repo root; the relative paths silently returned zero rows when run from another CWD - annotate P.4A thresholds as low-confidence (11 supporting readings) 47 tests pass. Retrain required for the label-rescue and P.82 changes to reach the classifier heads.
This commit is contained in:
+18
-6
@@ -24,7 +24,9 @@ from .features import UPSTREAM_LEADS
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_API_URL = "http://100.81.167.42:8000"
|
DEFAULT_API_URL = "http://100.81.167.42:8000"
|
||||||
CACHE_DIR = Path("models/cache")
|
# Anchored to the repo root so training/prediction work from any CWD; a relative
|
||||||
|
# path here silently produced 0 rows when the CLI ran outside the repo root.
|
||||||
|
CACHE_DIR = Path(__file__).resolve().parents[2] / "models" / "cache"
|
||||||
_MEASUREMENT_COLUMNS = ["timestamp", "station_code", "water_level", "discharge"]
|
_MEASUREMENT_COLUMNS = ["timestamp", "station_code", "water_level", "discharge"]
|
||||||
|
|
||||||
|
|
||||||
@@ -95,7 +97,9 @@ def _fetch_from_db(
|
|||||||
return _normalize_long(df)
|
return _normalize_long(df)
|
||||||
|
|
||||||
|
|
||||||
def _fetch_station_from_api(api_url: str, station_code: str, hours: int, limit: int = 100000) -> pd.DataFrame:
|
def _fetch_station_from_api(
|
||||||
|
api_url: str, station_code: str, hours: int, limit: int = 100000
|
||||||
|
) -> pd.DataFrame:
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
@@ -133,7 +137,9 @@ def _fetch_from_api(
|
|||||||
return _normalize_long(df)
|
return _normalize_long(df)
|
||||||
|
|
||||||
|
|
||||||
def _write_cache(df: pd.DataFrame, cache_dir: Path, source: str, discharge_maybe_synthetic: bool) -> None:
|
def _write_cache(
|
||||||
|
df: pd.DataFrame, cache_dir: Path, source: str, discharge_maybe_synthetic: bool
|
||||||
|
) -> None:
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
for code, group in df.groupby("station_code"):
|
for code, group in df.groupby("station_code"):
|
||||||
path = cache_dir / f"{code}.csv.gz"
|
path = cache_dir / f"{code}.csv.gz"
|
||||||
@@ -184,17 +190,23 @@ def load_measurements(
|
|||||||
try:
|
try:
|
||||||
df = _fetch_from_db(resolved_db_url, stations, start, end)
|
df = _fetch_from_db(resolved_db_url, stations, start, end)
|
||||||
if use_cache:
|
if use_cache:
|
||||||
_write_cache(df, cache_dir, source="postgres", discharge_maybe_synthetic=False)
|
_write_cache(
|
||||||
|
df, cache_dir, source="postgres", discharge_maybe_synthetic=False
|
||||||
|
)
|
||||||
return df
|
return df
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f"PostgreSQL fetch failed, falling back to HTTP API: {error}")
|
logger.warning(
|
||||||
|
f"PostgreSQL fetch failed, falling back to HTTP API: {error}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
api_stations = stations or _default_stations()
|
api_stations = stations or _default_stations()
|
||||||
df = _fetch_from_api(api_url, api_stations, start, end)
|
df = _fetch_from_api(api_url, api_stations, start, end)
|
||||||
if not df.empty:
|
if not df.empty:
|
||||||
if use_cache:
|
if use_cache:
|
||||||
_write_cache(df, cache_dir, source="api", discharge_maybe_synthetic=True)
|
_write_cache(
|
||||||
|
df, cache_dir, source="api", discharge_maybe_synthetic=True
|
||||||
|
)
|
||||||
return df
|
return df
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f"HTTP API fetch failed: {error}")
|
logger.warning(f"HTTP API fetch failed: {error}")
|
||||||
|
|||||||
+56
-13
@@ -18,9 +18,8 @@ logger = logging.getLogger(__name__)
|
|||||||
# Static configuration
|
# Static configuration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Per-station (warning, danger) level thresholds in meters. "*" is the default
|
|
||||||
# applied to any station without an explicit override.
|
|
||||||
# Per-station (warning, danger) levels in metres on each gauge's own datum.
|
# Per-station (warning, danger) levels in metres on each gauge's own datum.
|
||||||
|
# "*" is the default applied to any station without an explicit override.
|
||||||
# Calibrated 2026-08-10 from the DB's discharge_percent (RID % of channel
|
# Calibrated 2026-08-10 from the DB's discharge_percent (RID % of channel
|
||||||
# capacity): warning = median level at 75-85% capacity, danger = median level
|
# capacity): warning = median level at 75-85% capacity, danger = median level
|
||||||
# at 95-105%. P.1 instead uses the official Chiang Mai inundation map keyed to
|
# at 95-105%. P.1 instead uses the official Chiang Mai inundation map keyed to
|
||||||
@@ -32,6 +31,9 @@ THRESHOLDS: Dict[str, Tuple[float, float]] = {
|
|||||||
"P.103": (5.95, 6.75),
|
"P.103": (5.95, 6.75),
|
||||||
"P.20": (2.35, 2.80),
|
"P.20": (2.35, 2.80),
|
||||||
"P.21": (3.20, 3.60),
|
"P.21": (3.20, 3.60),
|
||||||
|
# P.4A: LOW CONFIDENCE — its sensor was dead 2019-2024 and only 11 readings
|
||||||
|
# ever reached 3.40 m, so the capacity calibration rests on very few points.
|
||||||
|
# It only affects the heuristic sigmoid (P.4A is NOT_TRAINABLE).
|
||||||
"P.4A": (3.40, 3.90),
|
"P.4A": (3.40, 3.90),
|
||||||
"P.5": (4.55, 4.95),
|
"P.5": (4.55, 4.95),
|
||||||
"P.67": (2.45, 2.90),
|
"P.67": (2.45, 2.90),
|
||||||
@@ -39,7 +41,9 @@ THRESHOLDS: Dict[str, Tuple[float, float]] = {
|
|||||||
"P.76": (5.35, 5.45),
|
"P.76": (5.35, 5.45),
|
||||||
"P.77": (2.85, 3.35),
|
"P.77": (2.85, 3.35),
|
||||||
"P.81": (5.15, 6.30),
|
"P.81": (5.15, 6.30),
|
||||||
"P.82": (3.40, 3.80),
|
# P.82 never reached 100% capacity in the record (max level 3.78, max 96.4%);
|
||||||
|
# danger sits just below the observed maximum so the head can actually train.
|
||||||
|
"P.82": (3.40, 3.75),
|
||||||
"P.84": (3.45, 3.90),
|
"P.84": (3.45, 3.90),
|
||||||
"P.85": (2.90, 3.35),
|
"P.85": (2.90, 3.35),
|
||||||
"P.87": (3.75, 4.05),
|
"P.87": (3.75, 4.05),
|
||||||
@@ -73,8 +77,23 @@ BASIN_ANCHOR = "P.1"
|
|||||||
# lists, for each station, the (upstream_code, lead_hours) pairs to use as
|
# lists, for each station, the (upstream_code, lead_hours) pairs to use as
|
||||||
# routed-upstream input features when forecasting `station`.
|
# routed-upstream input features when forecasting `station`.
|
||||||
UPSTREAM_LEADS: Dict[str, List[Tuple[str, int]]] = {
|
UPSTREAM_LEADS: Dict[str, List[Tuple[str, int]]] = {
|
||||||
"P.1": [("P.103", 1), ("P.67", 7), ("P.21", 9), ("P.75", 12), ("P.4A", 12), ("P.92", 15), ("P.20", 17)],
|
"P.1": [
|
||||||
"P.103": [("P.67", 6), ("P.21", 8), ("P.75", 11), ("P.4A", 11), ("P.92", 14), ("P.20", 16)],
|
("P.103", 1),
|
||||||
|
("P.67", 7),
|
||||||
|
("P.21", 9),
|
||||||
|
("P.75", 12),
|
||||||
|
("P.4A", 12),
|
||||||
|
("P.92", 15),
|
||||||
|
("P.20", 17),
|
||||||
|
],
|
||||||
|
"P.103": [
|
||||||
|
("P.67", 6),
|
||||||
|
("P.21", 8),
|
||||||
|
("P.75", 11),
|
||||||
|
("P.4A", 11),
|
||||||
|
("P.92", 14),
|
||||||
|
("P.20", 16),
|
||||||
|
],
|
||||||
"P.21": [("P.67", 1), ("P.75", 3), ("P.4A", 3), ("P.92", 6), ("P.20", 8)],
|
"P.21": [("P.67", 1), ("P.75", 3), ("P.4A", 3), ("P.92", 6), ("P.20", 8)],
|
||||||
"P.67": [("P.75", 5), ("P.4A", 5), ("P.92", 8), ("P.20", 10)],
|
"P.67": [("P.75", 5), ("P.4A", 5), ("P.92", 8), ("P.20", 10)],
|
||||||
"P.75": [("P.92", 3), ("P.20", 5)],
|
"P.75": [("P.92", 3), ("P.20", 5)],
|
||||||
@@ -140,9 +159,13 @@ def make_hourly_grid(df_long: pd.DataFrame) -> HourlyGrid:
|
|||||||
df["timestamp"] = pd.to_datetime(df["timestamp"]).dt.floor("h")
|
df["timestamp"] = pd.to_datetime(df["timestamp"]).dt.floor("h")
|
||||||
df = df.drop_duplicates(subset=["station_code", "timestamp"], keep="last")
|
df = df.drop_duplicates(subset=["station_code", "timestamp"], keep="last")
|
||||||
|
|
||||||
full_index = pd.date_range(df["timestamp"].min(), df["timestamp"].max(), freq="h", name="timestamp")
|
full_index = pd.date_range(
|
||||||
|
df["timestamp"].min(), df["timestamp"].max(), freq="h", name="timestamp"
|
||||||
|
)
|
||||||
|
|
||||||
wide = df.pivot(index="timestamp", columns="station_code", values=["water_level", "discharge"])
|
wide = df.pivot(
|
||||||
|
index="timestamp", columns="station_code", values=["water_level", "discharge"]
|
||||||
|
)
|
||||||
wide = wide.reorder_levels([1, 0], axis=1).sort_index(axis=1)
|
wide = wide.reorder_levels([1, 0], axis=1).sort_index(axis=1)
|
||||||
wide = wide.reindex(full_index)
|
wide = wide.reindex(full_index)
|
||||||
|
|
||||||
@@ -154,7 +177,9 @@ def make_hourly_grid(df_long: pd.DataFrame) -> HourlyGrid:
|
|||||||
return HourlyGrid(observed=observed, filled=filled, mask=mask)
|
return HourlyGrid(observed=observed, filled=filled, mask=mask)
|
||||||
|
|
||||||
|
|
||||||
def _series(grid_frame: pd.DataFrame, station: str, field: str, index: pd.Index) -> pd.Series:
|
def _series(
|
||||||
|
grid_frame: pd.DataFrame, station: str, field: str, index: pd.Index
|
||||||
|
) -> pd.Series:
|
||||||
"""Fetch a (station, field) column, or an all-NaN series if the station is absent."""
|
"""Fetch a (station, field) column, or an all-NaN series if the station is absent."""
|
||||||
if (station, field) in grid_frame.columns:
|
if (station, field) in grid_frame.columns:
|
||||||
return grid_frame[(station, field)]
|
return grid_frame[(station, field)]
|
||||||
@@ -214,7 +239,9 @@ def build_features(grid: HourlyGrid, station: str) -> pd.DataFrame:
|
|||||||
cols[f"{upstream_code}_level_lag_{lead_h}"] = u_level.shift(lead_h)
|
cols[f"{upstream_code}_level_lag_{lead_h}"] = u_level.shift(lead_h)
|
||||||
cols[f"{upstream_code}_level_lag_{lead_h + 3}"] = u_level.shift(lead_h + 3)
|
cols[f"{upstream_code}_level_lag_{lead_h + 3}"] = u_level.shift(lead_h + 3)
|
||||||
cols[f"{upstream_code}_rise_6_lag_{lead_h}"] = u_rise_6.shift(lead_h)
|
cols[f"{upstream_code}_rise_6_lag_{lead_h}"] = u_rise_6.shift(lead_h)
|
||||||
cols[f"{upstream_code}_rollmax_24_lag_{near_lag}"] = u_rollmax_24.shift(near_lag)
|
cols[f"{upstream_code}_rollmax_24_lag_{near_lag}"] = u_rollmax_24.shift(
|
||||||
|
near_lag
|
||||||
|
)
|
||||||
|
|
||||||
if station != BASIN_ANCHOR:
|
if station != BASIN_ANCHOR:
|
||||||
p1_level = _series(grid.filled, BASIN_ANCHOR, "water_level", idx)
|
p1_level = _series(grid.filled, BASIN_ANCHOR, "water_level", idx)
|
||||||
@@ -244,11 +271,23 @@ def _future_window_stats(col: pd.Series, horizon_h: int) -> Tuple[pd.Series, pd.
|
|||||||
return fut_max, fut_count
|
return fut_max, fut_count
|
||||||
|
|
||||||
|
|
||||||
def build_labels(grid: HourlyGrid, station: str, horizons: Tuple[int, ...] = (6, 12, 24)) -> pd.DataFrame:
|
def build_labels(
|
||||||
|
grid: HourlyGrid, station: str, horizons: Tuple[int, ...] = (6, 12, 24)
|
||||||
|
) -> pd.DataFrame:
|
||||||
"""Build max-level and threshold-exceedance labels for one target station."""
|
"""Build max-level and threshold-exceedance labels for one target station."""
|
||||||
idx = grid.observed.index
|
idx = grid.observed.index
|
||||||
observed_level = _series(grid.observed, station, "water_level", idx)
|
observed_level = _series(grid.observed, station, "water_level", idx)
|
||||||
warn_thr, danger_thr = get_thresholds(station)
|
warn_thr, danger_thr = get_thresholds(station)
|
||||||
|
# Low-coverage windows are still usable regression labels when they contain a
|
||||||
|
# rare high reading. Anchor "rare" to the station's own distribution (p97.5),
|
||||||
|
# NOT to warn_thr: coupling it to the configurable threshold made raising a
|
||||||
|
# station's threshold silently shrink its regression training set (P.5 lost
|
||||||
|
# 34% of rows and +46% MAE when its warning went 3.0 -> 4.55).
|
||||||
|
rescue_thr = (
|
||||||
|
float(observed_level.quantile(0.975))
|
||||||
|
if observed_level.notna().any()
|
||||||
|
else np.inf
|
||||||
|
)
|
||||||
|
|
||||||
out: Dict[str, pd.Series] = {}
|
out: Dict[str, pd.Series] = {}
|
||||||
for horizon_h in horizons:
|
for horizon_h in horizons:
|
||||||
@@ -264,7 +303,7 @@ def build_labels(grid: HourlyGrid, station: str, horizons: Tuple[int, ...] = (6,
|
|||||||
exceed_danger[fut_max >= danger_thr] = 1.0
|
exceed_danger[fut_max >= danger_thr] = 1.0
|
||||||
exceed_danger[enough_cov & exceed_danger.isna()] = 0.0
|
exceed_danger[enough_cov & exceed_danger.isna()] = 0.0
|
||||||
|
|
||||||
max_level_valid = fut_max.where(enough_cov | (fut_max >= warn_thr))
|
max_level_valid = fut_max.where(enough_cov | (fut_max >= rescue_thr))
|
||||||
|
|
||||||
out[f"max_level_{horizon_h}"] = max_level_valid
|
out[f"max_level_{horizon_h}"] = max_level_valid
|
||||||
out[f"exceed_warn_{horizon_h}"] = exceed_warn
|
out[f"exceed_warn_{horizon_h}"] = exceed_warn
|
||||||
@@ -297,13 +336,17 @@ def build_matrix(
|
|||||||
Y = Y.loc[keep]
|
Y = Y.loc[keep]
|
||||||
|
|
||||||
positive_counts = {
|
positive_counts = {
|
||||||
col: int(Y[col].sum()) for col in Y.columns if col.startswith("exceed_") and Y[col].notna().any()
|
col: int(Y[col].sum())
|
||||||
|
for col in Y.columns
|
||||||
|
if col.startswith("exceed_") and Y[col].notna().any()
|
||||||
}
|
}
|
||||||
meta = {
|
meta = {
|
||||||
"station_code": station,
|
"station_code": station,
|
||||||
"n_rows": int(len(X)),
|
"n_rows": int(len(X)),
|
||||||
"span": (
|
"span": (
|
||||||
(X.index.min().isoformat(), X.index.max().isoformat()) if len(X) else (None, None)
|
(X.index.min().isoformat(), X.index.max().isoformat())
|
||||||
|
if len(X)
|
||||||
|
else (None, None)
|
||||||
),
|
),
|
||||||
"positive_counts": positive_counts,
|
"positive_counts": positive_counts,
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-15
@@ -20,6 +20,9 @@ from . import features
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Anchored to the repo root so the API finds trained bundles regardless of CWD.
|
||||||
|
DEFAULT_MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
|
||||||
|
|
||||||
DEFAULT_HORIZONS: Tuple[int, ...] = (6, 12, 24)
|
DEFAULT_HORIZONS: Tuple[int, ...] = (6, 12, 24)
|
||||||
STALE_AFTER_H = 6.0
|
STALE_AFTER_H = 6.0
|
||||||
HEURISTIC_SIGMA = 0.3
|
HEURISTIC_SIGMA = 0.3
|
||||||
@@ -60,7 +63,9 @@ def _readings_to_long_df(readings_by_station: Dict[str, List[dict]]) -> pd.DataF
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if not rows:
|
if not rows:
|
||||||
return pd.DataFrame(columns=["timestamp", "station_code", "water_level", "discharge"])
|
return pd.DataFrame(
|
||||||
|
columns=["timestamp", "station_code", "water_level", "discharge"]
|
||||||
|
)
|
||||||
df = pd.DataFrame(rows)
|
df = pd.DataFrame(rows)
|
||||||
return df.dropna(subset=["timestamp"])
|
return df.dropna(subset=["timestamp"])
|
||||||
|
|
||||||
@@ -90,8 +95,12 @@ def _heuristic_forecast(
|
|||||||
results = []
|
results = []
|
||||||
for horizon_h in horizons:
|
for horizon_h in horizons:
|
||||||
predicted_max = max(current_level + rate * horizon_h * 0.7, current_level)
|
predicted_max = max(current_level + rate * horizon_h * 0.7, current_level)
|
||||||
p_warning = _clip_probability(_sigmoid_probability(predicted_max, warn_thr, HEURISTIC_SIGMA))
|
p_warning = _clip_probability(
|
||||||
p_danger = _clip_probability(_sigmoid_probability(predicted_max, danger_thr, HEURISTIC_SIGMA))
|
_sigmoid_probability(predicted_max, warn_thr, HEURISTIC_SIGMA)
|
||||||
|
)
|
||||||
|
p_danger = _clip_probability(
|
||||||
|
_sigmoid_probability(predicted_max, danger_thr, HEURISTIC_SIGMA)
|
||||||
|
)
|
||||||
p_danger = min(p_danger, p_warning)
|
p_danger = min(p_danger, p_warning)
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
@@ -121,12 +130,28 @@ def _model_forecast(
|
|||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
warn_thr = bundle["thresholds"]["warning"]
|
warn_thr = bundle["thresholds"]["warning"]
|
||||||
danger_thr = bundle["thresholds"]["danger"]
|
danger_thr = bundle["thresholds"]["danger"]
|
||||||
|
# If thresholds changed since this bundle was trained, its classifier heads
|
||||||
|
# answer the OLD question (labels for the old levels) while stages/config use
|
||||||
|
# the new ones — a silent contradiction on the dashboard. Until a retrain,
|
||||||
|
# answer the current question consistently: use the regression + sigma against
|
||||||
|
# the configured thresholds and skip the stale heads.
|
||||||
|
cfg_warn, cfg_danger = features.get_thresholds(station_code)
|
||||||
|
thresholds_stale = (cfg_warn, cfg_danger) != (warn_thr, danger_thr)
|
||||||
|
if thresholds_stale:
|
||||||
|
logger.warning(
|
||||||
|
f"{station_code}: bundle thresholds ({warn_thr}, {danger_thr}) differ from "
|
||||||
|
f"configured ({cfg_warn}, {cfg_danger}); using regression-derived probabilities "
|
||||||
|
"until the model is retrained"
|
||||||
|
)
|
||||||
|
warn_thr, danger_thr = cfg_warn, cfg_danger
|
||||||
|
|
||||||
feature_row = features.build_features(grid, station_code).loc[[as_of]]
|
feature_row = features.build_features(grid, station_code).loc[[as_of]]
|
||||||
expected_columns = bundle["feature_names"]
|
expected_columns = bundle["feature_names"]
|
||||||
missing = [c for c in expected_columns if c not in feature_row.columns]
|
missing = [c for c in expected_columns if c not in feature_row.columns]
|
||||||
if missing:
|
if missing:
|
||||||
logger.error(f"Feature mismatch for {station_code} (missing {missing}); falling back to heuristic")
|
logger.error(
|
||||||
|
f"Feature mismatch for {station_code} (missing {missing}); falling back to heuristic"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
feature_row = feature_row[expected_columns]
|
feature_row = feature_row[expected_columns]
|
||||||
|
|
||||||
@@ -139,13 +164,17 @@ def _model_forecast(
|
|||||||
predicted_max = max(float(reg.predict(feature_row)[0]), current_level)
|
predicted_max = max(float(reg.predict(feature_row)[0]), current_level)
|
||||||
sigma_h = bundle["sigma"].get(horizon_h, HEURISTIC_SIGMA)
|
sigma_h = bundle["sigma"].get(horizon_h, HEURISTIC_SIGMA)
|
||||||
|
|
||||||
warn_head = bundle["heads"].get(f"warn_{horizon_h}")
|
warn_head = (
|
||||||
|
None if thresholds_stale else bundle["heads"].get(f"warn_{horizon_h}")
|
||||||
|
)
|
||||||
if warn_head is not None:
|
if warn_head is not None:
|
||||||
p_warning = float(warn_head.predict_proba(feature_row)[0][1])
|
p_warning = float(warn_head.predict_proba(feature_row)[0][1])
|
||||||
else:
|
else:
|
||||||
p_warning = _sigmoid_probability(predicted_max, warn_thr, sigma_h)
|
p_warning = _sigmoid_probability(predicted_max, warn_thr, sigma_h)
|
||||||
|
|
||||||
danger_head = bundle["heads"].get(f"danger_{horizon_h}")
|
danger_head = (
|
||||||
|
None if thresholds_stale else bundle["heads"].get(f"danger_{horizon_h}")
|
||||||
|
)
|
||||||
if danger_head is not None:
|
if danger_head is not None:
|
||||||
p_danger = float(danger_head.predict_proba(feature_row)[0][1])
|
p_danger = float(danger_head.predict_proba(feature_row)[0][1])
|
||||||
else:
|
else:
|
||||||
@@ -209,19 +238,35 @@ def _forecast_station(
|
|||||||
|
|
||||||
warn_thr, danger_thr = features.get_thresholds(station_code)
|
warn_thr, danger_thr = features.get_thresholds(station_code)
|
||||||
t_minus_3 = as_of - pd.Timedelta(hours=3)
|
t_minus_3 = as_of - pd.Timedelta(hours=3)
|
||||||
level_t_minus_3 = float(observed_level.loc[t_minus_3]) if t_minus_3 in observed_level.index else None
|
level_t_minus_3 = (
|
||||||
|
float(observed_level.loc[t_minus_3])
|
||||||
|
if t_minus_3 in observed_level.index
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
bundle_path = models_dir / f"flood_{station_code}.joblib"
|
bundle_path = models_dir / f"flood_{station_code}.joblib"
|
||||||
if not bundle_path.exists() or staleness_h > STALE_AFTER_H:
|
if not bundle_path.exists() or staleness_h > STALE_AFTER_H:
|
||||||
return _heuristic_forecast(
|
return _heuristic_forecast(
|
||||||
station_code, as_of, current_level, level_t_minus_3, warn_thr, danger_thr, horizons
|
station_code,
|
||||||
|
as_of,
|
||||||
|
current_level,
|
||||||
|
level_t_minus_3,
|
||||||
|
warn_thr,
|
||||||
|
danger_thr,
|
||||||
|
horizons,
|
||||||
)
|
)
|
||||||
|
|
||||||
bundle = _load_bundle(bundle_path)
|
bundle = _load_bundle(bundle_path)
|
||||||
model_results = _model_forecast(station_code, grid, bundle, as_of, current_level)
|
model_results = _model_forecast(station_code, grid, bundle, as_of, current_level)
|
||||||
if model_results is None:
|
if model_results is None:
|
||||||
return _heuristic_forecast(
|
return _heuristic_forecast(
|
||||||
station_code, as_of, current_level, level_t_minus_3, warn_thr, danger_thr, horizons
|
station_code,
|
||||||
|
as_of,
|
||||||
|
current_level,
|
||||||
|
level_t_minus_3,
|
||||||
|
warn_thr,
|
||||||
|
danger_thr,
|
||||||
|
horizons,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Per-horizon heads that were skipped at train time (e.g. too few positives) still
|
# Per-horizon heads that were skipped at train time (e.g. too few positives) still
|
||||||
@@ -233,7 +278,13 @@ def _forecast_station(
|
|||||||
else:
|
else:
|
||||||
filled.extend(
|
filled.extend(
|
||||||
_heuristic_forecast(
|
_heuristic_forecast(
|
||||||
station_code, as_of, current_level, level_t_minus_3, warn_thr, danger_thr, (horizon_h,)
|
station_code,
|
||||||
|
as_of,
|
||||||
|
current_level,
|
||||||
|
level_t_minus_3,
|
||||||
|
warn_thr,
|
||||||
|
danger_thr,
|
||||||
|
(horizon_h,),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return filled
|
return filled
|
||||||
@@ -241,7 +292,7 @@ def _forecast_station(
|
|||||||
|
|
||||||
def get_forecasts(
|
def get_forecasts(
|
||||||
readings_by_station: Dict[str, List[dict]],
|
readings_by_station: Dict[str, List[dict]],
|
||||||
models_dir: Union[str, Path] = "models",
|
models_dir: Union[str, Path] = DEFAULT_MODELS_DIR,
|
||||||
now: Optional[Union[datetime.datetime, str]] = None,
|
now: Optional[Union[datetime.datetime, str]] = None,
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
"""Produce flood forecasts for every station present in `readings_by_station`.
|
"""Produce flood forecasts for every station present in `readings_by_station`.
|
||||||
@@ -264,7 +315,9 @@ def get_forecasts(
|
|||||||
results: List[dict] = []
|
results: List[dict] = []
|
||||||
for station_code in readings_by_station.keys():
|
for station_code in readings_by_station.keys():
|
||||||
try:
|
try:
|
||||||
results.extend(_forecast_station(station_code, grid, models_dir, now, DEFAULT_HORIZONS))
|
results.extend(
|
||||||
|
_forecast_station(station_code, grid, models_dir, now, DEFAULT_HORIZONS)
|
||||||
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.error(f"Forecast failed for station {station_code}: {error}")
|
logger.error(f"Forecast failed for station {station_code}: {error}")
|
||||||
return results
|
return results
|
||||||
@@ -272,7 +325,7 @@ def get_forecasts(
|
|||||||
|
|
||||||
def get_latest_forecasts(
|
def get_latest_forecasts(
|
||||||
db_url: Optional[str] = None,
|
db_url: Optional[str] = None,
|
||||||
models_dir: Union[str, Path] = "models",
|
models_dir: Union[str, Path] = DEFAULT_MODELS_DIR,
|
||||||
hours: int = 336,
|
hours: int = 336,
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
"""Convenience wrapper for web_api: load the latest window from the DB/API and forecast.
|
"""Convenience wrapper for web_api: load the latest window from the DB/API and forecast.
|
||||||
@@ -289,10 +342,14 @@ def get_latest_forecasts(
|
|||||||
readings_by_station: Dict[str, List[dict]] = {}
|
readings_by_station: Dict[str, List[dict]] = {}
|
||||||
if not df_long.empty:
|
if not df_long.empty:
|
||||||
for station_code, group in df_long.groupby("station_code"):
|
for station_code, group in df_long.groupby("station_code"):
|
||||||
readings_by_station[station_code] = group[["timestamp", "water_level", "discharge"]].to_dict("records")
|
readings_by_station[station_code] = group[
|
||||||
|
["timestamp", "water_level", "discharge"]
|
||||||
|
].to_dict("records")
|
||||||
|
|
||||||
expected_stations = set(features.UPSTREAM_LEADS.keys())
|
expected_stations = set(features.UPSTREAM_LEADS.keys())
|
||||||
for missing_station in expected_stations - set(readings_by_station.keys()):
|
for missing_station in expected_stations - set(readings_by_station.keys()):
|
||||||
logger.warning(f"No recent data for station {missing_station}; omitting from forecasts")
|
logger.warning(
|
||||||
|
f"No recent data for station {missing_station}; omitting from forecasts"
|
||||||
|
)
|
||||||
|
|
||||||
return get_forecasts(readings_by_station, models_dir=models_dir)
|
return get_forecasts(readings_by_station, models_dir=models_dir)
|
||||||
|
|||||||
+166
-42
@@ -20,8 +20,16 @@ import joblib
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import sklearn
|
import sklearn
|
||||||
from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor
|
from sklearn.ensemble import (
|
||||||
from sklearn.metrics import average_precision_score, brier_score_loss, mean_absolute_error, mean_squared_error
|
HistGradientBoostingClassifier,
|
||||||
|
HistGradientBoostingRegressor,
|
||||||
|
)
|
||||||
|
from sklearn.metrics import (
|
||||||
|
average_precision_score,
|
||||||
|
brier_score_loss,
|
||||||
|
mean_absolute_error,
|
||||||
|
mean_squared_error,
|
||||||
|
)
|
||||||
|
|
||||||
from . import features
|
from . import features
|
||||||
from .data import DEFAULT_API_URL, load_measurements, resolve_db_url
|
from .data import DEFAULT_API_URL, load_measurements, resolve_db_url
|
||||||
@@ -51,7 +59,11 @@ HGB_PARAMS = {
|
|||||||
def _git_short_sha() -> str:
|
def _git_short_sha() -> str:
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, timeout=5, check=True
|
["git", "rev-parse", "--short", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
check=True,
|
||||||
)
|
)
|
||||||
sha = result.stdout.strip()
|
sha = result.stdout.strip()
|
||||||
return sha or "nogit"
|
return sha or "nogit"
|
||||||
@@ -64,12 +76,20 @@ def _make_regressor(overrides: Optional[dict] = None) -> HistGradientBoostingReg
|
|||||||
return HistGradientBoostingRegressor(loss="squared_error", **params)
|
return HistGradientBoostingRegressor(loss="squared_error", **params)
|
||||||
|
|
||||||
|
|
||||||
def _make_classifier(overrides: Optional[dict] = None) -> HistGradientBoostingClassifier:
|
def _make_classifier(
|
||||||
|
overrides: Optional[dict] = None,
|
||||||
|
) -> HistGradientBoostingClassifier:
|
||||||
params = {**HGB_PARAMS, **(overrides or {})}
|
params = {**HGB_PARAMS, **(overrides or {})}
|
||||||
return HistGradientBoostingClassifier(**params)
|
return HistGradientBoostingClassifier(**params)
|
||||||
|
|
||||||
|
|
||||||
def _safe_fit(estimator, X: pd.DataFrame, y: pd.Series, head_key: str, skipped_heads: Dict[str, str]):
|
def _safe_fit(
|
||||||
|
estimator,
|
||||||
|
X: pd.DataFrame,
|
||||||
|
y: pd.Series,
|
||||||
|
head_key: str,
|
||||||
|
skipped_heads: Dict[str, str],
|
||||||
|
):
|
||||||
"""Fit an estimator, converting any failure (e.g. HistGradientBoosting's binning
|
"""Fit an estimator, converting any failure (e.g. HistGradientBoosting's binning
|
||||||
step rejecting an all-NaN/constant feature column) into a recorded skip rather
|
step rejecting an all-NaN/constant feature column) into a recorded skip rather
|
||||||
than a station-killing exception."""
|
than a station-killing exception."""
|
||||||
@@ -82,7 +102,9 @@ def _safe_fit(estimator, X: pd.DataFrame, y: pd.Series, head_key: str, skipped_h
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _recall_at_far(y_true: np.ndarray, y_score: np.ndarray, target_far: float) -> Optional[float]:
|
def _recall_at_far(
|
||||||
|
y_true: np.ndarray, y_score: np.ndarray, target_far: float
|
||||||
|
) -> Optional[float]:
|
||||||
"""Recall at the score threshold whose false-positive rate over true negatives is <= target_far."""
|
"""Recall at the score threshold whose false-positive rate over true negatives is <= target_far."""
|
||||||
y_true = np.asarray(y_true)
|
y_true = np.asarray(y_true)
|
||||||
y_score = np.asarray(y_score)
|
y_score = np.asarray(y_score)
|
||||||
@@ -98,7 +120,9 @@ def _recall_at_far(y_true: np.ndarray, y_score: np.ndarray, target_far: float) -
|
|||||||
return tp / n_pos
|
return tp / n_pos
|
||||||
|
|
||||||
|
|
||||||
def _p_warning_series(head, reg, X: pd.DataFrame, threshold: float, sigma: float) -> pd.Series:
|
def _p_warning_series(
|
||||||
|
head, reg, X: pd.DataFrame, threshold: float, sigma: float
|
||||||
|
) -> pd.Series:
|
||||||
"""Model score if a classifier head exists, else the sigmoid-derived fallback probability."""
|
"""Model score if a classifier head exists, else the sigmoid-derived fallback probability."""
|
||||||
if head is not None:
|
if head is not None:
|
||||||
return pd.Series(head.predict_proba(X)[:, 1], index=X.index)
|
return pd.Series(head.predict_proba(X)[:, 1], index=X.index)
|
||||||
@@ -117,12 +141,24 @@ def _find_events(observed_level: pd.Series, warn_thr: float) -> List[dict]:
|
|||||||
start = t
|
start = t
|
||||||
elif not is_above and start is not None:
|
elif not is_above and start is not None:
|
||||||
window = observed_level.loc[start:prev_t]
|
window = observed_level.loc[start:prev_t]
|
||||||
events.append({"crossed_warn_at": start, "peak_time": window.idxmax(), "peak_level": float(window.max())})
|
events.append(
|
||||||
|
{
|
||||||
|
"crossed_warn_at": start,
|
||||||
|
"peak_time": window.idxmax(),
|
||||||
|
"peak_level": float(window.max()),
|
||||||
|
}
|
||||||
|
)
|
||||||
start = None
|
start = None
|
||||||
prev_t = t
|
prev_t = t
|
||||||
if start is not None:
|
if start is not None:
|
||||||
window = observed_level.loc[start:]
|
window = observed_level.loc[start:]
|
||||||
events.append({"crossed_warn_at": start, "peak_time": window.idxmax(), "peak_level": float(window.max())})
|
events.append(
|
||||||
|
{
|
||||||
|
"crossed_warn_at": start,
|
||||||
|
"peak_time": window.idxmax(),
|
||||||
|
"peak_level": float(window.max()),
|
||||||
|
}
|
||||||
|
)
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
@@ -142,9 +178,13 @@ def _events_with_lead_time(
|
|||||||
events = _find_events(observed_level_test, warn_thr)
|
events = _find_events(observed_level_test, warn_thr)
|
||||||
for event in events:
|
for event in events:
|
||||||
first_alert_at = _first_alert_at(p_warning_test, event["crossed_warn_at"])
|
first_alert_at = _first_alert_at(p_warning_test, event["crossed_warn_at"])
|
||||||
event["first_alert_at"] = first_alert_at.isoformat() if first_alert_at is not None else None
|
event["first_alert_at"] = (
|
||||||
|
first_alert_at.isoformat() if first_alert_at is not None else None
|
||||||
|
)
|
||||||
if first_alert_at is not None:
|
if first_alert_at is not None:
|
||||||
lead_hours = (event["crossed_warn_at"] - first_alert_at).total_seconds() / 3600.0
|
lead_hours = (
|
||||||
|
event["crossed_warn_at"] - first_alert_at
|
||||||
|
).total_seconds() / 3600.0
|
||||||
else:
|
else:
|
||||||
lead_hours = None
|
lead_hours = None
|
||||||
event["lead_hours"] = lead_hours
|
event["lead_hours"] = lead_hours
|
||||||
@@ -166,7 +206,10 @@ def train_station(
|
|||||||
"""Train every head for one station. Returns (bundle_or_None, station_metrics)."""
|
"""Train every head for one station. Returns (bundle_or_None, station_metrics)."""
|
||||||
X, Y, meta = features.build_matrix(df_long, station, horizons)
|
X, Y, meta = features.build_matrix(df_long, station, horizons)
|
||||||
if meta["n_rows"] < MIN_ROWS_TO_TRAIN:
|
if meta["n_rows"] < MIN_ROWS_TO_TRAIN:
|
||||||
return None, {"status": "failed", "reason": f"only {meta['n_rows']} usable rows (< {MIN_ROWS_TO_TRAIN})"}
|
return None, {
|
||||||
|
"status": "failed",
|
||||||
|
"reason": f"only {meta['n_rows']} usable rows (< {MIN_ROWS_TO_TRAIN})",
|
||||||
|
}
|
||||||
|
|
||||||
warn_thr, danger_thr = features.get_thresholds(station)
|
warn_thr, danger_thr = features.get_thresholds(station)
|
||||||
feature_names = list(X.columns)
|
feature_names = list(X.columns)
|
||||||
@@ -176,7 +219,9 @@ def train_station(
|
|||||||
test_mask = pd.Series(False, index=X.index)
|
test_mask = pd.Series(False, index=X.index)
|
||||||
else:
|
else:
|
||||||
train_mask = X.index <= pd.Timestamp(split_train_end)
|
train_mask = X.index <= pd.Timestamp(split_train_end)
|
||||||
test_mask = (X.index >= pd.Timestamp(split_test_start)) & (X.index <= pd.Timestamp(split_test_end))
|
test_mask = (X.index >= pd.Timestamp(split_test_start)) & (
|
||||||
|
X.index <= pd.Timestamp(split_test_end)
|
||||||
|
)
|
||||||
X_train, Y_train = X.loc[train_mask], Y.loc[train_mask]
|
X_train, Y_train = X.loc[train_mask], Y.loc[train_mask]
|
||||||
X_test, Y_test = X.loc[test_mask], Y.loc[test_mask]
|
X_test, Y_test = X.loc[test_mask], Y.loc[test_mask]
|
||||||
eval_X, eval_Y = (X, Y) if skip_eval else (X_train, Y_train)
|
eval_X, eval_Y = (X, Y) if skip_eval else (X_train, Y_train)
|
||||||
@@ -188,7 +233,11 @@ def train_station(
|
|||||||
observed_grid = features.make_hourly_grid(df_long).observed
|
observed_grid = features.make_hourly_grid(df_long).observed
|
||||||
|
|
||||||
for h in horizons:
|
for h in horizons:
|
||||||
max_col, warn_col, danger_col = f"max_level_{h}", f"exceed_warn_{h}", f"exceed_danger_{h}"
|
max_col, warn_col, danger_col = (
|
||||||
|
f"max_level_{h}",
|
||||||
|
f"exceed_warn_{h}",
|
||||||
|
f"exceed_danger_{h}",
|
||||||
|
)
|
||||||
horizon_metrics: dict = {}
|
horizon_metrics: dict = {}
|
||||||
|
|
||||||
# --- regression head (max level) ---
|
# --- regression head (max level) ---
|
||||||
@@ -215,19 +264,28 @@ def train_station(
|
|||||||
sigma_h = max(float(np.std(residuals)), MIN_SIGMA)
|
sigma_h = max(float(np.std(residuals)), MIN_SIGMA)
|
||||||
horizon_metrics["n_test"] = int(test_labeled.sum())
|
horizon_metrics["n_test"] = int(test_labeled.sum())
|
||||||
horizon_metrics["mae"] = float(mean_absolute_error(y_true, y_pred))
|
horizon_metrics["mae"] = float(mean_absolute_error(y_true, y_pred))
|
||||||
horizon_metrics["rmse"] = float(np.sqrt(mean_squared_error(y_true, y_pred)))
|
horizon_metrics["rmse"] = float(
|
||||||
|
np.sqrt(mean_squared_error(y_true, y_pred))
|
||||||
|
)
|
||||||
above_2m = y_true >= 2.0
|
above_2m = y_true >= 2.0
|
||||||
horizon_metrics["mae_above_2m"] = (
|
horizon_metrics["mae_above_2m"] = (
|
||||||
float(mean_absolute_error(y_true[above_2m], y_pred[above_2m])) if above_2m.any() else None
|
float(mean_absolute_error(y_true[above_2m], y_pred[above_2m]))
|
||||||
|
if above_2m.any()
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
sigma[h] = sigma_h
|
sigma[h] = sigma_h
|
||||||
horizon_metrics["sigma"] = sigma_h
|
horizon_metrics["sigma"] = sigma_h
|
||||||
|
|
||||||
# --- classification heads (warn / danger) ---
|
# --- classification heads (warn / danger) ---
|
||||||
p_warning_test = None
|
p_warning_test = None
|
||||||
for label_name, col, thr in (("warn", warn_col, warn_thr), ("danger", danger_col, danger_thr)):
|
for label_name, col, thr in (
|
||||||
|
("warn", warn_col, warn_thr),
|
||||||
|
("danger", danger_col, danger_thr),
|
||||||
|
):
|
||||||
train_labeled = eval_Y[col].notna()
|
train_labeled = eval_Y[col].notna()
|
||||||
n_pos = int(eval_Y.loc[train_labeled, col].sum()) if train_labeled.any() else 0
|
n_pos = (
|
||||||
|
int(eval_Y.loc[train_labeled, col].sum()) if train_labeled.any() else 0
|
||||||
|
)
|
||||||
head_key = f"{label_name}_{h}"
|
head_key = f"{label_name}_{h}"
|
||||||
clf = None
|
clf = None
|
||||||
if n_pos >= MIN_POSITIVES_FOR_CLASSIFIER:
|
if n_pos >= MIN_POSITIVES_FOR_CLASSIFIER:
|
||||||
@@ -239,21 +297,37 @@ def train_station(
|
|||||||
skipped_heads,
|
skipped_heads,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
skipped_heads[head_key] = f"only {n_pos} positives in train span (< {MIN_POSITIVES_FOR_CLASSIFIER})"
|
skipped_heads[
|
||||||
|
head_key
|
||||||
|
] = f"only {n_pos} positives in train span (< {MIN_POSITIVES_FOR_CLASSIFIER})"
|
||||||
heads[head_key] = clf
|
heads[head_key] = clf
|
||||||
|
|
||||||
if not skip_eval:
|
if not skip_eval:
|
||||||
test_labeled = Y_test[col].notna()
|
test_labeled = Y_test[col].notna()
|
||||||
horizon_metrics[f"base_rate_{label_name}"] = (
|
horizon_metrics[f"base_rate_{label_name}"] = (
|
||||||
float(Y_test.loc[test_labeled, col].mean()) if test_labeled.any() else None
|
float(Y_test.loc[test_labeled, col].mean())
|
||||||
|
if test_labeled.any()
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
if clf is not None and test_labeled.sum() > 0 and Y_test.loc[test_labeled, col].nunique() > 1:
|
if (
|
||||||
|
clf is not None
|
||||||
|
and test_labeled.sum() > 0
|
||||||
|
and Y_test.loc[test_labeled, col].nunique() > 1
|
||||||
|
):
|
||||||
y_true = Y_test.loc[test_labeled, col]
|
y_true = Y_test.loc[test_labeled, col]
|
||||||
y_score = clf.predict_proba(X_test.loc[test_labeled])[:, 1]
|
y_score = clf.predict_proba(X_test.loc[test_labeled])[:, 1]
|
||||||
horizon_metrics[f"pr_auc_{label_name}"] = float(average_precision_score(y_true, y_score))
|
horizon_metrics[f"pr_auc_{label_name}"] = float(
|
||||||
horizon_metrics[f"brier_{label_name}"] = float(brier_score_loss(y_true, y_score))
|
average_precision_score(y_true, y_score)
|
||||||
horizon_metrics[f"recall_{label_name}_at_far1pct"] = _recall_at_far(y_true, y_score, 0.01)
|
)
|
||||||
horizon_metrics[f"recall_{label_name}_at_far5pct"] = _recall_at_far(y_true, y_score, 0.05)
|
horizon_metrics[f"brier_{label_name}"] = float(
|
||||||
|
brier_score_loss(y_true, y_score)
|
||||||
|
)
|
||||||
|
horizon_metrics[f"recall_{label_name}_at_far1pct"] = _recall_at_far(
|
||||||
|
y_true, y_score, 0.01
|
||||||
|
)
|
||||||
|
horizon_metrics[f"recall_{label_name}_at_far5pct"] = _recall_at_far(
|
||||||
|
y_true, y_score, 0.05
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
horizon_metrics[f"pr_auc_{label_name}"] = None
|
horizon_metrics[f"pr_auc_{label_name}"] = None
|
||||||
horizon_metrics[f"brier_{label_name}"] = None
|
horizon_metrics[f"brier_{label_name}"] = None
|
||||||
@@ -269,8 +343,12 @@ def train_station(
|
|||||||
if not skip_eval and reg is not None and p_warning_test is not None:
|
if not skip_eval and reg is not None and p_warning_test is not None:
|
||||||
observed_test_level = observed_grid.get((station, "water_level"))
|
observed_test_level = observed_grid.get((station, "water_level"))
|
||||||
if observed_test_level is not None:
|
if observed_test_level is not None:
|
||||||
observed_test_level = observed_test_level.loc[observed_test_level.index.isin(X_test.index)]
|
observed_test_level = observed_test_level.loc[
|
||||||
per_horizon[h]["events"] = _events_with_lead_time(observed_test_level, warn_thr, p_warning_test)
|
observed_test_level.index.isin(X_test.index)
|
||||||
|
]
|
||||||
|
per_horizon[h]["events"] = _events_with_lead_time(
|
||||||
|
observed_test_level, warn_thr, p_warning_test
|
||||||
|
)
|
||||||
|
|
||||||
# --- full refit on the ENTIRE record for the deployed artifact ---
|
# --- full refit on the ENTIRE record for the deployed artifact ---
|
||||||
# This may include/exclude different heads than the eval-phase gate above (the
|
# This may include/exclude different heads than the eval-phase gate above (the
|
||||||
@@ -278,11 +356,21 @@ def train_station(
|
|||||||
# skipped_heads must reflect what actually ends up in the saved bundle.
|
# skipped_heads must reflect what actually ends up in the saved bundle.
|
||||||
final_heads: Dict[str, object] = {}
|
final_heads: Dict[str, object] = {}
|
||||||
for h in horizons:
|
for h in horizons:
|
||||||
max_col, warn_col, danger_col = f"max_level_{h}", f"exceed_warn_{h}", f"exceed_danger_{h}"
|
max_col, warn_col, danger_col = (
|
||||||
|
f"max_level_{h}",
|
||||||
|
f"exceed_warn_{h}",
|
||||||
|
f"exceed_danger_{h}",
|
||||||
|
)
|
||||||
head_key = f"max_{h}"
|
head_key = f"max_{h}"
|
||||||
labeled = Y[max_col].notna()
|
labeled = Y[max_col].notna()
|
||||||
if labeled.sum() >= MIN_ROWS_FOR_HEAD:
|
if labeled.sum() >= MIN_ROWS_FOR_HEAD:
|
||||||
reg = _safe_fit(_make_regressor(hgb_overrides), X.loc[labeled], Y.loc[labeled, max_col], head_key, skipped_heads)
|
reg = _safe_fit(
|
||||||
|
_make_regressor(hgb_overrides),
|
||||||
|
X.loc[labeled],
|
||||||
|
Y.loc[labeled, max_col],
|
||||||
|
head_key,
|
||||||
|
skipped_heads,
|
||||||
|
)
|
||||||
final_heads[head_key] = reg
|
final_heads[head_key] = reg
|
||||||
if reg is not None:
|
if reg is not None:
|
||||||
skipped_heads.pop(head_key, None)
|
skipped_heads.pop(head_key, None)
|
||||||
@@ -296,13 +384,19 @@ def train_station(
|
|||||||
n_pos = int(Y.loc[train_labeled, col].sum()) if train_labeled.any() else 0
|
n_pos = int(Y.loc[train_labeled, col].sum()) if train_labeled.any() else 0
|
||||||
if n_pos >= MIN_POSITIVES_FOR_CLASSIFIER:
|
if n_pos >= MIN_POSITIVES_FOR_CLASSIFIER:
|
||||||
clf = _safe_fit(
|
clf = _safe_fit(
|
||||||
_make_classifier(hgb_overrides), X.loc[train_labeled], Y.loc[train_labeled, col], head_key, skipped_heads
|
_make_classifier(hgb_overrides),
|
||||||
|
X.loc[train_labeled],
|
||||||
|
Y.loc[train_labeled, col],
|
||||||
|
head_key,
|
||||||
|
skipped_heads,
|
||||||
)
|
)
|
||||||
final_heads[head_key] = clf
|
final_heads[head_key] = clf
|
||||||
if clf is not None:
|
if clf is not None:
|
||||||
skipped_heads.pop(head_key, None)
|
skipped_heads.pop(head_key, None)
|
||||||
else:
|
else:
|
||||||
skipped_heads[head_key] = f"only {n_pos} positives in train span (< {MIN_POSITIVES_FOR_CLASSIFIER})"
|
skipped_heads[
|
||||||
|
head_key
|
||||||
|
] = f"only {n_pos} positives in train span (< {MIN_POSITIVES_FOR_CLASSIFIER})"
|
||||||
final_heads[head_key] = None
|
final_heads[head_key] = None
|
||||||
|
|
||||||
bundle = {
|
bundle = {
|
||||||
@@ -345,7 +439,11 @@ def train_all(
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
bundle, station_metrics = train_station(
|
bundle, station_metrics = train_station(
|
||||||
df_long, station, horizons, skip_eval=skip_eval, hgb_overrides=hgb_overrides
|
df_long,
|
||||||
|
station,
|
||||||
|
horizons,
|
||||||
|
skip_eval=skip_eval,
|
||||||
|
hgb_overrides=hgb_overrides,
|
||||||
)
|
)
|
||||||
if bundle is None:
|
if bundle is None:
|
||||||
logger.warning(f"{station}: failed ({station_metrics.get('reason')})")
|
logger.warning(f"{station}: failed ({station_metrics.get('reason')})")
|
||||||
@@ -377,16 +475,32 @@ def train_all(
|
|||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[List[str]] = None) -> None:
|
def main(argv: Optional[List[str]] = None) -> None:
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||||
|
)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Train Ping River flood forecast models")
|
parser = argparse.ArgumentParser(
|
||||||
parser.add_argument("--stations", default="all", help="'all' or a comma-separated list of station codes")
|
description="Train Ping River flood forecast models"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--stations",
|
||||||
|
default="all",
|
||||||
|
help="'all' or a comma-separated list of station codes",
|
||||||
|
)
|
||||||
parser.add_argument("--models-dir", default="models")
|
parser.add_argument("--models-dir", default="models")
|
||||||
parser.add_argument("--db-url", default=None)
|
parser.add_argument("--db-url", default=None)
|
||||||
parser.add_argument("--api-url", default=DEFAULT_API_URL)
|
parser.add_argument("--api-url", default=DEFAULT_API_URL)
|
||||||
parser.add_argument("--skip-eval", action="store_true", help="Refit-only fast path; skip Split B evaluation")
|
parser.add_argument(
|
||||||
parser.add_argument("--start", default=None, help="ISO date; earliest measurement to load")
|
"--skip-eval",
|
||||||
parser.add_argument("--end", default=None, help="ISO date; latest measurement to load")
|
action="store_true",
|
||||||
|
help="Refit-only fast path; skip Split B evaluation",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--start", default=None, help="ISO date; earliest measurement to load"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--end", default=None, help="ISO date; latest measurement to load"
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.stations == "all":
|
if args.stations == "all":
|
||||||
@@ -399,15 +513,25 @@ def main(argv: Optional[List[str]] = None) -> None:
|
|||||||
|
|
||||||
logger.info(f"Loading measurements for {len(stations)} stations...")
|
logger.info(f"Loading measurements for {len(stations)} stations...")
|
||||||
df_long = load_measurements(
|
df_long = load_measurements(
|
||||||
db_url=resolve_db_url(args.db_url), stations=None, start=start, end=end, api_url=args.api_url
|
db_url=resolve_db_url(args.db_url),
|
||||||
|
stations=None,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
api_url=args.api_url,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Loaded {len(df_long)} rows spanning {df_long['timestamp'].min()} .. {df_long['timestamp'].max()}"
|
||||||
)
|
)
|
||||||
logger.info(f"Loaded {len(df_long)} rows spanning {df_long['timestamp'].min()} .. {df_long['timestamp'].max()}")
|
|
||||||
|
|
||||||
metrics_payload = train_all(
|
metrics_payload = train_all(
|
||||||
df_long, stations, models_dir=Path(args.models_dir), skip_eval=args.skip_eval
|
df_long, stations, models_dir=Path(args.models_dir), skip_eval=args.skip_eval
|
||||||
)
|
)
|
||||||
trained = sum(1 for s in metrics_payload["stations"].values() if s["status"] == "trained")
|
trained = sum(
|
||||||
logger.info(f"Done: {trained}/{len(stations)} stations trained. metrics.json written to {args.models_dir}")
|
1 for s in metrics_payload["stations"].values() if s["status"] == "trained"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Done: {trained}/{len(stations)} stations trained. metrics.json written to {args.models_dir}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user