File size: 8,265 Bytes
04d6dda
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
"""
Real labeled flood-event bookkeeping -- shared between
evaluate_flood_detection.py (which USES these events to flag real
false/true positives after training) and train_spatiotemporal_gnn.py
(which, as of the event-reweighted loss added here, USES the same real
events to upweight training examples near them). Moved out of
evaluate_flood_detection.py into its own module specifically so
train_spatiotemporal_gnn.py can import it too, without creating a
circular import -- evaluate_flood_detection.py already imports FROM
scripts.training.train_spatiotemporal_gnn (shared data-pipeline functions), so
the reverse import would have failed; this module has no dependency on
either script, so both can import it safely.

events_df is identify_flood_events.py's own output (a CSV with columns
station_code, start_date, end_date, threshold) -- both scripts treat
this the same way: real, labeled, human-verifiable flood windows, not
a heuristic re-derived at import time.
"""
from typing import Dict, List

import numpy as np
import pandas as pd


def build_event_lookup(events_df: pd.DataFrame) -> dict:
    """
    Real event date ranges, indexed by station, for fast membership
    checks -- {station_code: [(start_date, end_date, threshold), ...]}.
    A single station can have several real events across the record, so
    this stays a list per station rather than assuming at most one.
    """
    lookup = {}
    for _, row in events_df.iterrows():
        lookup.setdefault(row["station_code"], []).append(
            (pd.Timestamp(row["start_date"]), pd.Timestamp(row["end_date"]), row["threshold"])
        )
    return lookup


def is_real_event_day(station_code: str, date: pd.Timestamp, event_lookup: dict):
    """
    Returns the real threshold if `date` falls within a real event for
    this station, else None -- distinguishing "not an event" from
    "no threshold defined" matters for how the caller should treat it,
    so this returns the threshold itself rather than a bare boolean.
    """
    for start, end, threshold in event_lookup.get(station_code, []):
        if start <= date <= end:
            return threshold
    return None


def days_to_nearest_real_event(station_code: str, date: pd.Timestamp, event_lookup: dict) -> "float":
    """
    Real, signed distance in days from `date` to the nearest real event
    window for this station -- 0 if `date` falls inside one. Built to
    distinguish a real false positive that's a near-miss (a day or two
    from an actual real event) from one with no real event anywhere
    nearby, which are genuinely different situations worth telling
    apart when inspecting false positives directly.

    Returns float("inf") if this station has no real events at all.
    """
    best = float("inf")
    for start, end, _ in event_lookup.get(station_code, []):
        if start <= date <= end:
            return 0.0
        gap = min(abs((date - start).days), abs((date - end).days))
        best = min(best, gap)
    return best


def build_station_thresholds(events_df: pd.DataFrame) -> dict:
    """
    One real threshold per station -- identify_flood_events.py computes
    this once per station (that station's own historical percentile),
    so every real event row for a given station carries the same value;
    this collapses that back down to {station_code: threshold}.

    This threshold is what gets compared against EVERY day's
    prediction, event or not -- comparing only on real event days (and
    treating every other day as an automatic non-flag) would make false
    positives structurally impossible and precision meaningless by
    construction, not just optimistic.
    """
    return events_df.groupby("station_code")["threshold"].first().to_dict()


def build_event_weight_tensor(
    station_codes: List[str], anchor_dates: List[pd.Timestamp], horizons: List[int],
    event_lookup: dict, window_days: int = 5, boost: float = 4.0,
) -> np.ndarray:
    """
    Real, per-(example, node, horizon) sample weight for the SUPERVISED
    discharge quantile loss -- boost where the real TARGET date (anchor
    + horizon - 1 days, not the anchor date itself) falls within
    `window_days` of a real labeled flood event at that station, 1.0
    (neutral) everywhere else.

    Built as the direct, concrete response to the real, measured
    problem this project's own diagnostics confirmed: real flood
    events are rare (125 across the whole 2013-2025 record), so under
    plain (or log-space) pinball loss, the sheer VOLUME of quiet-day
    gradient updates dilutes the few real flood-day updates, even
    though pinball loss's own per-sample asymmetry already weights a
    flood-day underprediction more heavily than a quiet-day
    overprediction of the same size -- that per-sample weighting
    doesn't fix a per-EXAMPLE-COUNT imbalance. Direct, measured
    evidence this project already has: --check-quantile-calibration
    showed q0.95 coverage at 0.990 on low-flow days (should be ~0.95)
    and 0.698 on high-flow days (should also be ~0.95) on the SAME
    checkpoint -- opposite-direction miscalibration consistent with a
    model whose learned quantile function is dominated by the
    overwhelmingly more common quiet-day examples.

    Deliberately restricted to stations that appear in event_lookup at
    all (i.e. have at least one real labeled event) -- every other node
    (virtual/confluence nodes, or real gauges with zero labeled events)
    gets the neutral weight of 1.0 everywhere, without needing any real
    per-example work, since days_to_nearest_real_event would return
    float("inf") for them regardless. This keeps the real cost of
    building this tensor proportional to (stations with events) x
    (real examples) x (real horizons), not the full node count -- with
    ~4,500 nodes but only a handful of real gauges ever having labeled
    events, this is a real, large, deliberate savings, not a premature
    optimization.

    Args:
        station_codes: this run's real, ordered node->station-code list
            (combined["station_codes"]), same ordering Y's node axis
            uses.
        anchor_dates: this run's real anchor dates, same ordering X/Y's
            example axis uses (from prepare_graph_training_windows).
        horizons: this run's real HORIZONS list, same ordering Y's
            horizon axis uses.
        event_lookup: from build_event_lookup(events_df).
        window_days: how many real days from a labeled event's start/
            end the boost still applies -- 5 chosen as a real, moderate
            window (identify_flood_events.py's own docstring notes real
            event durations already run 3-10 days, so this catches the
            real rising/falling limb immediately around a labeled
            event, not just the event's own days).
        boost: the real weight multiplier applied to boosted examples
            (an untouched example keeps weight 1.0). 4.0 chosen as a
            real, moderate value in the 3-5x range this project's own
            discussion landed on -- large enough to meaningfully shift
            gradient share toward flood-adjacent examples, not so large
            it risks destabilizing training the way an aggressive
            reweighting scheme could. Treat as a real, tunable
            hyperparameter, not a fixed constant -- compare against
            boost=1.0 (a no-op, exactly reproducing the unweighted
            loss) as the baseline for any real run using this.
    """
    n_examples = len(anchor_dates)
    n_nodes = len(station_codes)
    n_horizons = len(horizons)
    weight = np.ones((n_examples, n_nodes, n_horizons), dtype=np.float32)

    stations_with_events = [code for code in station_codes if event_lookup.get(code)]
    if not stations_with_events:
        return weight

    for code in stations_with_events:
        node_idx = station_codes.index(code)
        for ex_idx, anchor in enumerate(anchor_dates):
            for h_idx, h in enumerate(horizons):
                target_date = anchor + pd.Timedelta(days=h - 1)
                if days_to_nearest_real_event(code, target_date, event_lookup) <= window_days:
                    weight[ex_idx, node_idx, h_idx] = boost
    return weight