test: add unit tests for configs, torch layers, utils, and base utils

Apply changes from PR #394 by @cj-wong:
- tests/__init__.py: package marker
- tests/test_base_utils.py: strip_leading_nans + linear_interpolation tests
- tests/test_configs.py: frozen dataclass, defaults, replace, equality tests
- tests/test_torch_layers.py: ResidualBlock, RMSNorm, RandomFourierFeatures
- tests/test_torch_utils.py: update_running_stats, revin, DecodeCache tests
This commit is contained in:
darkpowerxo
2026-04-08 14:13:37 -04:00
parent bc03b77e9e
commit c10494a4c5
5 changed files with 963 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Tests for TimesFM.
+168
View File
@@ -0,0 +1,168 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for NaN-handling and interpolation utilities in the base module.
``strip_leading_nans`` and ``linear_interpolation`` sit on the critical
inference path: every user input passes through them before being
patched and fed to the transformer. Incorrect behavior here — silently
keeping NaN values or interpolating the wrong indices — causes NaN
propagation through the entire model and produces garbage forecasts.
"""
import numpy as np
from timesfm.timesfm_2p5.timesfm_2p5_base import (
linear_interpolation,
strip_leading_nans,
)
# ---------------------------------------------------------------------------
# strip_leading_nans
# ---------------------------------------------------------------------------
class TestStripLeadingNans:
"""Tests for strip_leading_nans — removes leading NaN prefix."""
def test_no_nans_returns_unchanged(self):
"""An array without NaN values must pass through unmodified."""
arr = np.array([1.0, 2.0, 3.0])
result = strip_leading_nans(arr)
np.testing.assert_array_equal(result, arr)
def test_strips_leading_nans_only(self):
"""Leading NaNs are removed; NaNs embedded in the middle are kept."""
arr = np.array([np.nan, np.nan, 1.0, np.nan, 3.0])
result = strip_leading_nans(arr)
expected = np.array([1.0, np.nan, 3.0])
np.testing.assert_array_equal(result, expected)
def test_single_leading_nan(self):
"""Edge case: exactly one leading NaN."""
arr = np.array([np.nan, 5.0, 6.0])
result = strip_leading_nans(arr)
np.testing.assert_array_equal(result, np.array([5.0, 6.0]))
def test_no_leading_nan_with_internal_nans(self):
"""If the first element is valid, nothing is stripped regardless of
internal NaNs."""
arr = np.array([1.0, np.nan, np.nan, 4.0])
result = strip_leading_nans(arr)
np.testing.assert_array_equal(result, arr)
def test_single_valid_element(self):
"""A single non-NaN element must be returned as-is."""
arr = np.array([42.0])
result = strip_leading_nans(arr)
np.testing.assert_array_equal(result, np.array([42.0]))
def test_all_nans_returns_full_array(self):
"""When every element is NaN, ``np.argmax`` on an all-False mask
returns 0 — so the implementation returns the original array, not an
empty one.
This documents the *actual* behavior (which differs from the
docstring claim of returning an empty array). Downstream code
(``linear_interpolation``) is designed to handle this case.
"""
arr = np.array([np.nan, np.nan, np.nan])
result = strip_leading_nans(arr)
# Actual behavior: argmax(~isnan) = 0 when all NaN → returns full array.
assert len(result) == 3
assert np.all(np.isnan(result))
def test_preserves_dtype(self):
"""Output dtype must match input dtype (float32 stays float32)."""
arr = np.array([np.nan, 1.0, 2.0], dtype=np.float32)
result = strip_leading_nans(arr)
assert result.dtype == np.float32
# ---------------------------------------------------------------------------
# linear_interpolation
# ---------------------------------------------------------------------------
class TestLinearInterpolation:
"""Tests for linear_interpolation — fills NaN gaps via ``np.interp``."""
def test_no_nans_returns_identical(self):
"""Without NaN values the array is returned as-is (fast path)."""
arr = np.array([1.0, 2.0, 3.0])
result = linear_interpolation(arr.copy())
np.testing.assert_array_equal(result, arr)
def test_interpolates_single_interior_nan(self):
"""A single interior NaN is linearly interpolated from neighbors."""
arr = np.array([0.0, np.nan, 2.0])
result = linear_interpolation(arr)
np.testing.assert_allclose(result, [0.0, 1.0, 2.0])
def test_interpolates_multiple_interior_nans(self):
"""Multiple consecutive interior NaN values are interpolated."""
arr = np.array([0.0, np.nan, np.nan, 3.0])
result = linear_interpolation(arr)
np.testing.assert_allclose(result, [0.0, 1.0, 2.0, 3.0])
def test_extrapolates_trailing_nans(self):
"""Trailing NaN values are filled via ``np.interp`` which holds the
last known value (nearest-neighbor extrapolation)."""
arr = np.array([1.0, 2.0, np.nan, np.nan])
result = linear_interpolation(arr)
# np.interp extrapolates by clamping to boundary values.
np.testing.assert_allclose(result, [1.0, 2.0, 2.0, 2.0])
def test_extrapolates_leading_nans(self):
"""Leading NaN values are filled with the first valid value.
In practice ``strip_leading_nans`` runs first, but the function must
still be robust on its own.
"""
arr = np.array([np.nan, np.nan, 3.0, 4.0])
result = linear_interpolation(arr)
np.testing.assert_allclose(result, [3.0, 3.0, 3.0, 4.0])
def test_output_has_no_nans(self):
"""After interpolation, no NaN values should remain."""
arr = np.array([np.nan, 1.0, np.nan, np.nan, 4.0, np.nan])
result = linear_interpolation(arr)
assert not np.any(np.isnan(result))
def test_preserves_non_nan_values(self):
"""Non-NaN values in the original array must never be modified."""
arr = np.array([10.0, np.nan, 30.0, np.nan, 50.0])
original_valid = arr[~np.isnan(arr)].copy()
result = linear_interpolation(arr)
np.testing.assert_array_equal(
result[~np.isnan(np.array([10.0, np.nan, 30.0, np.nan, 50.0]))],
original_valid,
)
def test_interpolation_is_monotone_for_monotone_input(self):
"""If the known values are strictly increasing, the interpolated
result must also be non-decreasing — a basic sanity check on the
interpolation direction."""
arr = np.array([1.0, np.nan, np.nan, 4.0, np.nan, 6.0])
result = linear_interpolation(arr)
diffs = np.diff(result)
assert np.all(diffs >= 0)
def test_single_non_nan_fills_all_gaps(self):
"""With only one valid value, every NaN is replaced by that value
(np.interp clamps to the single known point)."""
arr = np.array([np.nan, 5.0, np.nan])
result = linear_interpolation(arr)
np.testing.assert_allclose(result, [5.0, 5.0, 5.0])
+196
View File
@@ -0,0 +1,196 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for TimesFM configuration dataclasses.
These tests verify that config dataclasses enforce immutability, compose
correctly, and carry the exact default values the model implementation
relies on. Catching a silent default-value drift here prevents subtle
inference regressions that would otherwise only surface as degraded
forecast quality.
"""
import dataclasses
import pytest
from timesfm.configs import (
ForecastConfig,
RandomFourierFeaturesConfig,
ResidualBlockConfig,
StackedTransformersConfig,
TransformerConfig,
)
# ---------------------------------------------------------------------------
# ForecastConfig
# ---------------------------------------------------------------------------
class TestForecastConfig:
"""Tests for ForecastConfig — the primary user-facing configuration."""
def test_defaults_match_safe_inference_settings(self):
"""Default config must be conservative: no normalization, no fancy heads.
These defaults are what users get when they call ``ForecastConfig()``
without arguments. Changing them silently would break all existing
code that relies on the defaults.
"""
cfg = ForecastConfig()
assert cfg.max_context == 0
assert cfg.max_horizon == 0
assert cfg.normalize_inputs is False
assert cfg.per_core_batch_size == 1
assert cfg.use_continuous_quantile_head is False
assert cfg.force_flip_invariance is True
assert cfg.infer_is_positive is True
assert cfg.fix_quantile_crossing is False
assert cfg.return_backcast is False
def test_frozen_prevents_mutation(self):
"""Configs are frozen dataclasses — mutating them must raise.
This is critical because ``compile()`` captures the config object and
the compiled decode closure relies on its values never changing.
"""
cfg = ForecastConfig(max_context=512)
with pytest.raises(dataclasses.FrozenInstanceError):
cfg.max_context = 1024
def test_replace_creates_independent_copy(self):
"""``dataclasses.replace`` must yield a new object with updated fields.
The compile path uses ``replace`` to adjust context/horizon to valid
multiples; the original config must remain untouched.
"""
original = ForecastConfig(max_context=512, max_horizon=128)
replaced = dataclasses.replace(original, max_context=1024)
assert replaced.max_context == 1024
assert replaced.max_horizon == 128 # untouched
assert original.max_context == 512 # original unchanged
def test_equality_is_structural(self):
"""Two configs with identical fields must be equal (value semantics)."""
a = ForecastConfig(max_context=256, normalize_inputs=True)
b = ForecastConfig(max_context=256, normalize_inputs=True)
assert a == b
def test_inequality_on_any_field_difference(self):
"""A single differing field must break equality."""
a = ForecastConfig(max_context=256)
b = ForecastConfig(max_context=512)
assert a != b
# ---------------------------------------------------------------------------
# ResidualBlockConfig
# ---------------------------------------------------------------------------
class TestResidualBlockConfig:
"""Tests for ResidualBlockConfig used by tokenizer and output projections."""
def test_frozen_prevents_mutation(self):
cfg = ResidualBlockConfig(
input_dims=64,
hidden_dims=128,
output_dims=128,
use_bias=True,
activation="swish",
)
with pytest.raises(dataclasses.FrozenInstanceError):
cfg.input_dims = 32
def test_activation_accepts_all_valid_literals(self):
"""All three activation modes must be constructable without error."""
for act in ("relu", "swish", "none"):
cfg = ResidualBlockConfig(
input_dims=8,
hidden_dims=16,
output_dims=8,
use_bias=False,
activation=act,
)
assert cfg.activation == act
# ---------------------------------------------------------------------------
# TransformerConfig & StackedTransformersConfig
# ---------------------------------------------------------------------------
class TestTransformerConfig:
"""Tests for TransformerConfig — architecture-level hyperparameters."""
def test_model_dims_must_be_divisible_by_num_heads(self):
"""The model instantiation will fail if this invariant is broken.
We verify the config at least *carries* the right values that the
TimesFM 2.5 definition uses (1280 dims, 16 heads → 80 head_dim).
"""
cfg = TransformerConfig(
model_dims=1280,
hidden_dims=1280,
num_heads=16,
attention_norm="rms",
feedforward_norm="rms",
qk_norm="rms",
use_bias=False,
use_rotary_position_embeddings=True,
ff_activation="swish",
fuse_qkv=True,
)
assert cfg.model_dims % cfg.num_heads == 0
assert cfg.model_dims // cfg.num_heads == 80 # head_dim
def test_stacked_config_composes_correctly(self):
"""StackedTransformersConfig must wrap a TransformerConfig cleanly."""
xf = TransformerConfig(
model_dims=64,
hidden_dims=64,
num_heads=4,
attention_norm="rms",
feedforward_norm="rms",
qk_norm="none",
use_bias=True,
use_rotary_position_embeddings=False,
ff_activation="relu",
fuse_qkv=False,
)
stacked = StackedTransformersConfig(num_layers=6, transformer=xf)
assert stacked.num_layers == 6
assert stacked.transformer is xf
assert stacked.transformer.model_dims == 64
# ---------------------------------------------------------------------------
# RandomFourierFeaturesConfig
# ---------------------------------------------------------------------------
class TestRandomFourierFeaturesConfig:
"""Tests for RandomFourierFeaturesConfig."""
def test_frozen_prevents_mutation(self):
cfg = RandomFourierFeaturesConfig(
input_dims=32,
output_dims=64,
projection_stddev=1.0,
use_bias=True,
)
with pytest.raises(dataclasses.FrozenInstanceError):
cfg.output_dims = 128
+262
View File
@@ -0,0 +1,262 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for PyTorch layer building blocks: ResidualBlock, RMSNorm,
RandomFourierFeatures.
These layers are the atoms of the TimesFM architecture. Verifying their
output shapes, numerical properties, and failure modes protects against
regressions during refactors. All tests use small dimensions and run
on CPU — no model checkpoint or GPU required.
"""
import torch
import pytest
from timesfm.configs import RandomFourierFeaturesConfig, ResidualBlockConfig
from timesfm.torch.dense import RandomFourierFeatures, ResidualBlock
from timesfm.torch.normalization import RMSNorm
# ---------------------------------------------------------------------------
# ResidualBlock
# ---------------------------------------------------------------------------
class TestResidualBlock:
"""Tests for the residual block: hidden → activation → output + skip."""
@pytest.fixture
def swish_block(self):
"""A small residual block with SiLU/Swish activation (matches TimesFM)."""
cfg = ResidualBlockConfig(
input_dims=16,
hidden_dims=32,
output_dims=8,
use_bias=True,
activation="swish",
)
return ResidualBlock(cfg)
def test_output_shape(self, swish_block):
"""Output must have the config's ``output_dims`` as the last dimension,
regardless of input batch shape."""
x = torch.randn(4, 16)
out = swish_block(x)
assert out.shape == (4, 8)
def test_output_shape_3d(self, swish_block):
"""The block must handle (batch, seq, features) inputs — the layout
used when processing patched time series."""
x = torch.randn(2, 10, 16)
out = swish_block(x)
assert out.shape == (2, 10, 8)
def test_residual_connection_nonzero(self):
"""The residual connection must contribute to the output.
We verify this by comparing the output when the hidden path is
zeroed out vs. the full output.
"""
cfg = ResidualBlockConfig(
input_dims=8,
hidden_dims=16,
output_dims=8,
use_bias=False,
activation="none",
)
block = ResidualBlock(cfg)
x = torch.randn(2, 8)
with torch.no_grad():
# Residual path only: zero out hidden and output layers.
block.hidden_layer.weight.zero_()
block.output_layer.weight.zero_()
residual_only = block(x)
# Must equal the residual layer output.
expected = block.residual_layer(x)
torch.testing.assert_close(residual_only, expected)
@pytest.mark.parametrize("activation", ["relu", "swish", "none"])
def test_all_activations_produce_valid_output(self, activation):
"""All supported activations must produce finite, non-NaN output."""
cfg = ResidualBlockConfig(
input_dims=8,
hidden_dims=16,
output_dims=8,
use_bias=True,
activation=activation,
)
block = ResidualBlock(cfg)
x = torch.randn(4, 8)
out = block(x)
assert not torch.any(torch.isnan(out))
assert not torch.any(torch.isinf(out))
def test_invalid_activation_raises(self):
"""Unsupported activation must raise ``ValueError`` immediately —
fail fast rather than producing garbage at inference time."""
cfg = ResidualBlockConfig(
input_dims=8,
hidden_dims=16,
output_dims=8,
use_bias=True,
activation="gelu",
)
with pytest.raises(ValueError, match="not supported"):
ResidualBlock(cfg)
def test_gradient_flows_through_both_paths(self):
"""Gradients must reach both the main path and the residual path.
Dead gradients on either path would prevent the layer from learning.
"""
cfg = ResidualBlockConfig(
input_dims=8,
hidden_dims=16,
output_dims=8,
use_bias=True,
activation="swish",
)
block = ResidualBlock(cfg)
x = torch.randn(2, 8, requires_grad=True)
out = block(x)
loss = out.sum()
loss.backward()
assert block.hidden_layer.weight.grad is not None
assert block.residual_layer.weight.grad is not None
assert torch.any(block.hidden_layer.weight.grad != 0)
assert torch.any(block.residual_layer.weight.grad != 0)
# ---------------------------------------------------------------------------
# RMSNorm
# ---------------------------------------------------------------------------
class TestRMSNorm:
"""Tests for RMS normalization used in transformer attention/FF blocks."""
def test_output_shape_preserved(self):
"""RMSNorm must not change the tensor shape."""
norm = RMSNorm(num_features=64)
x = torch.randn(2, 10, 64)
out = norm(x)
assert out.shape == x.shape
def test_zero_scale_produces_zeros(self):
"""With default scale (initialized to zeros), output must be all zeros.
This is a critical initialization property: at init, each transformer
layer's post-norm effectively passes through zeros, relying on the
residual connection to carry signal.
"""
norm = RMSNorm(num_features=8)
# scale is initialized to zeros by default.
x = torch.randn(4, 8)
out = norm(x)
torch.testing.assert_close(out, torch.zeros_like(out))
def test_unit_scale_preserves_rms_magnitude(self):
"""With scale = 1, output should have approximately unit RMS along
the feature dimension — that's the point of RMS normalization."""
norm = RMSNorm(num_features=64)
with torch.no_grad():
norm.scale.fill_(1.0)
x = torch.randn(8, 64) * 100 # large magnitude
out = norm(x)
rms = torch.sqrt(torch.mean(out**2, dim=-1))
# After normalization, RMS should be close to 1.0.
torch.testing.assert_close(
rms,
torch.ones(8),
atol=0.1,
rtol=0.1,
)
def test_no_nan_on_zero_input(self):
"""A zero-valued input must not cause NaN (epsilon prevents div-by-0)."""
norm = RMSNorm(num_features=8, epsilon=1e-6)
with torch.no_grad():
norm.scale.fill_(1.0)
x = torch.zeros(2, 8)
out = norm(x)
assert not torch.any(torch.isnan(out))
# ---------------------------------------------------------------------------
# RandomFourierFeatures
# ---------------------------------------------------------------------------
class TestRandomFourierFeatures:
"""Tests for the random Fourier feature layer."""
def test_output_shape(self):
"""Output dims must be exactly ``config.output_dims``."""
cfg = RandomFourierFeaturesConfig(
input_dims=8,
output_dims=32,
projection_stddev=1.0,
use_bias=True,
)
layer = RandomFourierFeatures(cfg)
x = torch.randn(4, 8)
out = layer(x)
assert out.shape == (4, 32)
def test_output_dims_not_multiple_of_4_raises(self):
"""The four Fourier components (cos, sin, sq_wave_1, sq_wave_2)
require ``output_dims`` to be divisible by 4."""
cfg = RandomFourierFeaturesConfig(
input_dims=8,
output_dims=30, # not divisible by 4
projection_stddev=1.0,
use_bias=True,
)
with pytest.raises(ValueError, match="multiple of 4"):
RandomFourierFeatures(cfg)
def test_fourier_components_bounded(self):
"""cos and sin outputs are bounded in [-1, 1]; sign outputs are
bounded in {-1, 0, 1}. The total Fourier part (before residual)
is thus bounded. We verify the output stays finite."""
cfg = RandomFourierFeaturesConfig(
input_dims=8,
output_dims=32,
projection_stddev=1.0,
use_bias=False,
)
layer = RandomFourierFeatures(cfg)
x = torch.randn(16, 8) * 10 # moderately large input
out = layer(x)
assert not torch.any(torch.isnan(out))
assert not torch.any(torch.isinf(out))
def test_3d_input_supported(self):
"""The layer must handle (batch, seq, features) tensors."""
cfg = RandomFourierFeaturesConfig(
input_dims=8,
output_dims=16,
projection_stddev=1.0,
use_bias=True,
)
layer = RandomFourierFeatures(cfg)
x = torch.randn(2, 5, 8)
out = layer(x)
assert out.shape == (2, 5, 16)
+336
View File
@@ -0,0 +1,336 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for PyTorch utility functions: running statistics and RevIN.
These utilities are invoked at every patch boundary during autoregressive
decoding. Bugs here cause silent numerical drift that compounds over
long horizons, making them especially hard to diagnose from forecast
output alone.
"""
import torch
import numpy as np
import pytest
from timesfm.torch.util import (
DecodeCache,
_TOLERANCE,
revin,
update_running_stats,
)
# ---------------------------------------------------------------------------
# update_running_stats
# ---------------------------------------------------------------------------
class TestUpdateRunningStats:
"""Tests for Welford-style online mean / variance accumulation."""
def test_single_batch_matches_numpy(self):
"""A single update with no mask must match numpy's mean and std.
This is the most basic correctness check: feed all values at once
and compare against the ground-truth statistics.
"""
x = torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0]])
mask = torch.zeros_like(x, dtype=torch.bool)
n0 = torch.zeros(1)
mu0 = torch.zeros(1)
sigma0 = torch.zeros(1)
(new_n, new_mu, new_sigma), _ = update_running_stats(n0, mu0, sigma0, x, mask)
np_values = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
expected_mu = np.mean(np_values)
# Population std (ddof=0), same as PyTorch default.
expected_sigma = np.std(np_values, ddof=0)
assert new_n.item() == pytest.approx(5.0)
assert new_mu.item() == pytest.approx(expected_mu, abs=1e-5)
assert new_sigma.item() == pytest.approx(expected_sigma, abs=1e-5)
def test_incremental_accumulation_matches_full_computation(self):
"""Accumulating two batches incrementally must yield the same result
as computing statistics over all values at once.
This is the defining property of online/streaming statistics.
"""
all_values = torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]])
batch1 = torch.tensor([[1.0, 2.0, 3.0]])
batch2 = torch.tensor([[4.0, 5.0, 6.0]])
def no_mask(t):
return torch.zeros_like(t, dtype=torch.bool)
# Full computation.
n0 = torch.zeros(1)
mu0 = torch.zeros(1)
sigma0 = torch.zeros(1)
(full_n, full_mu, full_sigma), _ = update_running_stats(
n0, mu0, sigma0, all_values, no_mask(all_values)
)
# Incremental computation.
(n1, mu1, sigma1), _ = update_running_stats(
n0, mu0, sigma0, batch1, no_mask(batch1)
)
(inc_n, inc_mu, inc_sigma), _ = update_running_stats(
n1, mu1, sigma1, batch2, no_mask(batch2)
)
assert inc_n.item() == pytest.approx(full_n.item())
assert inc_mu.item() == pytest.approx(full_mu.item(), abs=1e-5)
assert inc_sigma.item() == pytest.approx(full_sigma.item(), abs=1e-5)
def test_masked_elements_excluded_from_statistics(self):
"""Masked positions must be completely ignored — as if they don't exist.
In TimesFM, leading padding is masked. If mask handling is broken,
the zero-padding values pollute the running mean and variance.
"""
# Two values: 10 and 20 are valid; 0 is masked.
x = torch.tensor([[0.0, 10.0, 20.0]])
mask = torch.tensor([[True, False, False]])
n0 = torch.zeros(1)
mu0 = torch.zeros(1)
sigma0 = torch.zeros(1)
(new_n, new_mu, new_sigma), _ = update_running_stats(n0, mu0, sigma0, x, mask)
assert new_n.item() == pytest.approx(2.0)
assert new_mu.item() == pytest.approx(15.0, abs=1e-5)
expected_sigma = np.std([10.0, 20.0], ddof=0)
assert new_sigma.item() == pytest.approx(expected_sigma, abs=1e-5)
def test_all_masked_yields_zero_stats(self):
"""When every element is masked, the function must return zeros
rather than NaN or raise an error.
This happens when an input series is entirely padding.
"""
x = torch.tensor([[99.0, 99.0, 99.0]])
mask = torch.ones_like(x, dtype=torch.bool)
n0 = torch.zeros(1)
mu0 = torch.zeros(1)
sigma0 = torch.zeros(1)
(new_n, new_mu, new_sigma), _ = update_running_stats(n0, mu0, sigma0, x, mask)
assert new_n.item() == 0.0
assert new_mu.item() == 0.0
assert new_sigma.item() == 0.0
def test_batched_computation_independent(self):
"""Each sample in the batch must be computed independently.
Cross-sample leakage would corrupt multi-series forecasting.
"""
x = torch.tensor(
[
[1.0, 2.0, 3.0],
[100.0, 200.0, 300.0],
]
)
mask = torch.zeros_like(x, dtype=torch.bool)
n0 = torch.zeros(2)
mu0 = torch.zeros(2)
sigma0 = torch.zeros(2)
(new_n, new_mu, new_sigma), _ = update_running_stats(n0, mu0, sigma0, x, mask)
assert new_mu[0].item() == pytest.approx(2.0, abs=1e-5)
assert new_mu[1].item() == pytest.approx(200.0, abs=1e-5)
expected_sigma_0 = np.std([1.0, 2.0, 3.0], ddof=0)
expected_sigma_1 = np.std([100.0, 200.0, 300.0], ddof=0)
assert new_sigma[0].item() == pytest.approx(expected_sigma_0, abs=1e-5)
assert new_sigma[1].item() == pytest.approx(expected_sigma_1, abs=1e-5)
def test_constant_input_yields_zero_sigma(self):
"""A constant series has zero variance — sigma must be exactly 0.
This is important because ``revin`` guards against division-by-zero
using ``_TOLERANCE`` when sigma is near zero.
"""
x = torch.tensor([[7.0, 7.0, 7.0, 7.0]])
mask = torch.zeros_like(x, dtype=torch.bool)
n0 = torch.zeros(1)
mu0 = torch.zeros(1)
sigma0 = torch.zeros(1)
(_, new_mu, new_sigma), _ = update_running_stats(n0, mu0, sigma0, x, mask)
assert new_mu.item() == pytest.approx(7.0)
assert new_sigma.item() == pytest.approx(0.0)
# ---------------------------------------------------------------------------
# revin (Reversible Instance Normalization)
# ---------------------------------------------------------------------------
class TestRevIN:
"""Tests for the RevIN normalization used in patched decoding."""
def test_forward_then_reverse_is_identity(self):
"""normalize → denormalize must reconstruct the original tensor.
This is the fundamental invariant of reversible normalization: the
model operates in normalized space, and the output is mapped back
to the original scale. Any deviation here directly corrupts the
final forecast values.
"""
x = torch.tensor([[10.0, 20.0, 30.0]])
mu = torch.tensor([20.0])
sigma = torch.tensor([10.0])
normed = revin(x, mu, sigma, reverse=False)
recovered = revin(normed, mu, sigma, reverse=True)
torch.testing.assert_close(recovered, x, atol=1e-5, rtol=1e-5)
def test_forward_produces_correct_normalization(self):
"""After forward normalization: (x - mu) / sigma."""
x = torch.tensor([[10.0, 20.0, 30.0]])
mu = torch.tensor([20.0])
sigma = torch.tensor([10.0])
normed = revin(x, mu, sigma, reverse=False)
expected = torch.tensor([[-1.0, 0.0, 1.0]])
torch.testing.assert_close(normed, expected, atol=1e-5, rtol=1e-5)
def test_reverse_produces_correct_denormalization(self):
"""After reverse: x * sigma + mu."""
normed = torch.tensor([[-1.0, 0.0, 1.0]])
mu = torch.tensor([20.0])
sigma = torch.tensor([10.0])
recovered = revin(normed, mu, sigma, reverse=True)
expected = torch.tensor([[10.0, 20.0, 30.0]])
torch.testing.assert_close(recovered, expected, atol=1e-5, rtol=1e-5)
def test_zero_sigma_does_not_produce_nan(self):
"""When sigma < tolerance, the function substitutes 1.0 to avoid
division by zero. This occurs for constant-valued input series.
NaN propagation from here would poison the entire transformer
forward pass.
"""
x = torch.tensor([[5.0, 5.0, 5.0]])
mu = torch.tensor([5.0])
sigma = torch.tensor([0.0]) # zero variance
normed = revin(x, mu, sigma, reverse=False)
assert not torch.any(torch.isnan(normed))
assert not torch.any(torch.isinf(normed))
def test_near_zero_sigma_guarded_by_tolerance(self):
"""Sigma values just below ``_TOLERANCE`` must trigger the guard."""
x = torch.tensor([[1.0, 2.0, 3.0]])
mu = torch.tensor([2.0])
sigma = torch.tensor([_TOLERANCE / 2]) # below threshold
normed = revin(x, mu, sigma, reverse=False)
assert not torch.any(torch.isnan(normed))
# With sigma replaced by 1.0: result = x - mu
expected = torch.tensor([[-1.0, 0.0, 1.0]])
torch.testing.assert_close(normed, expected, atol=1e-5, rtol=1e-5)
def test_roundtrip_with_batched_3d_input(self):
"""RevIN must broadcast correctly for (batch, patches, patch_len)
tensors — the actual shape used during patched decoding."""
batch, patches, patch_len = 2, 4, 32
x = torch.randn(batch, patches, patch_len)
mu = torch.tensor([1.0, 2.0]) # (batch,)
sigma = torch.tensor([3.0, 4.0]) # (batch,)
normed = revin(x, mu, sigma, reverse=False)
recovered = revin(normed, mu, sigma, reverse=True)
torch.testing.assert_close(recovered, x, atol=1e-5, rtol=1e-5)
def test_roundtrip_with_batched_4d_input(self):
"""RevIN must broadcast correctly for (batch, patches, patch_len, q)
tensors — the shape used for quantile outputs.
In the actual decode path, mu/sigma have shape (batch, patches) for
4D tensors, so the ``len(mu.shape) == len(x.shape) - 2`` branch
fires and adds two trailing singleton dimensions.
"""
batch, patches, patch_len, q = 2, 4, 32, 10
x = torch.randn(batch, patches, patch_len, q)
# Match the actual call-site shape: (batch, patches)
mu = torch.randn(batch, patches)
sigma = torch.abs(torch.randn(batch, patches)) + 1.0 # ensure positive
normed = revin(x, mu, sigma, reverse=False)
recovered = revin(normed, mu, sigma, reverse=True)
torch.testing.assert_close(recovered, x, atol=1e-4, rtol=1e-4)
def test_negative_values_handled_correctly(self):
"""RevIN must work for series with negative values (e.g. temperature,
financial returns). ``infer_is_positive`` is a separate downstream
flag and does not affect RevIN itself.
"""
x = torch.tensor([[-10.0, -5.0, 0.0, 5.0, 10.0]])
mu = torch.tensor([0.0])
sigma = torch.tensor([7.07])
normed = revin(x, mu, sigma, reverse=False)
recovered = revin(normed, mu, sigma, reverse=True)
torch.testing.assert_close(recovered, x, atol=1e-3, rtol=1e-3)
# ---------------------------------------------------------------------------
# DecodeCache
# ---------------------------------------------------------------------------
class TestDecodeCache:
"""Tests for the DecodeCache dataclass used in KV-cache decoding."""
def test_is_mutable(self):
"""DecodeCache is *not* frozen — the attention loop mutates
``next_index`` and ``num_masked`` in-place during autoregressive
decoding."""
cache = DecodeCache(
next_index=torch.tensor([0]),
num_masked=torch.tensor([0]),
key=torch.zeros(1, 10, 4, 8),
value=torch.zeros(1, 10, 4, 8),
)
cache.next_index = torch.tensor([5])
assert cache.next_index.item() == 5
def test_key_value_shape_consistency(self):
"""Key and value tensors must have identical shapes — they are
indexed in parallel during attention computation."""
batch, seq, heads, head_dim = 2, 64, 16, 80
cache = DecodeCache(
next_index=torch.zeros(batch, dtype=torch.int32),
num_masked=torch.zeros(batch, dtype=torch.int32),
key=torch.zeros(batch, seq, heads, head_dim),
value=torch.zeros(batch, seq, heads, head_dim),
)
assert cache.key.shape == cache.value.shape
assert cache.key.shape == (batch, seq, heads, head_dim)