| """Dilated-convolution forecasting backbone. |
| |
| An attention-free encoder: a stack of causal dilated convolutions. With |
| kernel K=3 and dilations d_i = 2^(i-1) for i=1..N the receptive field is |
| |
| RF = 1 + 2 * sum_i d_i = 1 + 2 * (2^N - 1) |
| |
| so N=10 layers cover RF=2047, sufficient for the L=2048 context with zero |
| downsampling and no information loss at any time scale. Native multi-scale |
| via the dilation schedule; deployment-friendly (no L^2 attention matrix, no |
| softmax, pure matmul + element-wise; quantizes cleanly to INT8); streaming- |
| friendly (left-only causal padding). |
| |
| Structural priors (zero-parameter): |
| - a normalized-periodogram period detector driving a phase encoding |
| - bounded recency basis (signed-linear/log, multi-scale exp decay) |
| - position-parameterized decoder queries (single-shot arbitrary horizon) |
| - no autoregressive rollout inside the backbone |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| from .periodogram import significant_periods |
| from .encoding import ( |
| N_RECENCY_CHANNELS, |
| _norm_fp32, |
| _phase_encoding, |
| _positional_encoding, |
| ) |
|
|
|
|
| class _SwiGLU(nn.Module): |
| """Standard SwiGLU FFN.""" |
|
|
| def __init__(self, d: int, d_hidden: int) -> None: |
| super().__init__() |
| self.up = nn.Linear(d, 2 * d_hidden) |
| self.down = nn.Linear(d_hidden, d) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| gate, val = self.up(x).chunk(2, dim=-1) |
| return self.down(F.silu(gate) * val) |
|
|
|
|
| class _DilatedConvBlock(nn.Module): |
| """Dilated Conv1d → RMSNorm → SwiGLU → RMSNorm with residuals. |
| |
| ``causal=True``, which is what the released config sets, pads all (K-1)*d |
| timesteps on the left, so each output position sees only its own past. With |
| ``causal=False`` the padding is centered instead: length is still preserved |
| and each position gets a symmetric view of (K-1)*d/2 timesteps on either |
| side, at the cost of future-side context. Under both, the dilation scales |
| the per-layer receptive field without adding parameters. |
| """ |
|
|
| def __init__( |
| self, d: int, kernel: int = 3, dilation: int = 1, |
| ffn_mult: float = 1.5, causal: bool = False, gated: bool = False, |
| separable: bool = False, |
| ) -> None: |
| super().__init__() |
| self.k = int(kernel) |
| self.dilation = int(dilation) |
| self.causal = bool(causal) |
| self.gated = bool(gated) |
| self.separable = bool(separable) |
| |
| |
| |
| |
| |
| |
| |
| if self.separable: |
| |
| |
| |
| |
| |
| self.conv = nn.Sequential( |
| nn.Conv1d(d, d, kernel_size=self.k, dilation=self.dilation, groups=d), |
| nn.Conv1d(d, d, kernel_size=1), |
| ) |
| else: |
| self.conv = nn.Conv1d(d, d, kernel_size=self.k, dilation=self.dilation) |
| |
| |
| |
| |
| self.gate = ( |
| nn.Conv1d(d, d, kernel_size=self.k, dilation=self.dilation, groups=d) |
| if self.gated else None |
| ) |
| self.norm1 = nn.RMSNorm(d) |
| d_hidden = int(d * ffn_mult) |
| self.ffn = _SwiGLU(d, d_hidden) |
| self.norm2 = nn.RMSNorm(d) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| |
| x_t = x.transpose(1, 2) |
| |
| pad_total = (self.k - 1) * self.dilation |
| if self.causal: |
| left, right = pad_total, 0 |
| else: |
| left = pad_total // 2 |
| right = pad_total - left |
| x_t = F.pad(x_t, (left, right)) |
| if self.separable and self.gate is None: |
| |
| |
| |
| |
| dw = self.conv[0](x_t).transpose(1, 2) |
| pw = self.conv[1] |
| conv_out = F.linear(dw, pw.weight.squeeze(-1), pw.bias) |
| else: |
| conv_out = self.conv(x_t) |
| if self.gate is not None: |
| conv_out = conv_out * torch.sigmoid(self.gate(x_t)) |
| conv_out = conv_out.transpose(1, 2) |
| x = _norm_fp32(self.norm1, x + conv_out) |
| x = _norm_fp32(self.norm2, x + self.ffn(x)) |
| return x |
|
|
|
|
| class DilatedConvBackbone(nn.Module): |
| """Dilated-conv encoder with a phase-conditioned, position-parameterized decoder. |
| |
| Args: |
| seq_len: L, the context length. |
| p_out: H, the single-shot output length. |
| n_quantiles: Q, the number of output channels. |
| d: channel dimension. |
| n_layers: number of dilated-conv blocks (10 → RF 2047 ≥ L=2048). |
| kernel: conv kernel size (default 3). |
| ffn_mult: SwiGLU hidden multiplier (default 1.5). |
| dilations: explicit dilation schedule; if None, uses 2^(i-1). |
| top_k_periods: the periodogram detector's top-K (default 4). |
| significance_alpha: the periodogram detector's Bonferroni alpha (default 0.05). |
| n_harmonics: Fourier harmonics per detected period (default 1). |
| pool_kind: context-summary pooling: "mean_last" (default), |
| "mean", "last". Concat the chosen pool(s) into the |
| per-horizon query before query_proj. |
| causal: if True, all conv padding is left-only (no future |
| leakage, clean right edge, streaming-honest). |
| phase_bins: if > 0, augment the global pool with a period-folded |
| seasonal profile: for each period the periodogram |
| detector returns, fold the encoder output into this many |
| phase bins and let each decoder query gather the bin |
| matching its own phase. 0 disables phase folding and |
| leaves the plain global pool, which is the control the |
| paper's phase-folding ablation is measured against. |
| """ |
|
|
| def __init__( |
| self, |
| seq_len: int, |
| p_out: int, |
| n_quantiles: int = 1, |
| d: int = 80, |
| n_layers: int = 10, |
| kernel: int = 3, |
| ffn_mult: float = 1.5, |
| dilations: list[int] | None = None, |
| top_k_periods: int = 4, |
| significance_alpha: float = 0.05, |
| n_harmonics: int = 1, |
| pool_kind: str = "mean_last", |
| causal: bool = False, |
| phase_bins: int = 0, |
| phase_stats: str = "mean", |
| phase_recency_tau: float = 0.0, |
| recency_bins: int = 0, |
| sig_gate: bool = False, |
| cross_cycle: bool = False, |
| decoder_depth: int = 1, |
| horizon_kernel: int = 0, |
| horizon_recurrence: bool = False, |
| min_cycles: int = 0, |
| period_trust: str = "off", |
| gated_conv: bool = False, |
| residual_naive: bool = False, |
| residual_multi: bool = False, |
| residual_trend: bool = False, |
| decompose_kernel: int = 0, |
| periodogram_off: bool = False, |
| res_adaptive: bool = False, |
| res_period_target: int = 64, |
| res_r_max: float = 32.0, |
| with_missing: bool = False, |
| missing_channel: bool = False, |
| separable_conv: bool = False, |
| share_ffn: bool = False, |
| future_conv: bool = False, |
| future_conv_layers: int = 6, |
| future_conv_seed: int = 128, |
| base_seasonality: float = 24.0, |
| local_anchor: bool = False, |
| ) -> None: |
| super().__init__() |
| self.L = int(seq_len) |
| self.p_out = int(p_out) |
| self.n_quantiles = int(n_quantiles) |
| self.D = int(d) |
| self.K = int(top_k_periods) |
| self.significance_alpha = float(significance_alpha) |
| self.n_harmonics = int(n_harmonics) |
| self.pool_kind = str(pool_kind) |
| self.causal = bool(causal) |
| self.phase_bins = int(phase_bins) |
| if phase_stats not in ("mean", "mean_var"): |
| raise ValueError(f"phase_stats={phase_stats!r}; expected 'mean'|'mean_var'.") |
| self.phase_stats = str(phase_stats) |
| self.stat_mult = 2 if self.phase_stats == "mean_var" else 1 |
| self.phase_recency_tau = float(phase_recency_tau) |
| self.recency_bins = int(recency_bins) |
| self.sig_gate = bool(sig_gate) |
| self.cross_cycle = bool(cross_cycle) |
| self.decoder_depth = max(1, int(decoder_depth)) |
| self.horizon_kernel = int(horizon_kernel) |
| self.horizon_recurrence = bool(horizon_recurrence) |
| self.min_cycles = int(min_cycles) |
| if period_trust not in ("off", "coverage", "full"): |
| raise ValueError(f"period_trust={period_trust!r}; expected off|coverage|full") |
| self.period_trust = str(period_trust) |
| |
| |
| |
| |
| |
| |
| |
| |
| if self.period_trust != "off": |
| n_feat = 1 if self.period_trust == "coverage" else 2 |
| self.pt = nn.Linear(n_feat, 1) |
| with torch.no_grad(): |
| self.pt.weight.zero_(); self.pt.weight[0, 0] = 1.0 |
| self.pt.bias.zero_() |
| |
| n_fft = 1 << int(math.ceil(math.log2(max(2, self.L)))) |
| self._n_bins = max(2, n_fft // 2) |
| self._t_alpha = math.log(self._n_bins / max(self.significance_alpha, 1e-12)) / self._n_bins |
| else: |
| self.pt = None |
| self.gated_conv = bool(gated_conv) |
| self.residual_naive = bool(residual_naive) |
| self.residual_multi = bool(residual_multi) |
| self.residual_trend = bool(residual_trend) |
| self.periodogram_off = bool(periodogram_off) |
| self.res_adaptive = bool(res_adaptive) |
| self.res_period_target = int(res_period_target) |
| self.res_r_max = float(res_r_max) |
| |
| |
| self.decompose_kernel = int(decompose_kernel) |
| self.with_missing = bool(with_missing) |
| |
| |
| |
| self.missing_channel = bool(missing_channel) |
| if self.missing_channel and bool(res_adaptive): |
| raise NotImplementedError( |
| "missing_channel + res_adaptive: the observed-mask is not warped " |
| "through _resolution_adapt; not supported together." |
| ) |
| |
| |
| |
| |
| self.local_anchor = bool(local_anchor) |
|
|
| if self.n_harmonics < 1: |
| raise ValueError(f"n_harmonics must be >= 1; got {self.n_harmonics}") |
| if pool_kind not in ("mean_last", "mean", "last"): |
| raise ValueError( |
| f"pool_kind={pool_kind!r}; expected 'mean_last' | 'mean' | 'last'." |
| ) |
|
|
| |
| n_phase = 2 * self.K * self.n_harmonics |
| n_pe = n_phase + N_RECENCY_CHANNELS |
| |
| n_value_ch = 2 if self.decompose_kernel > 0 else 1 |
| if self.missing_channel: |
| n_value_ch += 1 |
| if self.local_anchor: |
| n_value_ch += 2 |
| in_channels = n_value_ch + n_pe |
| self.in_proj = nn.Linear(in_channels, self.D) |
| if self.local_anchor: |
| |
| |
| with torch.no_grad(): |
| self.in_proj.weight[:, n_value_ch - 2:n_value_ch].zero_() |
|
|
| |
| if dilations is None: |
| dilations = [2**i for i in range(int(n_layers))] |
| if len(dilations) != int(n_layers): |
| raise ValueError( |
| f"dilations length {len(dilations)} != n_layers {n_layers}" |
| ) |
| self.dilations = list(dilations) |
|
|
| |
| rf = 1 + (kernel - 1) * sum(self.dilations) |
| self.receptive_field = rf |
|
|
| self.encoder = nn.ModuleList([ |
| _DilatedConvBlock( |
| self.D, kernel=int(kernel), dilation=int(d_i), |
| ffn_mult=float(ffn_mult), causal=self.causal, |
| gated=self.gated_conv, separable=bool(separable_conv), |
| ) |
| for d_i in self.dilations |
| ]) |
| |
| |
| |
| |
| self.share_ffn = bool(share_ffn) |
| if self.share_ffn and len(self.encoder) > 1: |
| shared_ffn = self.encoder[0].ffn |
| for blk in self.encoder[1:]: |
| blk.ffn = shared_ffn |
|
|
| |
| pool_dim = {"mean_last": 2 * self.D, "mean": self.D, "last": self.D}[ |
| self.pool_kind |
| ] |
|
|
| |
| |
| |
| |
| |
| |
| |
| if self.phase_bins > 0: |
| self.phase_mix = nn.Linear(self.K * self.stat_mult * self.D, self.D) |
| else: |
| self.phase_mix = None |
|
|
| |
| |
| if self.recency_bins > 0: |
| self.recency_mix = nn.Linear( |
| self.recency_bins * self.stat_mult * self.D, self.D, |
| ) |
| else: |
| self.recency_mix = None |
|
|
| |
| |
| |
| if self.cross_cycle: |
| self.cc_bins = self.phase_bins if self.phase_bins > 0 else 16 |
| self.cc_cycles = 8 |
| |
| |
| self.cc_conv = nn.Conv1d( |
| self.D, self.D, kernel_size=3, padding=1, groups=self.D, |
| ) |
| self.cc_mix = nn.Linear(self.D, self.D) |
| else: |
| self.cc_conv = None |
|
|
| |
| |
| |
| query_in = n_pe + pool_dim |
| if self.sig_gate and self.phase_mix is not None and self.recency_mix is not None: |
| query_in += self.D |
| else: |
| query_in += self.D if self.phase_mix is not None else 0 |
| query_in += self.D if self.recency_mix is not None else 0 |
| query_in += self.D if self.cross_cycle else 0 |
| self.query_proj = nn.Linear(query_in, self.D) |
| d_hidden = int(self.D * float(ffn_mult)) |
| |
| |
| |
| self.decoder_ffns = nn.ModuleList( |
| [_SwiGLU(self.D, d_hidden) for _ in range(self.decoder_depth)] |
| ) |
| self.decoder_norms = nn.ModuleList( |
| [nn.RMSNorm(self.D) for _ in range(self.decoder_depth)] |
| ) |
| |
| |
| |
| |
| if self.horizon_kernel > 0: |
| self.horizon_conv = nn.Conv1d( |
| self.D, self.D, kernel_size=self.horizon_kernel, groups=self.D, |
| ) |
| self.horizon_norm = nn.RMSNorm(self.D) |
| else: |
| self.horizon_conv = None |
| |
| |
| |
| |
| |
| |
| |
| if self.horizon_recurrence: |
| self.hr_z = nn.Linear(self.D, self.D) |
| self.hr_c = nn.Linear(self.D, self.D) |
| self.hr_o = nn.Linear(self.D, self.D) |
| nn.init.zeros_(self.hr_o.weight); nn.init.zeros_(self.hr_o.bias) |
| self.hr_norm = nn.RMSNorm(self.D) |
| else: |
| self.hr_z = None |
| self.out_proj = nn.Linear(self.D, self.n_quantiles) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| self.future_conv = bool(future_conv) |
| if self.future_conv: |
| if res_adaptive: |
| raise ValueError("future_conv is incompatible with res_adaptive") |
| self.fc_seed = int(future_conv_seed) |
| fc_in = 1 + n_pe |
| self.fc_in_proj = nn.Linear(fc_in, self.D) |
| fc_dils = [2 ** i for i in range(int(future_conv_layers))] |
| self.fc_blocks = nn.ModuleList([ |
| _DilatedConvBlock( |
| self.D, kernel=int(kernel), dilation=int(d_i), |
| ffn_mult=float(ffn_mult), causal=True, |
| separable=True, |
| ) |
| for d_i in fc_dils |
| ]) |
| |
| |
| shared = self.fc_blocks[0].ffn |
| for blk in self.fc_blocks[1:]: |
| blk.ffn = shared |
| self.fc_out = nn.Linear(self.D, self.D) |
| nn.init.zeros_(self.fc_out.weight) |
| nn.init.zeros_(self.fc_out.bias) |
|
|
| self.base_seasonality = float(base_seasonality) |
|
|
| |
|
|
| def _future_conv_states( |
| self, h: torch.Tensor, fut_pe: torch.Tensor, fill: torch.Tensor, |
| ) -> torch.Tensor: |
| """Causal-conv continuation states at the H future positions. |
| |
| h: (B, L, D) encoder output; fut_pe: (B, H, n_pe); fill: (B, H) the |
| seasonal-naive future continuation. Returns (B, H, D). |
| """ |
| |
| |
| |
| ft = self.fc_in_proj(torch.cat([fill.unsqueeze(-1), fut_pe], dim=-1)) |
| seed = h[:, -min(self.fc_seed, self.L):, :] |
| z = torch.cat([seed, ft], dim=1) |
| for blk in self.fc_blocks: |
| z = blk(z) |
| return z[:, -ft.shape[1]:, :] |
|
|
| @torch.compiler.disable() |
| def _detect_periods( |
| self, x_fp32: torch.Tensor, |
| ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: |
| """Run the significance-filtered periodogram in fp32 (no grad). |
| |
| @torch.compiler.disable: the periodogram uses a complex rfft that |
| Inductor cannot codegen: left inside the compiled graph it forces an |
| eager fallback + graph break every forward, early in `forward`, blocking |
| fusion of the whole conv encoder/decoder downstream. Disabling compile on |
| this (no_grad, fp32, produces integer periods the rest only reads) makes a |
| clean eager boundary: the FFT runs eager, everything after fuses. Output |
| bit-identical (only where it compiles changes). |
| |
| Returns ``(periods, n_valid, scores)``: the integer periods (0 = |
| rejected), the count of significant periods per sample, and the |
| per-period periodogram scores. ``n_valid`` is the periodicity-strength |
| signal the significance gate reads. |
| """ |
| B, L = x_fp32.shape |
| if self.periodogram_off: |
| |
| |
| |
| z = torch.zeros(B, self.K, dtype=torch.long, device=x_fp32.device) |
| return (z, torch.zeros(B, dtype=torch.long, device=x_fp32.device), |
| torch.zeros(B, self.K, device=x_fp32.device)) |
| with torch.no_grad(): |
| periods, scores, n_valid = significant_periods( |
| x_fp32, |
| min_period=2, |
| max_period=L // 2, |
| top_k=self.K, |
| significance_alpha=self.significance_alpha, |
| ) |
| periods = periods.long() |
| if self.min_cycles > 0: |
| |
| |
| |
| |
| |
| max_p = L // self.min_cycles |
| keep = (periods > 0) & (periods <= max_p) |
| periods = torch.where(keep, periods, torch.zeros_like(periods)) |
| n_valid = keep.sum(dim=1).long() |
| return periods, n_valid.long(), scores.float() |
|
|
| def _pool(self, h: torch.Tensor) -> torch.Tensor: |
| """Pool encoder output to a fixed-size summary. |
| |
| h: (B, L, D) |
| Returns: (B, pool_dim) |
| """ |
| if self.pool_kind == "mean": |
| return h.mean(dim=1) |
| if self.pool_kind == "last": |
| return h[:, -1, :] |
| |
| return torch.cat([h.mean(dim=1), h[:, -1, :]], dim=-1) |
|
|
| def _scatter_profile( |
| self, h: torch.Tensor, bins: torch.Tensor, nb: int, |
| weight: torch.Tensor | None = None, |
| ) -> torch.Tensor: |
| """Weighted scatter-mean (+ optional per-bin variance) of h into nb bins. |
| |
| h: (B, L, D) |
| bins: (B, L) int in [0, nb) |
| weight: (B, L) non-negative, or None for uniform. |
| Returns (B, nb, stat_mult·D): per-bin mean, then per-bin variance if |
| ``phase_stats == 'mean_var'``. Empty bins → global mean |
| (and zero variance). |
| """ |
| B, L, D = h.shape |
| oh = F.one_hot(bins, nb).to(h.dtype) |
| if weight is not None: |
| oh = oh * weight.unsqueeze(-1) |
| cnt = oh.sum(dim=1).unsqueeze(-1) |
| denom = cnt.clamp(min=1e-6) |
| mean = torch.bmm(oh.transpose(1, 2), h) / denom |
| gmean = h.mean(dim=1, keepdim=True) |
| empty = cnt <= 0 |
| mean = torch.where(empty, gmean.expand(B, nb, D), mean) |
| if self.phase_stats == "mean_var": |
| sq = torch.bmm(oh.transpose(1, 2), h * h) / denom |
| var = (sq - mean * mean).clamp(min=0.0) |
| var = torch.where(empty, torch.zeros_like(var), var) |
| return torch.cat([mean, var], dim=-1) |
| return mean |
|
|
| def _recency_weight(self, L: int, device) -> torch.Tensor | None: |
| """exp recency weight over context positions: recent positions weigh |
| more, so the folded profile tracks the CURRENT regime's waveform rather |
| than the window average. None if tau<=0 (uniform).""" |
| if self.phase_recency_tau <= 0.0: |
| return None |
| t = torch.arange(L, device=device).float() |
| dist = (L - 1 - t) / L |
| return torch.exp(-dist / self.phase_recency_tau).view(1, L) |
|
|
| def _phase_profile( |
| self, h: torch.Tensor, periods: torch.Tensor, |
| ) -> torch.Tensor: |
| """Period-fold the encoder output into per-phase profiles. |
| |
| Returns (B, K, n_bins, stat_mult·D). Each detected period p_k folds |
| the sequence into n_bins phase bins; per bin we keep the (recency- |
| weighted) mean [and variance]. Empty bins → global mean. |
| """ |
| B, L, D = h.shape |
| K, nb = self.K, self.phase_bins |
| t = torch.arange(L, device=h.device).view(1, L).float() |
| p_safe = periods.clamp(min=1).float() |
| weight = self._recency_weight(L, h.device) |
| if weight is not None: |
| weight = weight.expand(B, L) |
| profs = [] |
| for k in range(K): |
| frac = (t % p_safe[:, k:k + 1]) / p_safe[:, k:k + 1] |
| bins = torch.clamp((frac * nb).long(), max=nb - 1) |
| profs.append(self._scatter_profile(h, bins, nb, weight)) |
| return torch.stack(profs, dim=1) |
|
|
| def _gather_phase( |
| self, prof: torch.Tensor, fut_pos: torch.Tensor, |
| periods: torch.Tensor, weight: torch.Tensor | None = None, |
| ) -> torch.Tensor: |
| """Gather each future query's matching phase bin per period, mix → D. |
| |
| prof: (B, K, n_bins, S·D); fut_pos: (B, H); periods: (B, K). |
| weight: optional (B,K) per-period reliability weight (period_trust). |
| Returns (B, H, D). |
| """ |
| B, K, nb, SD = prof.shape |
| H = fut_pos.shape[1] |
| p_safe = periods.clamp(min=1).float().view(B, 1, K) |
| frac = (fut_pos.unsqueeze(-1).float() % p_safe) / p_safe |
| fbins = torch.clamp((frac * nb).long(), max=nb - 1) |
| idx = fbins.permute(0, 2, 1).unsqueeze(-1).expand(B, K, H, SD) |
| gathered = torch.gather(prof, 2, idx) |
| if weight is not None: |
| gathered = gathered * weight.view(B, K, 1, 1).to(gathered.dtype) |
| gathered = gathered.permute(0, 2, 1, 3).reshape(B, H, K * SD) |
| return self.phase_mix(gathered) |
|
|
| def _period_trust_weights( |
| self, periods: torch.Tensor, scores: torch.Tensor, |
| ) -> torch.Tensor: |
| """Hyperparameter-free per-period reliability weight w_k∈[0,1] (B,K). |
| |
| ln-coverage = ln(L/period) (data/structure-determined); for 'full' also |
| the significance margin ln(s_k/t_alpha) (s_k = the periodogram score, |
| t_alpha = data-determined Bonferroni threshold). The sigmoid crossover |
| is LEARNED (the linear layer's weights and bias), not a hand-set |
| threshold. 0 on rejected slots. |
| """ |
| valid = periods > 0 |
| logcov = torch.log(float(self.L) / periods.clamp(min=1).float()) |
| if self.period_trust == "coverage": |
| feat = logcov.unsqueeze(-1) |
| else: |
| margin = torch.log(scores.clamp(min=1e-12) / self._t_alpha) |
| feat = torch.stack([margin, logcov], dim=-1) |
| w = torch.sigmoid(self.pt(feat.to(self.pt.weight.dtype))).squeeze(-1) |
| return torch.where(valid, w, torch.zeros_like(w)) |
|
|
| def _recency_feat(self, h: torch.Tensor) -> torch.Tensor: |
| """Recency-binned profile: bin context positions by |
| log-distance-from-now and pool. Always valid (no period needed); |
| the aperiodic content path. Flattened to a single (B, D) descriptor |
| (broadcast to all horizons; the query's own PE carries how-far-ahead). |
| """ |
| B, L, D = h.shape |
| rb = self.recency_bins |
| t = torch.arange(L, device=h.device).view(1, L).float().expand(B, L) |
| dist = (L - 1 - t).clamp(min=0.0) |
| frac = torch.log1p(dist) / math.log1p(float(L - 1) + 1e-9) |
| bins = torch.clamp((frac * rb).long(), max=rb - 1) |
| prof = self._scatter_profile(h, bins, rb, None) |
| return self.recency_mix(prof.reshape(B, rb * self.stat_mult * D)) |
|
|
| def _cross_cycle_profile( |
| self, h: torch.Tensor, periods: torch.Tensor, |
| ) -> torch.Tensor: |
| """Cross-cycle conv, true ragged form. |
| |
| For the dominant period p0, fold the sequence into a |
| [cycles-back-from-now × phase] grid (B, nc, nb, D) by scatter-mean, |
| then convolve ACROSS the cycle axis at fixed phase (depthwise conv1d |
| over nc), modelling how each phase evolves cycle-to-cycle ("every |
| Monday 9am, trending up"). Read out the most-recent cycle (post-conv, |
| so it has seen the trend). Returns a (B, nb, D) phase profile the |
| decoder gathers by its own phase. |
| |
| Per-sample period handled like phase-binning: phase resampled to nb |
| fixed bins; cycles-back clamped to nc (older cycles fold into the |
| oldest slot). Attention-free, fixed-shape, batchable. |
| """ |
| B, L, D = h.shape |
| nb, nc = self.cc_bins, self.cc_cycles |
| t = torch.arange(L, device=h.device).view(1, L).float() |
| p0 = periods[:, :1].clamp(min=1).float() |
| pbin = torch.clamp(((t % p0) / p0 * nb).long(), max=nb - 1) |
| cyc = torch.clamp(((L - 1 - t) // p0).long(), max=nc - 1) |
| comb = (cyc * nb + pbin).clamp(min=0, max=nc * nb - 1) |
| oh = F.one_hot(comb, nc * nb).to(h.dtype) |
| cnt = oh.sum(dim=1).unsqueeze(-1).clamp(min=1e-6) |
| grid = (torch.bmm(oh.transpose(1, 2), h) / cnt).view(B, nc, nb, D) |
| |
| x = grid.permute(0, 2, 3, 1).reshape(B * nb, D, nc) |
| x = self.cc_conv(x).reshape(B, nb, D, nc) |
| return x[..., 0] |
|
|
| @staticmethod |
| def _prefix_integral( |
| f: torch.Tensor, Csum: torch.Tensor, xpad: torch.Tensor, L: int, |
| ) -> torch.Tensor: |
| """Integral of piecewise-constant x from 0 to fractional position f. |
| f: (B,M) in native units. Csum: (B,L+1) prefix sums; xpad: (B,L+1).""" |
| fc = f.clamp(0.0, float(L)) |
| k = torch.floor(fc).long() |
| rem = (fc - k.float()).to(Csum.dtype) |
| return torch.gather(Csum, 1, k) + rem * torch.gather(xpad, 1, k) |
|
|
| def _resolution_adapt( |
| self, x: torch.Tensor, periods: torch.Tensor, H: int, |
| ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: |
| """Resolution adaptation (the TinyCast premise; zero params). |
| |
| Resample the context onto a canonical-period grid so the fixed dilation |
| schedule spans consistent CYCLE-fractions across all sampling rates: the |
| dominant detected period is warped to ``res_period_target`` samples/cycle. |
| All detected periods scale by the same ratio; the future native horizon |
| is queried at its canonical-mapped position (decoder is position- |
| parameterized, so outputs are native values and no output resampling |
| is needed). |
| |
| Returns (x_canon (B,L), periods_canon (B,K), fut_pos_canon (B,H) float). |
| Aperiodic series (dominant period 0) pass through unchanged (r=1). |
| """ |
| B, L = x.shape |
| pt = float(self.res_period_target) |
| p0 = periods[:, 0].float() |
| r = torch.where(p0 > 0, pt / p0.clamp(min=1.0), torch.ones_like(p0)) |
| |
| |
| r = r.clamp(0.05, self.res_r_max).view(B, 1) |
| j = torch.arange(L, device=x.device).view(1, L).float() |
| |
| t = (L - 1) - ((L - 1) - j) / r |
| |
| |
| |
| t0 = torch.floor(t) |
| frac = (t - t0).to(x.dtype) |
| x_lin = (torch.gather(x, 1, t0.clamp(0, L - 1).long()) * (1.0 - frac) |
| + torch.gather(x, 1, (t0 + 1).clamp(0, L - 1).long()) * frac) |
| w = 1.0 / r |
| lo, hi = t - w / 2.0, t + w / 2.0 |
| Csum = F.pad(x.cumsum(dim=1), (1, 0)) |
| xpad = F.pad(x, (0, 1)) |
| denom = (hi.clamp(0.0, L) - lo.clamp(0.0, L)).clamp(min=1e-6) |
| x_area = (self._prefix_integral(hi, Csum, xpad, L) |
| - self._prefix_integral(lo, Csum, xpad, L)) / denom |
| x_canon = torch.where(r < 1.0, x_area, x_lin) |
| x_canon = x_canon * (t >= 0).to(x.dtype) |
| periods_canon = torch.round(periods.float() * r).long() |
| periods_canon = torch.where( |
| periods > 0, periods_canon.clamp(min=2), torch.zeros_like(periods), |
| ) |
| h_steps = torch.arange(1, H + 1, device=x.device).view(1, H).float() |
| fut_pos_canon = (L - 1) + h_steps * r |
| return x_canon, periods_canon, fut_pos_canon |
|
|
| def _seasonal_naive( |
| self, x: torch.Tensor, fut_pos: torch.Tensor, periods: torch.Tensor, |
| ) -> torch.Tensor: |
| """Value-space seasonal-naive baseline (zero params). |
| |
| Fold the (normalized) input x by the dominant period into phase bins, |
| take the per-phase mean VALUE, and gather the bin matching each future |
| query's phase. The network then learns only the residual on top of this |
| baseline: a target reframe, not added model complexity. Aperiodic / |
| empty-bin → fall back to the context mean (persistence-of-level). |
| |
| x: (B, L) fut_pos: (B, H) periods: (B, K) → (B, H) |
| """ |
| B, L = x.shape |
| nb = self.phase_bins if self.phase_bins > 0 else 16 |
| t = torch.arange(L, device=x.device).view(1, L).float() |
| p0 = periods[:, :1].clamp(min=1).float() |
| pbin = torch.clamp(((t % p0) / p0 * nb).long(), max=nb - 1) |
| oh = F.one_hot(pbin, nb).to(x.dtype) |
| cnt = oh.sum(dim=1) |
| base = torch.bmm(oh.transpose(1, 2), x.unsqueeze(-1)).squeeze(-1) |
| gmean = x.mean(dim=1, keepdim=True) |
| base = torch.where(cnt > 0, base / cnt.clamp(min=1.0), gmean.expand(B, nb)) |
| fb = torch.clamp((fut_pos.float() % p0) / p0 * nb, max=nb - 1).long() |
| return torch.gather(base, 1, fb) |
|
|
| def _super_naive( |
| self, x: torch.Tensor, fut_pos: torch.Tensor, periods: torch.Tensor, |
| ) -> torch.Tensor: |
| """Multi-seasonal "super-naive" baseline (zero params). |
| |
| Greedy additive decomposition over ALL significant periods: start from |
| the context mean, then for each significant period (strongest first) |
| fold the running residual into per-phase means, subtract it (deflate), |
| and accumulate that component's value at the future phase. Result: |
| baseline(L+h) = mean + Σ_k s_k[phase_k(L+h)], the genuine multi-period |
| seasonal-naive forecast. Non-significant periods (p=0) contribute zero. |
| |
| x: (B, L) fut_pos: (B, H) periods: (B, K) → (B, H) |
| """ |
| B, L = x.shape |
| H = fut_pos.shape[1] |
| nb = self.phase_bins if self.phase_bins > 0 else 16 |
| t = torch.arange(L, device=x.device).view(1, L).float() |
| if self.residual_trend: |
| |
| |
| tc = t - t.mean() |
| xc = x - x.mean(dim=1, keepdim=True) |
| slope = (tc * xc).sum(1, keepdim=True) / (tc * tc).sum().clamp(min=1.0) |
| intercept = x.mean(dim=1, keepdim=True) |
| tmean = t.mean() |
| trend_ctx = intercept + slope * (t - tmean) |
| r = x - trend_ctx |
| baseline = intercept + slope * (fut_pos.float() - tmean) |
| else: |
| mean = x.mean(dim=1, keepdim=True) |
| r = x - mean |
| baseline = mean.expand(B, H).clone() |
| for k in range(self.K): |
| pk = periods[:, k:k + 1].float() |
| sig = (pk > 0).to(x.dtype) |
| pks = pk.clamp(min=1.0) |
| pbin = torch.clamp((t % pks) / pks * nb, max=nb - 1).long() |
| oh = F.one_hot(pbin, nb).to(x.dtype) |
| cnt = oh.sum(dim=1).clamp(min=1.0) |
| s_k = torch.bmm(oh.transpose(1, 2), r.unsqueeze(-1)).squeeze(-1) / cnt |
| s_k = s_k * sig |
| r = r - torch.gather(s_k, 1, pbin) |
| fb = torch.clamp((fut_pos.float() % pks) / pks * nb, max=nb - 1).long() |
| baseline = baseline + torch.gather(s_k, 1, fb) |
| return baseline |
|
|
| def _gather_cc( |
| self, cc_prof: torch.Tensor, fut_pos: torch.Tensor, |
| periods: torch.Tensor, |
| ) -> torch.Tensor: |
| """Gather each future query's matching phase bin from the cross-cycle |
| profile (dominant period), mix → D. cc_prof: (B,nb,D).""" |
| B, nb, D = cc_prof.shape |
| H = fut_pos.shape[1] |
| p0 = periods[:, :1].clamp(min=1).float() |
| fb = torch.clamp((fut_pos.float() % p0) / p0 * nb, max=nb - 1).long() |
| gathered = torch.gather(cc_prof, 1, fb.unsqueeze(-1).expand(B, H, D)) |
| return self.cc_mix(gathered) |
|
|
| |
|
|
| def _local_anchor_channels( |
| self, x: torch.Tensor, scale_factor: torch.Tensor | float | None, |
| ) -> torch.Tensor: |
| """Two causal local-statistics channels exposing the recent level/scale to |
| the encoder (the gradient-connected re-anchoring signal WindowMinMax lacks): |
| ch1 = (x_t - m_t) / (s_t + eps) local-scale residual (a causal z-score) |
| ch2 = log(s_t + eps) log local scale (global normed range ~= 1) |
| m_t, s_t = causal boxcar mean / std over a trailing window w ~ one canonical |
| period round(base_seasonality / scale_factor), clamped [8, L//4], fallback 64. |
| Vectorized via cumsum + per-sample-window gather (O(L), no python loop). |
| """ |
| B, L = x.shape |
| device = x.device |
| if scale_factor is not None: |
| sf = (scale_factor if torch.is_tensor(scale_factor) |
| else x.new_tensor(scale_factor)).reshape(-1).float() |
| if sf.numel() == 1: |
| sf = sf.expand(B) |
| w = (self.base_seasonality / sf.clamp(min=1e-3)).round().long() |
| w = w.clamp(min=8, max=max(8, L // 4)) |
| else: |
| w = torch.full((B,), 64, device=device, dtype=torch.long) |
| |
| |
| |
| |
| |
| xf = x.float() |
| xc = xf - xf.mean(dim=1, keepdim=True) |
| cs = F.pad(torch.cumsum(xc, dim=1), (1, 0)) |
| cs2 = F.pad(torch.cumsum(xc * xc, dim=1), (1, 0)) |
| t = torch.arange(L, device=device).view(1, L).expand(B, L) |
| lo = (t - w.view(B, 1) + 1).clamp(min=0) |
| cnt = (t - lo + 1).float() |
| sum_x = cs.gather(1, t + 1) - cs.gather(1, lo) |
| sum_x2 = cs2.gather(1, t + 1) - cs2.gather(1, lo) |
| m = sum_x / cnt |
| s = (sum_x2 / cnt - m * m).clamp(min=0.0).sqrt() |
| eps = 1e-4 |
| ch1 = (xc - m) / (s + eps) |
| ch2 = torch.log(s + eps) |
| return torch.stack([ch1, ch2], dim=-1).to(x.dtype) |
|
|
| def forward( |
| self, |
| x_normed: torch.Tensor, |
| nan_mask: torch.Tensor | None = None, |
| scale_factor: torch.Tensor | float | None = None, |
| horizon: int | None = None, |
| ) -> torch.Tensor: |
| |
| |
| |
| obs_mask = None |
| if self.missing_channel and nan_mask is not None: |
| obs_mask = nan_mask[..., 0] if nan_mask.dim() == 3 else nan_mask |
|
|
| if x_normed.dim() == 3 and x_normed.shape[-1] > 1: |
| x = x_normed[..., 0] |
| elif x_normed.dim() == 3: |
| x = x_normed.squeeze(-1) |
| else: |
| x = x_normed |
|
|
| x = torch.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0) |
| B, L = x.shape |
| assert L == self.L, f"context length mismatch: got {L}, expected {self.L}" |
| H = self.p_out if horizon is None else int(horizon) |
| device = x.device |
|
|
| |
| periods, n_valid, scores = self._detect_periods(x.float()) |
|
|
| |
| |
| if self.res_adaptive: |
| x, periods, fut_pos = self._resolution_adapt(x, periods, H) |
| ctx_pos = torch.arange(L, device=device).view(1, L).expand(B, L) |
| else: |
| ctx_pos = torch.arange(L, device=device).view(1, L).expand(B, L) |
| fut_pos = torch.arange(L, L + H, device=device).view(1, H).expand(B, H) |
| with torch.amp.autocast( |
| device_type=device.type if x.is_cuda else "cpu", enabled=False, |
| ): |
| ctx_pe = _positional_encoding( |
| ctx_pos, periods, L, n_harmonics=self.n_harmonics, |
| ) |
| fut_pe = _positional_encoding( |
| fut_pos, periods, L, n_harmonics=self.n_harmonics, |
| ) |
| ctx_pe = ctx_pe.to(x.dtype) |
| fut_pe = fut_pe.to(x.dtype) |
|
|
| |
| |
| |
| ptw = None |
| if self.pt is not None: |
| ptw = self._period_trust_weights(periods, scores).to(x.dtype) |
| |
| |
| rep = self.n_harmonics * 2 |
| chan_w = ptw.repeat_interleave(rep, dim=1).view(B, 1, -1) |
| np_ = chan_w.shape[-1] |
| ctx_pe = torch.cat([ctx_pe[..., :np_] * chan_w, ctx_pe[..., np_:]], dim=-1) |
| fut_pe = torch.cat([fut_pe[..., :np_] * chan_w, fut_pe[..., np_:]], dim=-1) |
|
|
| |
| if self.decompose_kernel > 0: |
| |
| |
| k = self.decompose_kernel |
| pad = k // 2 |
| xp = F.pad(x.unsqueeze(1), (pad, pad), mode="replicate") |
| trend = F.avg_pool1d(xp, kernel_size=k, stride=1)[..., :L].squeeze(1) |
| seasonal = x - trend |
| value_ch = torch.stack([trend, seasonal], dim=-1) |
| else: |
| value_ch = x.unsqueeze(-1) |
| if self.missing_channel and obs_mask is not None: |
| value_ch = torch.cat( |
| [value_ch, obs_mask.unsqueeze(-1).to(value_ch.dtype)], dim=-1 |
| ) |
| if self.local_anchor: |
| value_ch = torch.cat( |
| [value_ch, self._local_anchor_channels(x, scale_factor)], dim=-1 |
| ) |
| ctx_in = torch.cat([value_ch, ctx_pe], dim=-1) |
| h = self.in_proj(ctx_in) |
|
|
| |
| for block in self.encoder: |
| h = block(h) |
|
|
| |
| summary = self._pool(h) |
| ctx_feat = summary.unsqueeze(1).expand(B, H, -1) |
|
|
| |
| |
| q_parts = [fut_pe, ctx_feat] |
|
|
| phase_feat = None |
| if self.phase_mix is not None: |
| prof = self._phase_profile(h, periods) |
| phase_feat = self._gather_phase(prof, fut_pos, periods, weight=ptw) |
|
|
| rec_feat = None |
| if self.recency_mix is not None: |
| rec_feat = self._recency_feat(h).unsqueeze(1).expand(B, H, self.D) |
|
|
| if self.sig_gate and phase_feat is not None and rec_feat is not None: |
| |
| |
| |
| strength = ptw.sum(dim=1) if ptw is not None else n_valid.float() |
| g = (strength / float(self.K)).clamp(0.0, 1.0).view(B, 1, 1) |
| q_parts.append(g * phase_feat + (1.0 - g) * rec_feat) |
| else: |
| if phase_feat is not None: |
| q_parts.append(phase_feat) |
| if rec_feat is not None: |
| q_parts.append(rec_feat) |
|
|
| if self.cross_cycle: |
| cc_prof = self._cross_cycle_profile(h, periods) |
| q_parts.append(self._gather_cc(cc_prof, fut_pos, periods)) |
|
|
| q_in = torch.cat(q_parts, dim=-1) |
| q = self.query_proj(q_in) |
|
|
| if self.future_conv: |
| |
| |
| |
| |
| fill = self._seasonal_naive(x, fut_pos, periods) |
| fut_states = self._future_conv_states(h, fut_pe, fill) |
| q = q + self.fc_out(fut_states) |
|
|
| for ffn, norm in zip(self.decoder_ffns, self.decoder_norms): |
| q = _norm_fp32(norm, q + ffn(q)) |
|
|
| if self.horizon_conv is not None: |
| |
| |
| qt = F.pad(q.transpose(1, 2), (self.horizon_kernel - 1, 0)) |
| hc = self.horizon_conv(qt).transpose(1, 2) |
| q = _norm_fp32(self.horizon_norm, q + hc) |
|
|
| if self.hr_z is not None: |
| |
| |
| |
| z = torch.sigmoid(self.hr_z(q)) |
| c = torch.tanh(self.hr_c(q)) |
| s = torch.zeros(B, self.D, dtype=q.dtype, device=q.device) |
| states = [] |
| for t in range(q.shape[1]): |
| s = (1.0 - z[:, t]) * s + z[:, t] * c[:, t] |
| states.append(s) |
| hstate = torch.stack(states, dim=1) |
| q = _norm_fp32(self.hr_norm, q + self.hr_o(hstate)) |
|
|
| y = self.out_proj(q) |
|
|
|
|
| if self.residual_naive: |
| |
| if self.residual_multi: |
| baseline = self._super_naive(x, fut_pos, periods) |
| else: |
| baseline = self._seasonal_naive(x, fut_pos, periods) |
| y = y + baseline.unsqueeze(-1) |
|
|
| return y |
|
|
|
|