File size: 6,092 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 | """Eval-time period-alignment downsampling rule.
Some datasets carry their dominant seasonality at a clean integer multiple of
the canonical (samples-per-day) period for their sampling frequency, with the
canonical period itself absent. For example, bizitobs_l2c at 5T has no daily
cycle at all, only weekly (2016 samples = 7x the canonical 288). Models calibrated
around the canonical period grid systematically mis-handle such series;
downsampling the context by the multiple aliases the dominant period back onto
the grid (weekly-at-5T becomes daily-at-35T) and restores in-distribution
behaviour. This module derives the factor from the CONTEXT data (a k=7 alias
on bizitobs_l2c/5T medium+long), so no per-dataset hand-tuning and no
information beyond the model's own inputs is used.
Fire conditions (all required, and deliberately narrow: downsampling a config
whose canonical peak is intact makes MASE strictly worse, m4_hourly's strong
weekly harmonic included):
1. sub-daily frequency (canonical period >= MIN_CANONICAL samples/day);
2. a dominant spectral peak at period P with P/canonical within
``REL_TOL`` of an integer k in [2, MAX_K];
3. the canonical-period peak is ABSENT: power near the canonical period is
below ``CANONICAL_ABSENT`` x the dominant peak's power;
4. the dominant peak is significant: >= ``PEAK_SIG`` x median spectral power;
5. a >= ``QUORUM`` fraction of sampled series agree on the same k;
6. the downsampled context still fills the model window
(median len / k >= ``context_window``, else the aliasing starves the
encoder: on bizitobs_l2c/H, where T/k ~ 358, k=7 turns from a win into a
loss at long horizons);
7. the horizon spans at least one canonical day (H >= canonical, else the
coarse forecast's interpolation loses more than the aliasing gains: on
bizitobs_l2c/5T/short, H=48 against a canonical 288, by +0.05 MASE).
"""
from __future__ import annotations
from typing import Iterable, Optional
import numpy as np
MIN_CANONICAL = 8 # rule inactive for daily-or-coarser frequencies
MAX_K = 16
REL_TOL = 0.06 # |P/canonical - k| <= REL_TOL * k (after sub-bin refinement)
CANONICAL_ABSENT = 0.10 # canonical peak power < 10% of dominant peak power
PEAK_SIG = 20.0 # dominant peak >= 20x median spectral power
QUORUM = 0.7
MIN_CYCLES = 3 # dominant period must repeat >= 3x in the analysed tail
TAIL = 16384 # analyse at most this many trailing samples
N_SAMPLE = 64 # series sampled per config
def _series_factor(x: np.ndarray, canonical: int) -> int:
x = np.asarray(x, dtype=np.float64)
x = x[np.isfinite(x)]
if x.size < MIN_CYCLES * 2 * canonical:
return 1
x = x[-TAIL:]
n = x.size
x = x - x.mean()
p = np.abs(np.fft.rfft(x)) ** 2
p[0] = 0.0
med = np.median(p[1:])
if med <= 0.0:
return 1
periods = np.full(p.shape, np.inf)
periods[1:] = n / np.arange(1, p.shape[0], dtype=np.float64)
# Power near the canonical period (max over a +-10% band).
canon_band = (periods >= 0.9 * canonical) & (periods <= 1.1 * canonical)
canon_pow = p[canon_band].max() if canon_band.any() else 0.0
# Dominant peak among periods that are >= 1.5x canonical and repeat
# >= MIN_CYCLES times in the analysed tail.
cand = (periods >= 1.5 * canonical) & (periods <= n / MIN_CYCLES)
if not cand.any():
return 1
idx = int(np.flatnonzero(cand)[np.argmax(p[cand])])
peak_pow = p[idx]
if peak_pow < PEAK_SIG * med:
return 1
if canon_pow > CANONICAL_ABSENT * peak_pow:
return 1 # canonical period present: do not alias
# Sub-bin peak refinement (parabolic on log-power): the raw frequency
# grid is coarse at long periods (spacing ~P^2/n, i.e. ~12% of P for
# weekly-at-5T in a 16k tail), which would let non-integer multiples
# masquerade as clean ones under any workable tolerance.
delta = 0.0
if 1 <= idx < p.shape[0] - 1 and p[idx - 1] > 0 and p[idx + 1] > 0:
lp = np.log(p[idx - 1:idx + 2])
denom = lp[0] - 2.0 * lp[1] + lp[2]
if denom < 0:
delta = float(np.clip(0.5 * (lp[0] - lp[2]) / denom, -0.5, 0.5))
refined_period = n / (idx + delta)
r = refined_period / canonical
k = int(round(r))
if k < 2 or k > MAX_K or abs(r - k) > REL_TOL * k:
return 1
return k
def period_alignment_factor(
contexts: Iterable[np.ndarray],
freq_seconds: float,
horizon: int,
context_window: int = 2048,
n_sample: int = N_SAMPLE,
) -> int:
"""Downsample factor for one eval config, from context windows only.
``contexts`` are the test INPUT series (the model's own inputs);
``freq_seconds`` is the sampling interval; ``horizon`` the prediction
length; ``context_window`` the model's encoder window. Returns 1 unless
the config's series agree (>= QUORUM) on the same aliasing multiple k >= 2
and the horizon/window guards pass.
"""
canonical = int(round(86400.0 / float(freq_seconds)))
if canonical < MIN_CANONICAL:
return 1
if int(horizon) < canonical: # guard 7: >= one canonical day
return 1
ks = []
lens = []
for x in contexts:
x = np.asarray(x, dtype=np.float64)
ks.append(_series_factor(x, canonical))
lens.append(x.size)
if len(ks) >= n_sample:
break
if not ks:
return 1
vals, counts = np.unique(ks, return_counts=True)
best = int(vals[np.argmax(counts)])
if best == 1 or counts.max() / len(ks) < QUORUM:
return 1
if float(np.median(lens)) / best < context_window: # guard 6
return 1
return best
def freq_to_seconds(freq: str) -> Optional[float]:
"""Sampling interval in seconds for a pandas-style freq string, or None."""
import pandas as pd
try:
off = pd.tseries.frequencies.to_offset(freq)
return pd.Timedelta(off).total_seconds()
except (ValueError, TypeError):
return None
|