Spaces:
Running on Zero
Running on Zero
CI deploy 0e23c622
Browse files- src/adapters.py +62 -2
- src/config.py +8 -0
- tests/test_adapters.py +70 -0
src/adapters.py
CHANGED
|
@@ -192,13 +192,26 @@ class ChronosAdapter(ForecastAdapter):
|
|
| 192 |
BOLT_CHUNK = 256
|
| 193 |
T5_CHUNK = 16
|
| 194 |
T5_NUM_SAMPLES = 20
|
|
|
|
| 195 |
|
| 196 |
@property
|
| 197 |
def _is_bolt(self) -> bool:
|
| 198 |
return "bolt" in (self.model_id or "").lower()
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
@property
|
| 201 |
def chunk_size(self) -> int:
|
|
|
|
|
|
|
| 202 |
return self.BOLT_CHUNK if self._is_bolt else self.T5_CHUNK
|
| 203 |
|
| 204 |
def predict(self, context_windows: np.ndarray) -> Forecast:
|
|
@@ -211,7 +224,12 @@ class ChronosAdapter(ForecastAdapter):
|
|
| 211 |
ctx = ctx[None, :]
|
| 212 |
|
| 213 |
q_levels = list(DEFAULT_QUANTILES)
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
step = max(1, self.chunk_size)
|
| 216 |
parts = []
|
| 217 |
|
|
@@ -222,7 +240,7 @@ class ChronosAdapter(ForecastAdapter):
|
|
| 222 |
quantiles, _mean = self._model.predict_quantiles(
|
| 223 |
tensors, prediction_length=1, quantile_levels=q_levels, **extra,
|
| 224 |
)
|
| 225 |
-
parts.append(
|
| 226 |
del quantiles, tensors
|
| 227 |
_release(self.device)
|
| 228 |
|
|
@@ -230,6 +248,48 @@ class ChronosAdapter(ForecastAdapter):
|
|
| 230 |
return Forecast(q10=arr[:, 0], q50=arr[:, 1], q90=arr[:, 2],
|
| 231 |
context_len=ctx.shape[1])
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
# --------------------------------------------------------------------------
|
| 235 |
# TimesFM
|
|
|
|
| 192 |
BOLT_CHUNK = 256
|
| 193 |
T5_CHUNK = 16
|
| 194 |
T5_NUM_SAMPLES = 20
|
| 195 |
+
CHRONOS2_CHUNK = 128
|
| 196 |
|
| 197 |
@property
|
| 198 |
def _is_bolt(self) -> bool:
|
| 199 |
return "bolt" in (self.model_id or "").lower()
|
| 200 |
|
| 201 |
+
@property
|
| 202 |
+
def _is_chronos2(self) -> bool:
|
| 203 |
+
"""Chronos-2 (`amazon/chronos-2`).
|
| 204 |
+
|
| 205 |
+
Detected from the id rather than from the loaded pipeline class so the
|
| 206 |
+
chunk size is known before the weights are fetched.
|
| 207 |
+
"""
|
| 208 |
+
name = (self.model_id or "").lower()
|
| 209 |
+
return "chronos-2" in name or "chronos2" in name
|
| 210 |
+
|
| 211 |
@property
|
| 212 |
def chunk_size(self) -> int:
|
| 213 |
+
if self._is_chronos2:
|
| 214 |
+
return self.CHRONOS2_CHUNK
|
| 215 |
return self.BOLT_CHUNK if self._is_bolt else self.T5_CHUNK
|
| 216 |
|
| 217 |
def predict(self, context_windows: np.ndarray) -> Forecast:
|
|
|
|
| 224 |
ctx = ctx[None, :]
|
| 225 |
|
| 226 |
q_levels = list(DEFAULT_QUANTILES)
|
| 227 |
+
# Only the original T5 Chronos samples paths; Bolt and Chronos-2 emit
|
| 228 |
+
# quantiles directly.
|
| 229 |
+
if self._is_bolt or self._is_chronos2:
|
| 230 |
+
extra = {}
|
| 231 |
+
else:
|
| 232 |
+
extra = {"num_samples": self.T5_NUM_SAMPLES}
|
| 233 |
step = max(1, self.chunk_size)
|
| 234 |
parts = []
|
| 235 |
|
|
|
|
| 240 |
quantiles, _mean = self._model.predict_quantiles(
|
| 241 |
tensors, prediction_length=1, quantile_levels=q_levels, **extra,
|
| 242 |
)
|
| 243 |
+
parts.append(self._to_array(quantiles))
|
| 244 |
del quantiles, tensors
|
| 245 |
_release(self.device)
|
| 246 |
|
|
|
|
| 248 |
return Forecast(q10=arr[:, 0], q50=arr[:, 1], q90=arr[:, 2],
|
| 249 |
context_len=ctx.shape[1])
|
| 250 |
|
| 251 |
+
@staticmethod
|
| 252 |
+
def _to_array(quantiles) -> np.ndarray:
|
| 253 |
+
"""Normalise a `predict_quantiles` result to `(batch, n_quantiles)`.
|
| 254 |
+
|
| 255 |
+
The two shapes differ and the difference is not cosmetic:
|
| 256 |
+
|
| 257 |
+
Bolt / T5 one stacked tensor, `(batch, horizon, quantiles)`
|
| 258 |
+
Chronos-2 a *list* of per-item tensors, each
|
| 259 |
+
`(n_variates, horizon, quantiles)`
|
| 260 |
+
|
| 261 |
+
Calling `.float()` on the list raises `AttributeError`, which is
|
| 262 |
+
exactly what `amazon/chronos-2` did before this existed. Horizon is
|
| 263 |
+
always 1 here, and these are univariate price series, so the leading
|
| 264 |
+
variate axis is taken at index 0.
|
| 265 |
+
"""
|
| 266 |
+
if isinstance(quantiles, (list, tuple)):
|
| 267 |
+
rows = []
|
| 268 |
+
for item in quantiles:
|
| 269 |
+
array = ChronosAdapter._as_numpy(item)
|
| 270 |
+
# (n_variates, horizon, quantiles) -> (quantiles,)
|
| 271 |
+
while array.ndim > 1:
|
| 272 |
+
array = array[0]
|
| 273 |
+
rows.append(array)
|
| 274 |
+
return np.vstack(rows)
|
| 275 |
+
|
| 276 |
+
return ChronosAdapter._as_numpy(quantiles)[:, 0, :]
|
| 277 |
+
|
| 278 |
+
@staticmethod
|
| 279 |
+
def _as_numpy(value) -> np.ndarray:
|
| 280 |
+
"""Tensor -> ndarray, by duck typing rather than an isinstance check.
|
| 281 |
+
|
| 282 |
+
Deliberately does not import torch: the test suite runs offline with no
|
| 283 |
+
GPU stack installed, and a shape-normalising helper that cannot be
|
| 284 |
+
tested without a 2GB dependency would not get tested.
|
| 285 |
+
"""
|
| 286 |
+
if hasattr(value, "float") and hasattr(value, "cpu"):
|
| 287 |
+
return np.asarray(value.float().cpu().numpy())
|
| 288 |
+
if isinstance(value, np.ndarray):
|
| 289 |
+
return value
|
| 290 |
+
raise AdapterError(
|
| 291 |
+
f"unexpected predict_quantiles result: {type(value).__name__}")
|
| 292 |
+
|
| 293 |
|
| 294 |
# --------------------------------------------------------------------------
|
| 295 |
# TimesFM
|
src/config.py
CHANGED
|
@@ -172,6 +172,14 @@ SEED_MODELS: dict[str, ModelSpec] = {
|
|
| 172 |
ModelSpec("chronos-t5-small", "amazon/chronos-t5-small", "chronos",
|
| 173 |
"Chronos T5 Small", context_len=512),
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
# Naive baselines, deliberately first-class. A forecasting model that
|
| 176 |
# cannot beat "tomorrow looks like today" is not worth deploying, and
|
| 177 |
# the leaderboard should make that impossible to miss.
|
|
|
|
| 172 |
ModelSpec("chronos-t5-small", "amazon/chronos-t5-small", "chronos",
|
| 173 |
"Chronos T5 Small", context_len=512),
|
| 174 |
|
| 175 |
+
# Chronos-2. Loads through the same adapter and the already-pinned
|
| 176 |
+
# chronos-forecasting 2.3.1, but `predict_quantiles` returns a list of
|
| 177 |
+
# per-item tensors rather than one stacked tensor -- see
|
| 178 |
+
# `ChronosAdapter._to_array`. Seedable, so it costs GPU quota on the
|
| 179 |
+
# next seed run; drop it from SEEDABLE_MODELS if that is not wanted yet.
|
| 180 |
+
ModelSpec("chronos-2", "amazon/chronos-2", "chronos",
|
| 181 |
+
"Chronos-2", context_len=512),
|
| 182 |
+
|
| 183 |
# Naive baselines, deliberately first-class. A forecasting model that
|
| 184 |
# cannot beat "tomorrow looks like today" is not worth deploying, and
|
| 185 |
# the leaderboard should make that impossible to miss.
|
tests/test_adapters.py
CHANGED
|
@@ -343,3 +343,73 @@ def test_chronos_100_step_run_is_schema_valid():
|
|
| 343 |
assert out["inference_version"].nunique() == 1
|
| 344 |
assert out["inference_version"].iloc[0] != config.PLACEHOLDER_VERSION
|
| 345 |
assert np.isfinite(out[["q10", "q50", "q90"]].to_numpy()).all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
assert out["inference_version"].nunique() == 1
|
| 344 |
assert out["inference_version"].iloc[0] != config.PLACEHOLDER_VERSION
|
| 345 |
assert np.isfinite(out[["q10", "q50", "q90"]].to_numpy()).all()
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
# --------------------------------------------------------------------------
|
| 349 |
+
# Chronos-2
|
| 350 |
+
# --------------------------------------------------------------------------
|
| 351 |
+
#
|
| 352 |
+
# `predict_quantiles` returns a different shape per pipeline, and the
|
| 353 |
+
# difference is not cosmetic:
|
| 354 |
+
#
|
| 355 |
+
# Bolt / T5 one stacked tensor, (batch, horizon, quantiles)
|
| 356 |
+
# Chronos-2 a LIST of per-item tensors, (n_variates, horizon, quantiles)
|
| 357 |
+
#
|
| 358 |
+
# Calling .float() on the list raises AttributeError, which is what
|
| 359 |
+
# amazon/chronos-2 did before `_to_array` existed.
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def test_bolt_style_tensor_is_normalised():
|
| 363 |
+
import numpy as np
|
| 364 |
+
|
| 365 |
+
from src.adapters import ChronosAdapter
|
| 366 |
+
|
| 367 |
+
stacked = np.zeros((4, 1, 3), dtype="float32")
|
| 368 |
+
stacked[:, 0, 1] = 5.0 # q50 for every row
|
| 369 |
+
out = ChronosAdapter._to_array(stacked)
|
| 370 |
+
assert out.shape == (4, 3)
|
| 371 |
+
assert np.allclose(out[:, 1], 5.0)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def test_chronos2_list_of_tensors_is_normalised():
|
| 375 |
+
import numpy as np
|
| 376 |
+
|
| 377 |
+
from src.adapters import ChronosAdapter
|
| 378 |
+
|
| 379 |
+
# Four items, each (n_variates=1, horizon=1, quantiles=3).
|
| 380 |
+
listed = [np.array([[[1.0, 2.0, 3.0]]], dtype="float32") for _ in range(4)]
|
| 381 |
+
out = ChronosAdapter._to_array(listed)
|
| 382 |
+
assert out.shape == (4, 3), "Chronos-2's list shape was not handled"
|
| 383 |
+
assert np.allclose(out[0], [1.0, 2.0, 3.0])
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def test_an_unexpected_result_type_raises_a_named_error():
|
| 387 |
+
from src.adapters import AdapterError, ChronosAdapter
|
| 388 |
+
|
| 389 |
+
with pytest.raises(AdapterError):
|
| 390 |
+
ChronosAdapter._to_array({"not": "a tensor"})
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
@pytest.mark.parametrize("model_id,is_c2,chunk_attr", [
|
| 394 |
+
("amazon/chronos-2", True, "CHRONOS2_CHUNK"),
|
| 395 |
+
("amazon/chronos-bolt-base", False, "BOLT_CHUNK"),
|
| 396 |
+
("amazon/chronos-t5-small", False, "T5_CHUNK"),
|
| 397 |
+
])
|
| 398 |
+
def test_chunk_size_per_variant(model_id, is_c2, chunk_attr):
|
| 399 |
+
from src.adapters import ChronosAdapter
|
| 400 |
+
|
| 401 |
+
chunk = getattr(ChronosAdapter, chunk_attr)
|
| 402 |
+
adapter = ChronosAdapter.__new__(ChronosAdapter)
|
| 403 |
+
adapter.model_id = model_id
|
| 404 |
+
assert adapter._is_chronos2 is is_c2
|
| 405 |
+
assert adapter.chunk_size == chunk
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def test_chronos2_does_not_ask_for_samples():
|
| 409 |
+
"""Only the original T5 Chronos samples paths; asking Chronos-2 for
|
| 410 |
+
`num_samples` is an unexpected keyword."""
|
| 411 |
+
from src.adapters import ChronosAdapter
|
| 412 |
+
|
| 413 |
+
adapter = ChronosAdapter.__new__(ChronosAdapter)
|
| 414 |
+
adapter.model_id = "amazon/chronos-2"
|
| 415 |
+
assert adapter._is_bolt is False and adapter._is_chronos2 is True
|