File size: 12,964 Bytes
f2046b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
"""
Anomaly model for the climate sub-model: predicts (actual - climatology)
at a requested horizon, given a recent history of anomalies. Combined
with climate_climatology.py's climatology at inference time:

    forecast(date, horizon) = climatology(day_of_year) + predicted_anomaly(horizon)

DESIGN CHOICE, stated directly rather than left implicit: the predicted
anomaly is passed through a learnable, per-variable, MONOTONICALLY
DECAYING gate as a function of horizon -- built in architecturally, not
left for training to discover on its own. This is a direct response to
what the real climatology results showed: precipitation has close to
zero seasonal predictability even at the baseline level (4/20 real grid
cells beat a flat mean, and the margins were negligible), so there is
no reason to expect a data-driven model to have genuine multi-month
signal for it either. Rather than risk the model outputting a
confident-looking but meaningless anomaly at day 180, the gate forces
the anomaly's contribution toward zero as horizon grows -- how fast is
LEARNED per variable (a temperature-like variable with real persistence
should decay slowly; precipitation should decay fast), but the
DIRECTION (toward zero, never away from it) is guaranteed by
construction, not hoped for.

Verification status: prepare_training_windows and decay_gate are pure
numpy/pandas, tested directly below. The nn.Module (ClimateAnomalyModel)
has been syntax-checked but not run end-to-end -- run
scripts/test_climate_anomaly_model.py against real data and confirm
behavior before trusting it, the same way the climatology and
grid-extraction work was validated.
"""
from typing import Dict, List, Optional, Tuple

import numpy as np
import pandas as pd

try:
    import torch
    import torch.nn as nn
    _HAS_TORCH = True
except ImportError:
    _HAS_TORCH = False
    nn = None  # only referenced inside the class body below, never at import time otherwise


# ---------------------------------------------------------------------------
# Data preparation -- pure numpy/pandas, fully testable without torch
# ---------------------------------------------------------------------------

def prepare_training_windows(
    anomaly_wide: pd.DataFrame, lookback_days: int, horizons: List[int],
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, List[pd.Timestamp]]:
    """
    Slices a [date x cell] anomaly DataFrame into (history, target)
    training examples: for every date with a full lookback window
    behind it AND real target values at every requested horizon ahead
    of it, extract the lookback window as input and the horizon-ahead
    anomalies as targets.

    A window is only kept if the ENTIRE lookback period is real
    (non-NaN) for that cell -- a history with gaps silently interpolated
    would teach the model on fabricated input, not real conditions. A
    target is NaN-masked per (example, horizon) individually rather
    than requiring every horizon to be real, since discharge/climate
    series have real, uneven gaps and requiring all horizons real would
    throw away most of the sparse real data this project already has.

    Args:
        anomaly_wide: [date x cell_id], from climate_climatology.
            compute_anomalies.
        lookback_days: how much history feeds the encoder.
        horizons: which day-ahead offsets to produce targets for, e.g.
            [1, 5, 10, 30, 60, 90, 180].

    Returns:
        X: [n_examples, lookback_days, n_cells] -- history window,
            same set of cells for every example (aligned columns).
        Y: [n_examples, n_horizons] -- target anomaly at each horizon,
            for the SPECIFIC cell/column indicated by `example_cell_idx`.
        example_cell_idx: [n_examples] -- which column (cell) index in
            X's last dimension this example's Y targets refer to (one
            training example = one cell's history predicting that SAME
            cell's own future, not cross-cell).
        dates: the anchor date (the "today") for each example, for
            traceability.
    """
    max_horizon = max(horizons)
    values = anomaly_wide.values
    dates = anomaly_wide.index
    n_dates, n_cells = values.shape

    X_list, Y_list, cell_idx_list, anchor_dates = [], [], [], []

    for t in range(lookback_days, n_dates - max_horizon):
        window = values[t - lookback_days:t, :]
        window_complete_mask = ~np.isnan(window).any(axis=0)  # per cell
        if not window_complete_mask.any():
            continue

        for cell in np.where(window_complete_mask)[0]:
            targets = np.array([values[t + h - 1, cell] for h in horizons])
            if np.isnan(targets).all():
                continue
            X_list.append(window[:, cell])
            Y_list.append(targets)
            cell_idx_list.append(cell)
            anchor_dates.append(dates[t])

    if not X_list:
        return (np.empty((0, lookback_days)), np.empty((0, len(horizons))),
                np.empty((0,), dtype=int), [])

    X = np.stack(X_list)[:, :, None]  # [n_examples, lookback_days, 1] -- single-variable per call
    Y = np.stack(Y_list)
    return X, Y, np.array(cell_idx_list), anchor_dates


def decay_gate(horizons: np.ndarray, tau: float) -> np.ndarray:
    """
    exp(-h / tau) -- the monotonic decay curve gating the predicted
    anomaly toward zero as horizon grows. tau is learned per variable
    in the real model (see ClimateAnomalyModel); this standalone
    version is what's actually tested here, since the shape/behavior
    of the curve is the real design claim, independent of whether it's
    a fixed constant or a learned nn.Parameter wrapping this same formula.

    tau is the horizon (in days) at which the gate has decayed to 1/e
    (~37%) of its starting value -- a small tau means fast decay
    (appropriate for precipitation, per the real climatology results),
    a large tau means slow decay (appropriate for temperature).
    """
    return np.exp(-horizons / tau)


# ---------------------------------------------------------------------------
# Model. Forward-pass shape/behavior verified only via the standalone
# decay-gate check above; the full nn.Module has not been independently
# run end-to-end.
# ---------------------------------------------------------------------------

