trichronos-eval / data_pipeline.py
iravikr's picture
TriChronos-0.1B setup
40450cd verified
Raw
History Blame Contribute Delete
13.3 kB
"""
data_pipeline.py — TriChronos-0.1B
Bronze → Silver → Gold streaming pipeline from Salesforce/lotsa_data.
Never materialises the full dataset to disk — everything is streamed and
processed on-the-fly.
Stages
------
Bronze raw stream from the Hugging Face Hub
Silver NaN-safe running z-score normalisation + asinh transform
Gold non-overlapping 8-timestep patches → (patches, targets) tensors
"""
from __future__ import annotations
import math
import os
from typing import Iterator, List, Optional, Tuple
import numpy as np
import torch
from torch.utils.data import IterableDataset
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
PATCH_SIZE: int = 8 # timesteps per patch
FORECAST_HORIZON: int = 24 # timesteps to predict
MIN_SERIES_LEN: int = PATCH_SIZE * 4 + FORECAST_HORIZON # minimum usable length
# All LOTSA subsets to stream (use None to stream all)
LOTSA_SUBSETS: Optional[List[str]] = None # None → HF will enumerate all
# Asinh stabilises heavy-tailed distributions and handles near-zero series.
ASINH_SCALE: float = 1.0
# ---------------------------------------------------------------------------
# Bronze — raw streaming from HF
# ---------------------------------------------------------------------------
LOTSA_REPO: str = "Salesforce/lotsa_data"
def list_lotsa_subsets() -> List[str]:
"""
Enumerate every config (subset) name in Salesforce/lotsa_data.
Streaming the repo with no ``name`` globs arrow files across *all*
subsets under one implicit config, forcing HF to infer a single schema
for ``target``. Because univariate subsets store ``target`` as
``list<float>`` and multivariate ones as ``list<list<float>>``, the cast
fails. Streaming each config by name keeps every arrow file in the stream
on a homogeneous schema, so no cross-schema cast is attempted.
"""
from datasets import get_dataset_config_names
names = get_dataset_config_names(LOTSA_REPO)
# "default" (if present) is the union config that triggers the schema
# clash — drop it and stream the real subsets instead.
return [n for n in names if n != "default"]
def _iter_channels(values) -> Iterator[np.ndarray]:
"""
Normalise a raw ``target`` into one or more 1-D float32 series.
LOTSA ``target`` is either univariate (``list<float>``) or multivariate
(``list<list<float>>`` — one inner list per channel). Multivariate series
are split into independent univariate channels so the univariate patching
downstream stays valid.
"""
if values is None:
return
arr = np.asarray(values, dtype=np.float32)
if arr.ndim == 1:
yield arr
elif arr.ndim == 2:
for channel in arr: # (n_channels, T) → T-length series each
yield np.ascontiguousarray(channel, dtype=np.float32)
# anything else (ragged / empty) is silently skipped
def _bronze_stream(subset: Optional[str] = None, split: str = "train"):
"""
Yield raw examples from Salesforce/lotsa_data for a specific named subset config.
Each example has at least:
"target" : List[float] or List[List[float]] — univariate or multivariate time series values
"start" : str — ISO-format start timestamp
"""
from datasets import load_dataset # lazy import
ds_kwargs: dict = dict(
path="Salesforce/lotsa_data",
split=split,
streaming=True,
)
if subset is not None:
ds_kwargs["name"] = subset
try:
dataset = load_dataset(**ds_kwargs)
except Exception as exc:
print(f"[data_pipeline] Skipping subset '{subset}': {exc}", flush=True)
return
for example in dataset:
values = example.get("target", None)
if values is None:
continue
val_arr = np.asarray(values, dtype=np.float32)
# Handle 2D multivariate series (channels, timesteps)
if val_arr.ndim == 2:
for c in range(val_arr.shape[0]):
chan = val_arr[c]
if len(chan) >= MIN_SERIES_LEN:
yield {
"values": chan,
"subset": subset or "unknown",
"start": example.get("start", ""),
}
elif val_arr.ndim == 1 and len(val_arr) >= MIN_SERIES_LEN:
yield {
"values": val_arr,
"subset": subset or "unknown",
"start": example.get("start", ""),
}
# ---------------------------------------------------------------------------
# Silver — normalisation
# ---------------------------------------------------------------------------
def _silver_normalize(values: np.ndarray) -> Tuple[np.ndarray, float, float]:
"""
NaN-safe running z-score + asinh transform.
Returns
-------
normed : normalised array (same length as input)
mean_ : sample mean used for normalisation
std_ : sample std used for normalisation (clipped to ≥ 1e-6)
"""
# Replace NaN/Inf with 0 before computing stats
clean = np.where(np.isfinite(values), values, 0.0)
mean_ = float(np.mean(clean))
std_ = float(np.std(clean))
std_ = max(std_, 1e-6)
z = (clean - mean_) / std_
normed = np.arcsinh(ASINH_SCALE * z).astype(np.float32)
return normed, mean_, std_
# ---------------------------------------------------------------------------
# Gold — patching
# ---------------------------------------------------------------------------
def _gold_patch(
normed: np.ndarray,
patch_size: int = PATCH_SIZE,
horizon: int = FORECAST_HORIZON,
) -> Iterator[Tuple[np.ndarray, np.ndarray]]:
"""
Slide over the normalised series and yield (context_patches, target) pairs.
context_patches : (n_patches, patch_size) — past context
target : (horizon,) — future values to forecast
"""
n = len(normed)
# Maximum number of non-overlapping patches we can form from the context
# (leave `horizon` steps at the end for the target)
max_start = n - horizon
if max_start < patch_size:
return
# Use all valid (start, end) positions with a stride of patch_size
for end_of_context in range(patch_size, max_start + 1, patch_size):
context = normed[:end_of_context]
target = normed[end_of_context: end_of_context + horizon]
if len(target) < horizon:
break
# Chop context into non-overlapping patches of exactly patch_size
# Discard any incomplete leading patch
n_full_patches = len(context) // patch_size
if n_full_patches == 0:
continue
context_aligned = context[-n_full_patches * patch_size:]
patches = context_aligned.reshape(n_full_patches, patch_size)
yield patches, target
# ---------------------------------------------------------------------------
# IterableDataset (Gold)
# ---------------------------------------------------------------------------
class LOTSAStreamDataset(IterableDataset):
"""
PyTorch IterableDataset wrapping the full Bronze->Silver->Gold pipeline.
Yields dicts:
"patches" : FloatTensor (n_patches, patch_size)
"target" : FloatTensor (horizon,)
"subset" : str
Parameters
----------
subsets : list of LOTSA subset names, or None to dynamically discover all
split : HF dataset split ("train")
patch_size : timesteps per patch
horizon : forecast horizon
max_patches : cap on n_patches (pad/truncate); None = variable length
"""
def __init__(
self,
subsets: Optional[List[str]] = None,
split: str = "train",
patch_size: int = PATCH_SIZE,
horizon: int = FORECAST_HORIZON,
max_patches: Optional[int] = 64,
):
super().__init__()
if subsets is None:
from datasets import get_dataset_config_names
try:
all_configs = get_dataset_config_names("Salesforce/lotsa_data")
subsets = [c for c in all_configs if c != "default"]
except Exception:
subsets = [
"m4_monthly", "traffic_hourly", "electricity_hourly",
"solar_power", "weather", "m4_daily", "m4_hourly",
"favorita_sales", "kdd2022", "wind_power"
]
self.subsets = subsets
self.split = split
self.patch_size = patch_size
self.horizon = horizon
self.max_patches = max_patches
# ------------------------------------------------------------------
def _iter_subset(self, subset: Optional[str]) -> Iterator[dict]:
for bronze in _bronze_stream(subset, self.split):
normed, _, _ = _silver_normalize(bronze["values"])
for patches, target in _gold_patch(normed, self.patch_size, self.horizon):
patches_t = torch.from_numpy(patches) # (n_patches, patch_size)
target_t = torch.from_numpy(target) # (horizon,)
# Pad / truncate patches to max_patches
if self.max_patches is not None:
n = patches_t.shape[0]
if n > self.max_patches:
patches_t = patches_t[-self.max_patches:] # keep most-recent
elif n < self.max_patches:
pad = torch.zeros(
self.max_patches - n, self.patch_size,
dtype=patches_t.dtype,
)
patches_t = torch.cat([pad, patches_t], dim=0)
yield {
"patches": patches_t, # (max_patches, patch_size)
"target": target_t, # (horizon,)
"subset": bronze["subset"],
}
# ------------------------------------------------------------------
def __iter__(self) -> Iterator[dict]:
worker_info = torch.utils.data.get_worker_info()
subsets = self.subsets
if worker_info is not None:
# Distribute subsets across DataLoader workers
subsets = [
s for i, s in enumerate(subsets)
if i % worker_info.num_workers == worker_info.id
]
for subset in subsets:
yield from self._iter_subset(subset)
# ---------------------------------------------------------------------------
# Collate function
# ---------------------------------------------------------------------------
def collate_fn(batch: List[dict]) -> dict:
"""
Collate a list of Gold samples into batched tensors.
Groups samples by subset so that cross-series (group) attention
in the model operates on related series from the same dataset —
a prerequisite for the group attention mechanism to capture real
multivariate correlations.
Returns
-------
patches : FloatTensor (B, max_patches, patch_size)
targets : FloatTensor (B, horizon)
subsets : List[str]
"""
# Sort by subset name so related series are adjacent in the batch
batch = sorted(batch, key=lambda x: x["subset"])
patches = torch.stack([b["patches"] for b in batch], dim=0)
targets = torch.stack([b["target"] for b in batch], dim=0)
subsets = [b["subset"] for b in batch]
return {
"patches": patches, # (B, n_patches, patch_size)
"targets": targets, # (B, horizon)
"subsets": subsets,
}
# ---------------------------------------------------------------------------
# Quick smoke test (python data_pipeline.py)
# ---------------------------------------------------------------------------
if __name__ == "__main__":
print("Running data_pipeline smoke test with synthetic data …")
# Build synthetic series to avoid requiring HF credentials
rng = np.random.default_rng(0)
series_len = 512
values = rng.standard_normal(series_len).astype(np.float32) * 10 + 5
# Silver
normed, mean_, std_ = _silver_normalize(values)
print(f"Silver: mean={mean_:.3f}, std={std_:.3f}, shape={normed.shape}")
# Gold
examples = list(_gold_patch(normed))
print(f"Gold: {len(examples)} (patches, target) pairs from a single series")
patches0, target0 = examples[0]
print(f" patches shape: {patches0.shape}, target shape: {target0.shape}")
# Collate
MAX_P = 64
fake_batch = []
for p, t in examples[:4]:
pt = torch.from_numpy(p) # (n, patch_size) — variable n
# Pad/truncate to MAX_P (same logic as LOTSAStreamDataset)
n = pt.shape[0]
if n > MAX_P:
pt = pt[-MAX_P:]
elif n < MAX_P:
pad = torch.zeros(MAX_P - n, pt.shape[1], dtype=pt.dtype)
pt = torch.cat([pad, pt], dim=0)
fake_batch.append({"patches": pt, "target": torch.from_numpy(t), "subset": "test"})
batch = collate_fn(fake_batch)
print(f"Collated patches: {batch['patches'].shape}, targets: {batch['targets'].shape}")
print("data_pipeline.py - all checks passed OK")