Bit-Trading-Company commited on
Commit
389e1f7
·
verified ·
1 Parent(s): 14ba54c

CI deploy 0e23c622

Browse files
Files changed (3) hide show
  1. src/adapters.py +62 -2
  2. src/config.py +8 -0
  3. 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
- extra = {} if self._is_bolt else {"num_samples": self.T5_NUM_SAMPLES}
 
 
 
 
 
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(quantiles.float().cpu().numpy()[:, 0, :])
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