if _HAS_TORCH:
    class ClimateAnomalyModel(nn.Module):
        """
        Encoder: GRU over the lookback window of anomalies -> final
        hidden state per example.
        Decoder: [hidden_state, horizon_embedding] -> MLP -> (mean,
        log_variance) per horizon.

        Two complementary mechanisms handle "the model shouldn't be
        confidently wrong when there's no real signal" -- confirmed as
        a real, not hypothetical, problem: the first version of this
        model (mean-only, plain MSE) was measurably WORSE than pure
        climatology at several horizons for temp_C/solar_Wm2/
        wind_speed_ms, while a persistence baseline diagnostic showed
        there was no real short-horizon signal for those variables to
        find in the first place -- the model was outputting confident,
        wrong guesses instead of admitting it didn't know.

        1. The MEAN is passed through a learnable, monotonically
           decaying gate per variable (see decay_gate) -- a HARD
           architectural constraint: the model is structurally unable
           to output a large mean anomaly at a horizon its own learned
           tau says is past the point of real signal. Proven to work
           correctly on real data already: trained on snow_mm (a
           genuinely close-to-unpredictable variable), tau collapsed to
           2.3 and the model's predictions converged to matching
           climatology almost exactly at every horizon beyond a few days.
        2. The VARIANCE is predicted separately, NOT gated the same
           way, and trained via Gaussian NLL loss rather than plain
           MSE. This is what actually targets the diagnosed
           overconfidence problem: NLL rewards the model for outputting
           high predicted variance when it genuinely can't predict
           well, rather than being forced into a single point guess
           the loss then punishes regardless of whether real signal
           existed to make a better one.

        One model per climate variable, not a shared model across all
        nine -- the real climatology and persistence-diagnostic results
        showed genuinely different predictability profiles (runoff_mm
        beat climatology at every horizon; wind_v_ms/precip_mm/snow_mm
        essentially never did), and forcing one shared decay/variance
        behavior to serve both would misrepresent at least one of them.
        """

        def __init__(self, hidden_dim: int = 32, horizon_embed_dim: int = 8, init_tau: float = 30.0):
            super().__init__()
            self.encoder = nn.GRU(input_size=1, hidden_size=hidden_dim, batch_first=True)
            self.horizon_embed = nn.Linear(1, horizon_embed_dim)
            self.decoder = nn.Sequential(
                nn.Linear(hidden_dim + horizon_embed_dim, hidden_dim),
                nn.ReLU(),
                nn.Linear(hidden_dim, 2),  # [mean, log_variance] per horizon
            )
            # log_tau, not tau directly, so tau = exp(log_tau) is always
            # positive regardless of what gradient descent does to the
            # underlying parameter -- a negative or zero tau would make
            # the decay gate nonsensical (blow up or divide by zero).
            self.log_tau = nn.Parameter(torch.log(torch.tensor(float(init_tau))))

        def forward(self, history: "torch.Tensor", horizons: "torch.Tensor") -> Tuple["torch.Tensor", "torch.Tensor"]:
            """
            Args:
                history: [batch, lookback_days, 1] -- real anomaly
                    history, already NaN-free (prepare_training_windows
                    only emits complete windows).
                horizons: [n_horizons] -- day-ahead offsets to predict
                    for, shared across the batch.

            Returns:
                (mean, log_variance), each [batch, n_horizons]. The mean
                is already gated by the learned decay curve; log_variance
                is the model's own learned, per-example uncertainty,
                deliberately not put through the same gate -- a model
                should be able to express high uncertainty at ANY
                horizon, including short ones, if a specific example
                genuinely warrants it, not only according to a fixed
                schedule.
            """
            _, h_n = self.encoder(history)
            hidden = h_n.squeeze(0)  # [batch, hidden_dim]

            batch_size = hidden.shape[0]
            n_horizons = horizons.shape[0]
            hidden_expanded = hidden.unsqueeze(1).expand(batch_size, n_horizons, hidden.shape[-1])
            horizon_feat = self.horizon_embed(horizons.float().view(1, n_horizons, 1).expand(batch_size, n_horizons, 1))

            decoder_input = torch.cat([hidden_expanded, horizon_feat], dim=-1)
            raw_output = self.decoder(decoder_input)  # [batch, n_horizons, 2]
            raw_mean = raw_output[..., 0]
            log_var = raw_output[..., 1]

            tau = torch.exp(self.log_tau)
            gate = torch.exp(-horizons.float() / tau)  # [n_horizons]
            mean = raw_mean * gate.unsqueeze(0)

            return mean, log_var


    def gaussian_nll_loss(mean: "torch.Tensor", log_var: "torch.Tensor", target: "torch.Tensor",
                           min_log_var: float = -10.0) -> "torch.Tensor":
        """
        Heteroscedastic Gaussian negative log-likelihood, NaN-masked
        (same masking principle used throughout this project's physics
        losses -- a target that doesn't exist for a given [example,
        horizon] pair must not silently poison every other real pair's
        gradient the same way an un-masked MSE mean would).

        NLL = 0.5 * log_var + 0.5 * (target - mean)^2 / exp(log_var)

        min_log_var clamps how confident the model is allowed to claim
        to be -- without a floor, a model can trivially drive NLL toward
        -infinity by predicting an extremely small variance on an
        example it happens to get right by chance, which is a real,
        well-known failure mode of unconstrained heteroscedastic
        regression, not a hypothetical one.
        """
        mask = ~torch.isnan(target)
        if not mask.any():
            return mean.sum() * float("nan")
        log_var_clamped = torch.clamp(log_var[mask], min=min_log_var)
        residual_sq = (target[mask] - mean[mask]) ** 2
        return (0.5 * log_var_clamped + 0.5 * residual_sq / torch.exp(log_var_clamped)).mean()
else:
    ClimateAnomalyModel = None  # torch not installed; import guard, not a silent no-op
    gaussian_nll_loss = None