| """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 |
| MAX_K = 16 |
| REL_TOL = 0.06 |
| CANONICAL_ABSENT = 0.10 |
| PEAK_SIG = 20.0 |
| QUORUM = 0.7 |
| MIN_CYCLES = 3 |
| TAIL = 16384 |
| N_SAMPLE = 64 |
|
|
|
|
| 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) |
|
|
| |
| canon_band = (periods >= 0.9 * canonical) & (periods <= 1.1 * canonical) |
| canon_pow = p[canon_band].max() if canon_band.any() else 0.0 |
|
|
| |
| |
| 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 |
|
|
| |
| |
| |
| |
| 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: |
| 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: |
| 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 |
|
|