File size: 9,611 Bytes
bdd9175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Training objectives for TinyCast.

Two objectives supervise the model. The nine-quantile pinball loss trains the
whole predictive distribution; the gated committing term acts on the median
alone and is the paper's own addition. ``seasonal_copy_baseline`` builds the
reference forecast the committing term measures the median against.

All three work in the model's normalized output space. ``TinyCastBackbone.encode``
returns ``(y_norm, x_min, x_range)``; normalize the target with those same
statistics before calling anything here, so predictions, targets and the
seasonal copy sit on one scale.

Each function is self-contained and takes plain tensors, so it can be exercised
without a model or a training loop.
"""
from __future__ import annotations

from typing import Optional, Sequence, Union

import torch

# Cycles per canonical day, the constant the scale factor is expressed against:
# a sample's seasonal lag is BASE_SEASONALITY divided by its scale factor. It is
# defined once, in tinycast.scale, and re-exported here so the lag this module
# folds at and the factor the model is conditioned on cannot drift apart.
from .scale import BASE_SEASONALITY

# Weight of the committing term in the shipped recipe.
COMMIT_WEIGHT = 0.3

_Reduction = str


def _reduce(per_sample: torch.Tensor, reduction: _Reduction) -> torch.Tensor:
    if reduction == "none":
        return per_sample
    if reduction == "mean":
        return per_sample.mean()
    if reduction == "sum":
        return per_sample.sum()
    raise ValueError(
        f"Unknown reduction: {reduction!r}; expected 'mean', 'sum' or 'none'."
    )


def _as_bh(target: torch.Tensor, name: str) -> torch.Tensor:
    """Accept ``(B, H)`` or ``(B, H, 1)`` and return ``(B, H)``."""
    if target.dim() == 3 and target.shape[-1] == 1:
        return target.squeeze(-1)
    if target.dim() != 2:
        raise ValueError(
            f"{name} must be (B, H) or (B, H, 1), got {tuple(target.shape)}."
        )
    return target


def _mask_like(mask: Optional[torch.Tensor], ref: torch.Tensor) -> torch.Tensor:
    if mask is None:
        return torch.ones_like(ref)
    mask = _as_bh(mask, "mask").to(ref.dtype)
    if mask.shape != ref.shape:
        raise ValueError(
            f"mask shape {tuple(mask.shape)} does not match "
            f"{tuple(ref.shape)}."
        )
    return mask


def pinball_loss(
    pred: torch.Tensor,
    target: torch.Tensor,
    quantiles: Union[Sequence[float], torch.Tensor],
    mask: Optional[torch.Tensor] = None,
    *,
    reduction: _Reduction = "mean",
) -> torch.Tensor:
    """Quantile (pinball) loss over all quantile levels.

    Args:
        pred: ``(B, H, Q)`` normalized quantile forecasts, ordered to match
            ``quantiles``.
        target: ``(B, H)`` or ``(B, H, 1)`` normalized targets.
        quantiles: the ``Q`` levels, e.g. ``config.quantiles``.
        mask: ``(B, H)`` observedness, 1 for an observed target position and 0
            otherwise. ``None`` treats every position as observed.
        reduction: ``"mean"`` (default) averages over the batch, ``"sum"`` adds,
            ``"none"`` returns the ``(B,)`` per-sample losses.

    The per-sample loss divides by the observed count times ``Q``, so a sample
    with few observed positions is not down-weighted against a full one, and a
    sample with none contributes zero rather than a division by zero.

    A non-finite target position is replaced by the detached median forecast, so
    it contributes no loss and no gradient. Mask such positions out as well: the
    substitution is a guard, not the mechanism.
    """
    if pred.dim() != 3:
        raise ValueError(f"pred must be (B, H, Q), got {tuple(pred.shape)}.")
    target = _as_bh(target, "target")
    if pred.shape[:2] != target.shape:
        raise ValueError(
            f"pred {tuple(pred.shape)} and target {tuple(target.shape)} "
            "disagree on batch or horizon."
        )

    q = torch.as_tensor(quantiles, dtype=pred.dtype, device=pred.device)
    q = q.reshape(-1)
    n_q = pred.shape[-1]
    if q.numel() != n_q:
        raise ValueError(
            f"pred carries {n_q} quantile channels but {q.numel()} levels "
            "were given."
        )

    q_mid = n_q // 2
    target_b = target.unsqueeze(-1)
    fill = pred[..., q_mid: q_mid + 1].detach()
    target_safe = torch.where(torch.isfinite(target_b), target_b, fill)

    err = target_safe - pred
    q = q.view(1, 1, -1)
    loss = torch.maximum(q * err, (q - 1.0) * err)              # (B, H, Q)

    obs = _mask_like(mask, target)
    denom = obs.sum(dim=1).clamp(min=1.0) * float(n_q)
    per_sample = (loss * obs.unsqueeze(-1)).sum(dim=(1, 2)) / denom
    return _reduce(per_sample, reduction)


def seasonal_copy_baseline(
    context: torch.Tensor,
    horizon: int,
    scale_factor: Union[torch.Tensor, float],
    *,
    base_seasonality: float = BASE_SEASONALITY,
) -> torch.Tensor:
    """Repeat the last seasonal cycle of the context over the horizon.

    Args:
        context: ``(B, L)`` or ``(B, L, 1)`` context values, in whatever space
            the caller wants the copy in (raw or normalized).
        horizon: ``H``, how many steps to emit.
        scale_factor: per-sample or scalar seasonal scale factor ``s``, the
            value ``tinycast.scale.seasonal_scale_factor`` returns for the
            sample's frequency.
        base_seasonality: numerator of the lag, 24 by convention.

    The lag is ``round(base_seasonality / s)`` clipped to ``[2, L // 2]``, and
    position ``h`` of the copy is context position ``L - lag + (h mod lag)``.
    Returns ``(B, H)``.
    """
    if context.dim() == 3 and context.shape[-1] == 1:
        context = context.squeeze(-1)
    if context.dim() != 2:
        raise ValueError(
            f"context must be (B, L) or (B, L, 1), got {tuple(context.shape)}."
        )
    b, length = context.shape
    horizon = int(horizon)
    if horizon < 1:
        raise ValueError(f"horizon must be positive, got {horizon}.")
    if length < 4:
        raise ValueError(f"context is too short to fold a cycle: L={length}.")

    sf = torch.as_tensor(scale_factor, dtype=torch.float32, device=context.device)
    sf = sf.reshape(-1)
    if sf.numel() == 1:
        sf = sf.expand(b)
    elif sf.numel() != b:
        raise ValueError(
            f"scale_factor carries {sf.numel()} entries for a batch of {b}."
        )

    lag = (base_seasonality / sf.clamp(min=1e-3)).round().long()
    lag = lag.clamp(2, max(2, length // 2)).view(-1, 1)         # (B, 1)

    h = torch.arange(horizon, device=context.device).view(1, horizon)
    src = (length - lag + (h % lag)).clamp(0, length - 1)       # (B, H)
    return torch.gather(context, 1, src)


def committing_loss(
    median: torch.Tensor,
    target: torch.Tensor,
    seasonal_copy: torch.Tensor,
    mask: Optional[torch.Tensor] = None,
    *,
    weight: float = COMMIT_WEIGHT,
    gated: bool = True,
    reduction: _Reduction = "mean",
) -> torch.Tensor:
    """Gated committing term: penalize a median that hedges below the copy.

    Args:
        median: ``(B, H)`` normalized median forecast.
        target: ``(B, H)`` normalized targets.
        seasonal_copy: ``(B, H)`` the reference from
            ``seasonal_copy_baseline``, in the same space as ``median``.
        mask: ``(B, H)`` observedness; ``None`` treats every position as
            observed.
        weight: the multiplier the trainer applies, 0.3 in the shipped recipe.
        gated: apply the window-level gate. With ``False`` the hinge is scored
            on every window.
        reduction: as in :func:`pinball_loss`.

    The hinge ``relu(|median - target| - |copy - target|)`` is zero wherever the
    median is already at least as close as the copy, so the term stops as soon
    as the median reaches it. The gate multiplies the whole window by zero
    unless the copy beats the median summed over the observed positions, which
    keeps the term silent on windows the model already wins.

    Positions where any of the three inputs is non-finite contribute nothing,
    to the hinge and to the gate alike. The average is still taken over the
    observed count, so masking is what sets the denominator.
    """
    median = _as_bh(median, "median")
    target = _as_bh(target, "target")
    seasonal_copy = _as_bh(seasonal_copy, "seasonal_copy")
    if median.shape != target.shape or median.shape != seasonal_copy.shape:
        raise ValueError(
            f"median {tuple(median.shape)}, target {tuple(target.shape)} and "
            f"seasonal_copy {tuple(seasonal_copy.shape)} must agree."
        )

    obs = _mask_like(mask, target)
    med_err = (median - target).abs()
    copy_err = (seasonal_copy - target).abs()

    finite = (
        torch.isfinite(med_err) & torch.isfinite(copy_err) & torch.isfinite(target)
    ).to(obs.dtype)
    scored = obs * finite

    hinge = torch.relu(med_err - copy_err)
    hinge = torch.where(torch.isfinite(hinge), hinge, torch.zeros_like(hinge))
    denom = obs.sum(dim=1).clamp(min=1.0)
    per_sample = (hinge * scored).sum(dim=1) / denom

    if gated:
        med_total = (torch.nan_to_num(med_err, 0.0, 0.0, 0.0) * scored).sum(dim=1)
        copy_total = (torch.nan_to_num(copy_err, 0.0, 0.0, 0.0) * scored).sum(dim=1)
        per_sample = per_sample * (copy_total < med_total).to(per_sample.dtype)

    return _reduce(per_sample * float(weight), reduction)


__all__ = [
    "BASE_SEASONALITY",
    "COMMIT_WEIGHT",
    "pinball_loss",
    "seasonal_copy_baseline",
    "committing_loss",
]