| """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 |
|
|
| |
| |
| |
| |
| from .scale import BASE_SEASONALITY |
|
|
| |
| 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) |
|
|
| 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) |
|
|
| h = torch.arange(horizon, device=context.device).view(1, horizon) |
| src = (length - lag + (h % lag)).clamp(0, length - 1) |
| 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", |
| ] |
|
|