timestral-mlx / scripts /validate.py
jbilcke's picture
Publish all four Timestral MLX FP32 models, pipeline, original licences and validation
d35e174 verified
Raw History Blame Contribute Delete
5.08 kB
"""Compare converted checkpoints against pinned Mistral CPU FP32 inference."""
import argparse
import gc
import importlib.metadata
import json
import platform
import sys
import subprocess
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "upstream" / "src"))
import numpy as np
import torch
import mlx.core as mx
from timestral import TimestralForecaster as SourceForecaster
from timestral_mlx import TimestralForecaster
from timestral_mlx.loading import sha256
VARIANTS = ("5M", "28M", "123M", "209M")
def fixtures():
rng = np.random.default_rng(20261020)
def signal(b, v, t):
x = (3*np.sin(np.arange(t)*2*np.pi/24) + rng.normal(0,.2,(b,v,t)).cumsum(-1) + 10).astype(np.float32)
return x
x = signal(1, 1, 257)
x[..., 10:16] = np.nan
yield "univariate_missing_96", x, 96, {}
x = signal(2, 2, 129); x[0,0,:40] = np.nan; x[1,1,60:70] = np.nan
yield "multivariate_batched_65", x, 65, {}
x = signal(1, 1, 193)
past,known = signal(1,2,193),signal(1,2,193+97)
past[0,0,20:28] = np.nan
known[:,1] = np.arange(193+97)%2
known[0,0,210:215] = np.nan
yield "numeric_covariates_97", x, 97, dict(past_covariates=past,known_covariates=known)
codes = (np.arange(193+97)%4 + 2**26).astype(np.float64)[None,None]
codes[...,5:9] = np.nan
yield "categorical_covariates_97", x, 97, dict(categorical_past_covariates=codes[...,:193],categorical_known_covariates=codes)
x = signal(1,1,257)
yield "autoregressive_1057", x, 1057, dict(symmetric_averaging=False)
yield "constant_65", np.full(65, 5, np.float32), 65, {}
yield "all_missing_65", np.full(65, np.nan, np.float32), 65, {}
def validate(size):
print(f"Validating {size} against upstream...", flush=True)
src = SourceForecaster.from_pretrained(ROOT / "models" / "source" / size)
dst = TimestralForecaster.from_pretrained(ROOT / "models" / size)
records=[]
for name,x,h,kw in fixtures():
start=time.perf_counter(); expected=src.forecast(x,h,**kw).quantiles.numpy(); source_s=time.perf_counter()-start
start=time.perf_counter(); actual=dst.forecast(x,h,**kw).quantiles; mlx_s=time.perf_counter()-start
delta=actual.astype(np.float64)-expected
rms=float(np.sqrt(np.mean(expected.astype(np.float64)**2)))
err=float(np.sqrt(np.mean(delta**2)))
normalized_rmse=err/max(rms,1e-6)
normalized_max=float(np.max(np.abs(delta)))/max(rms,1e-6)
# Fixed, declared acceptance criteria; not adjusted per model.
passed=bool(np.isfinite(actual).all() and (np.diff(actual,axis=-1)>=0).all()
and normalized_rmse <= 1e-4 and normalized_max <= 2e-3)
record=dict(fixture=name,shape=list(actual.shape),source_seconds=source_s,mlx_seconds=mlx_s,
max_absolute_error=float(np.max(np.abs(delta))),rmse=err,reference_rms=rms,
normalized_rmse=normalized_rmse,normalized_max_error=normalized_max,
finite=bool(np.isfinite(actual).all()),monotone=bool((np.diff(actual,axis=-1)>=0).all()),passed=passed)
records.append(record)
print(f" {name}: normalized RMSE={normalized_rmse:.3g}, max error={record['max_absolute_error']:.3g}, pass={passed}",flush=True)
report=dict(variant=size,passed=all(r['passed'] for r in records),
acceptance=dict(normalized_rmse_max=1e-4,normalized_max_error_max=2e-3),
bundle_manifest_sha256=sha256(ROOT/'models'/size/'manifest.json'),
runtime_sha256={p.name:sha256(p) for p in sorted((ROOT/'timestral_mlx').glob('*.py')) if not p.name.startswith('._')},
environment=dict(python=platform.python_version(),platform=platform.platform(),
torch=torch.__version__,mlx=importlib.metadata.version('mlx'),numpy=np.__version__,
source_device='cpu',mlx_device=str(mx.default_device()),torch_threads=torch.get_num_threads()),
notes='Deterministic synthetic parity fixtures; timings include first-call overhead and are not a controlled benchmark. No GIFT-Eval/TIME/fev-bench evaluation was run.',fixtures=records)
(ROOT/'reports'/f'{size}.json').write_text(json.dumps(report,indent=2)+'\n')
del src,dst; gc.collect(); mx.clear_cache()
if not report['passed']:
raise RuntimeError(f'{size} did not pass numerical validation')
if __name__=='__main__':
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('variants',nargs='*',choices=VARIANTS,default=list(VARIANTS))
revision=subprocess.check_output(['git','-C',str(ROOT/'upstream'),'rev-parse','HEAD'],text=True).strip()
if revision!='a534bdbd244d64f3d9d743f46c7bf6fc330ed4ff':
raise ValueError('upstream source revision mismatch')
subprocess.run(['git','-C',str(ROOT/'upstream'),'diff','--exit-code','HEAD','--','src'],check=True)
torch.set_num_threads(1)
for size in p.parse_args().variants:
validate(size)