Files
2026-07-13 12:18:07 +08:00

337 lines
12 KiB
Python

# 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)