flashpdm 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of flashpdm might be problematic. Click here for more details.
- flashpdm/__init__.py +6 -0
- flashpdm/arrays.py +56 -0
- flashpdm/client.py +230 -0
- flashpdm/gift.py +240 -0
- flashpdm/gift_reference.json +1100 -0
- flashpdm/gift_reference_l40s.json +506 -0
- flashpdm/types.py +46 -0
- flashpdm-0.1.0.dist-info/METADATA +151 -0
- flashpdm-0.1.0.dist-info/RECORD +10 -0
- flashpdm-0.1.0.dist-info/WHEEL +4 -0
flashpdm/__init__.py
ADDED
flashpdm/arrays.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Optional NumPy adapter over the same public forecast API."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def series_from_arrays(context, horizon, past_covariates=None, future_covariates=None, *, numpy_values=False):
|
|
6
|
+
x = np.asarray(context, dtype=np.float32)
|
|
7
|
+
if x.ndim == 1:
|
|
8
|
+
x = x[None, None]
|
|
9
|
+
elif x.ndim == 2:
|
|
10
|
+
x = x[None]
|
|
11
|
+
if x.ndim != 3:
|
|
12
|
+
raise ValueError("context must have shape (N,V,T), (V,T), or (T,)")
|
|
13
|
+
n, v, full_t = x.shape
|
|
14
|
+
t = min(full_t, 16336)
|
|
15
|
+
def covariates(raw, future=False):
|
|
16
|
+
if raw is None:
|
|
17
|
+
return None
|
|
18
|
+
a = np.asarray(raw, dtype=np.float32)
|
|
19
|
+
if a.ndim == 2 and n == 1:
|
|
20
|
+
a = a[None]
|
|
21
|
+
if a.ndim != 3 or a.shape[0] != n or a.shape[2] != full_t + (horizon if future else 0):
|
|
22
|
+
raise ValueError("Covariate batch/time axes must match the context")
|
|
23
|
+
return a
|
|
24
|
+
past = covariates(past_covariates)
|
|
25
|
+
known = covariates(future_covariates, future=True)
|
|
26
|
+
def numbers(row):
|
|
27
|
+
if numpy_values:
|
|
28
|
+
return np.ascontiguousarray(row)
|
|
29
|
+
# Nine significant decimal digits preserve every float32 exactly and
|
|
30
|
+
# avoid sending a float64-length decimal representation of each value.
|
|
31
|
+
return [None if np.isnan(value) else float(format(float(value), ".9g")) for value in row]
|
|
32
|
+
rows = []
|
|
33
|
+
for b in range(n):
|
|
34
|
+
group = f"window-{b}"
|
|
35
|
+
for j in range(v):
|
|
36
|
+
rows.append({"id": f"{b}:target:{j}", "group": group, "values": numbers(x[b, j, -t:])})
|
|
37
|
+
if past is not None:
|
|
38
|
+
for j in range(past.shape[1]):
|
|
39
|
+
rows.append({"id": f"{b}:past:{j}", "group": group, "role": "past_covariate", "values": numbers(past[b, j, -t:])})
|
|
40
|
+
if known is not None:
|
|
41
|
+
for j in range(known.shape[1]):
|
|
42
|
+
rows.append({"id": f"{b}:known:{j}", "group": group, "role": "future_covariate",
|
|
43
|
+
"values": numbers(known[b, j, full_t-t:full_t]), "future_values": numbers(known[b, j, full_t:])})
|
|
44
|
+
return rows, n, v
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def forecast_arrays(client, context, horizon, *, past_covariates=None, future_covariates=None,
|
|
48
|
+
quantiles=None, postprocess=True, model_revision=None):
|
|
49
|
+
levels = quantiles if quantiles is not None else [.01] + [i / 20 for i in range(1, 20)] + [.99]
|
|
50
|
+
rows, n, v = series_from_arrays(context, horizon, past_covariates, future_covariates, numpy_values=True)
|
|
51
|
+
result = client._forecast_array_rows(rows, horizon, quantiles=levels, postprocess=postprocess, model_revision=model_revision)
|
|
52
|
+
values = np.empty((n, v, horizon, len(levels)), dtype=np.float32)
|
|
53
|
+
for b in range(n):
|
|
54
|
+
for j in range(v):
|
|
55
|
+
values[b, j] = np.asarray(result[f"{b}:target:{j}"].quantile_values, dtype=np.float32).T
|
|
56
|
+
return values
|
flashpdm/client.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Sync and async HTTP clients. Forecast requests are never retried implicitly."""
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .types import APIError, Forecast, ForecastResponse
|
|
10
|
+
|
|
11
|
+
MAX_RESPONSE_BYTES = 32 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _body(series, horizon, model, *, quantiles=None, postprocess=None, model_revision=None, array_values=False):
|
|
15
|
+
if model != "pdm-nano":
|
|
16
|
+
raise ValueError("Only pdm-nano is supported")
|
|
17
|
+
if isinstance(horizon, bool) or not isinstance(horizon, int) or not 1 <= horizon <= 16384:
|
|
18
|
+
raise ValueError("horizon must be an integer in [1, 16384]")
|
|
19
|
+
if isinstance(series, dict):
|
|
20
|
+
series = [{"id": k, "values": v} for k, v in series.items()]
|
|
21
|
+
if not isinstance(series, (list, tuple)) or not 1 <= len(series) <= 512:
|
|
22
|
+
raise ValueError("series must contain 1 to 512 named series; account limits are enforced by the gateway")
|
|
23
|
+
def values(raw):
|
|
24
|
+
if array_values:
|
|
25
|
+
import numpy as np
|
|
26
|
+
if not isinstance(raw, np.ndarray) or raw.dtype != np.float32 or raw.ndim != 1 or np.isinf(raw).any():
|
|
27
|
+
raise ValueError("Array observations must be float32 vectors without infinities")
|
|
28
|
+
return np.ascontiguousarray(raw)
|
|
29
|
+
clean = []
|
|
30
|
+
for value in raw:
|
|
31
|
+
if value is None:
|
|
32
|
+
clean.append(None)
|
|
33
|
+
continue
|
|
34
|
+
if isinstance(value, (bool, str, bytes)):
|
|
35
|
+
raise ValueError("Observations must be numbers or None")
|
|
36
|
+
try:
|
|
37
|
+
number = float(value)
|
|
38
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
39
|
+
raise ValueError("Observations must be numbers or None") from exc
|
|
40
|
+
if math.isinf(number):
|
|
41
|
+
raise ValueError("Infinite observations are invalid")
|
|
42
|
+
clean.append(None if math.isnan(number) else number)
|
|
43
|
+
return clean
|
|
44
|
+
result, seen = [], set()
|
|
45
|
+
for row in series:
|
|
46
|
+
if not isinstance(row, dict) or set(row) - {"id", "values", "group", "role", "future_values"} or not {"id", "values"} <= set(row):
|
|
47
|
+
raise ValueError("Each series must contain id and values")
|
|
48
|
+
name = row["id"]
|
|
49
|
+
if not isinstance(name, str) or not 1 <= len(name) <= 100 or name in seen:
|
|
50
|
+
raise ValueError("Series IDs must be unique strings of 1 to 100 characters")
|
|
51
|
+
clean = values(row["values"])
|
|
52
|
+
if not 2 <= len(clean) <= 16336:
|
|
53
|
+
raise ValueError("Each series requires 2 to 16336 observations")
|
|
54
|
+
item = {"id": name, "values": clean}
|
|
55
|
+
for field in ("group", "role"):
|
|
56
|
+
if field in row:
|
|
57
|
+
item[field] = row[field]
|
|
58
|
+
if "future_values" in row:
|
|
59
|
+
item["future_values"] = values(row["future_values"])
|
|
60
|
+
seen.add(name)
|
|
61
|
+
result.append(item)
|
|
62
|
+
body = {"model": model, "series": result, "horizon": horizon}
|
|
63
|
+
if quantiles is not None:
|
|
64
|
+
if not 1 <= len(quantiles) <= 21 or any(isinstance(q, bool) or not isinstance(q, (int, float)) or not .01 <= q <= .99 for q in quantiles) or any(b <= a for a, b in zip(quantiles, quantiles[1:])):
|
|
65
|
+
raise ValueError("quantiles must be increasing probabilities in [0.01, 0.99]")
|
|
66
|
+
body["quantiles"] = list(quantiles)
|
|
67
|
+
if postprocess is not None:
|
|
68
|
+
if not isinstance(postprocess, bool):
|
|
69
|
+
raise ValueError("postprocess must be boolean")
|
|
70
|
+
body["postprocess"] = postprocess
|
|
71
|
+
if model_revision is not None:
|
|
72
|
+
if not isinstance(model_revision, str) or len(model_revision) != 64 or any(c not in "0123456789abcdef" for c in model_revision):
|
|
73
|
+
raise ValueError("model_revision must be a SHA-256 digest")
|
|
74
|
+
body["model_revision"] = model_revision
|
|
75
|
+
return body
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _decode(response, content, payload):
|
|
79
|
+
request_id = response.headers.get("x-request-id")
|
|
80
|
+
try:
|
|
81
|
+
data = json.loads(content)
|
|
82
|
+
except (ValueError, UnicodeError) as exc:
|
|
83
|
+
raise APIError("Invalid API response", status_code=response.status_code,
|
|
84
|
+
code="invalid_response", request_id=request_id) from exc
|
|
85
|
+
if response.status_code != 200:
|
|
86
|
+
error = data.get("error", {}) if isinstance(data, dict) else {}
|
|
87
|
+
if not isinstance(error, dict):
|
|
88
|
+
error = {}
|
|
89
|
+
raise APIError(error.get("message", "Forecast request failed"), status_code=response.status_code,
|
|
90
|
+
code=error.get("code"), request_id=error.get("requestId", request_id),
|
|
91
|
+
retry_after=error.get("retryAfter", response.headers.get("retry-after")))
|
|
92
|
+
try:
|
|
93
|
+
if set(data) - {"model", "forecasts", "quantiles", "model_revision"} or data["model"] != payload["model"]:
|
|
94
|
+
raise ValueError("Unexpected model response")
|
|
95
|
+
by_id = {}
|
|
96
|
+
expected = {r["id"] for r in payload["series"] if r.get("role", "target") == "target"}
|
|
97
|
+
if payload.get("model_revision") and data.get("model_revision") != payload["model_revision"]:
|
|
98
|
+
raise ValueError("Unexpected model revision")
|
|
99
|
+
if data.get("quantiles") != payload.get("quantiles"):
|
|
100
|
+
raise ValueError("Unexpected quantile grid")
|
|
101
|
+
horizon = payload["horizon"]
|
|
102
|
+
for row in data["forecasts"]:
|
|
103
|
+
if set(row) - {"id", "values", "lower", "upper", "quantile_values"} or row["id"] not in expected or row["id"] in by_id:
|
|
104
|
+
raise ValueError("Unexpected forecast series")
|
|
105
|
+
for field in ("values", "lower", "upper"):
|
|
106
|
+
if field in row:
|
|
107
|
+
values = row[field]
|
|
108
|
+
if not isinstance(values, list) or len(values) != horizon or any(
|
|
109
|
+
isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) for v in values):
|
|
110
|
+
raise ValueError("Invalid forecast values")
|
|
111
|
+
if ("lower" in row) != ("upper" in row):
|
|
112
|
+
raise ValueError("Incomplete forecast bounds")
|
|
113
|
+
if "lower" in row and any(lo > mid or mid > hi for lo, mid, hi in zip(row["lower"], row["values"], row["upper"])):
|
|
114
|
+
raise ValueError("Forecast bounds cross")
|
|
115
|
+
if payload.get("quantiles"):
|
|
116
|
+
bands = row.get("quantile_values")
|
|
117
|
+
if not isinstance(bands, list) or len(bands) != len(payload["quantiles"]) or any(
|
|
118
|
+
not isinstance(band, list) or len(band) != horizon or any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) for v in band) for band in bands):
|
|
119
|
+
raise ValueError("Invalid quantile forecasts")
|
|
120
|
+
if any(any(v < before[j] for j, v in enumerate(after)) for before, after in zip(bands, bands[1:])):
|
|
121
|
+
raise ValueError("Quantile forecasts cross")
|
|
122
|
+
elif "quantile_values" in row:
|
|
123
|
+
raise ValueError("Unexpected quantile forecasts")
|
|
124
|
+
by_id[row["id"]] = Forecast(**row)
|
|
125
|
+
if set(by_id) != expected:
|
|
126
|
+
raise ValueError("Missing forecast series")
|
|
127
|
+
return ForecastResponse(data["model"], [by_id[r["id"]] for r in payload["series"] if r.get("role", "target") == "target"], request_id, data.get("quantiles"), data.get("model_revision"))
|
|
128
|
+
except (KeyError, TypeError, ValueError, OverflowError) as exc:
|
|
129
|
+
raise APIError("Invalid forecast response", status_code=200, code="invalid_response", request_id=request_id) from exc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _Base:
|
|
133
|
+
def __init__(self, api_key=None, *, base_url="https://www.flashmind.com", timeout=130., transport=None):
|
|
134
|
+
url = urlsplit(base_url)
|
|
135
|
+
local = url.hostname in {"localhost", "127.0.0.1", "::1"}
|
|
136
|
+
if url.username or url.password or url.query or url.fragment or not url.netloc or (
|
|
137
|
+
url.scheme != "https" and not (local and url.scheme == "http")):
|
|
138
|
+
raise ValueError("base_url must be HTTPS (loopback HTTP is allowed for development)")
|
|
139
|
+
self.api_key = api_key if api_key is not None else os.environ.get("FLASHMIND_API_KEY")
|
|
140
|
+
if self.api_key is not None and (not isinstance(self.api_key, str) or not self.api_key.strip()):
|
|
141
|
+
raise ValueError("api_key cannot be empty")
|
|
142
|
+
self.endpoint = base_url.rstrip("/") + ("/v1/forecast" if self.api_key else "/api/public/forecast")
|
|
143
|
+
self.headers = {"Accept": "application/json", "User-Agent": "flashpdm-python/0.1.0"}
|
|
144
|
+
if self.api_key:
|
|
145
|
+
self.headers["Authorization"] = f"Bearer {self.api_key}"
|
|
146
|
+
self.timeout, self.transport = timeout, transport
|
|
147
|
+
|
|
148
|
+
def _headers(self, idempotency_key):
|
|
149
|
+
if idempotency_key and not self.api_key:
|
|
150
|
+
raise ValueError("Idempotency keys require an API key")
|
|
151
|
+
if idempotency_key is not None and (not isinstance(idempotency_key, str) or not idempotency_key or "\n" in idempotency_key or "\r" in idempotency_key):
|
|
152
|
+
raise ValueError("Invalid idempotency key")
|
|
153
|
+
return {**self.headers, **({"Idempotency-Key": idempotency_key} if idempotency_key else {})}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Client(_Base):
|
|
157
|
+
def __init__(self, *args, **kwargs):
|
|
158
|
+
super().__init__(*args, **kwargs)
|
|
159
|
+
self._http = httpx.Client(timeout=self.timeout, transport=self.transport, follow_redirects=False)
|
|
160
|
+
|
|
161
|
+
def forecast(self, series, horizon, *, model="pdm-nano", idempotency_key=None, quantiles=None, postprocess=None, model_revision=None):
|
|
162
|
+
payload = _body(series, horizon, model, quantiles=quantiles, postprocess=postprocess, model_revision=model_revision)
|
|
163
|
+
return self._request(payload, idempotency_key=idempotency_key)
|
|
164
|
+
|
|
165
|
+
def _forecast_array_rows(self, series, horizon, **kwargs):
|
|
166
|
+
import orjson
|
|
167
|
+
payload = _body(series, horizon, "pdm-nano", array_values=True, **kwargs)
|
|
168
|
+
# Native float32 serialization preserves the exact observations and
|
|
169
|
+
# emits NaN as JSON null without per-value Python formatting.
|
|
170
|
+
encoded = orjson.dumps(payload, option=orjson.OPT_SERIALIZE_NUMPY)
|
|
171
|
+
return self._request(payload, encoded=encoded)
|
|
172
|
+
|
|
173
|
+
def _request(self, payload, *, idempotency_key=None, encoded=None):
|
|
174
|
+
headers = self._headers(idempotency_key)
|
|
175
|
+
if encoded is not None:
|
|
176
|
+
headers["Content-Type"] = "application/json"
|
|
177
|
+
body = {"json": payload} if encoded is None else {"content": encoded}
|
|
178
|
+
try:
|
|
179
|
+
with self._http.stream("POST", self.endpoint, headers=headers, **body) as response:
|
|
180
|
+
chunks, size = [], 0
|
|
181
|
+
for chunk in response.iter_bytes():
|
|
182
|
+
size += len(chunk)
|
|
183
|
+
if size > MAX_RESPONSE_BYTES:
|
|
184
|
+
raise APIError("API response too large", code="invalid_response")
|
|
185
|
+
chunks.append(chunk)
|
|
186
|
+
return _decode(response, b"".join(chunks), payload)
|
|
187
|
+
except httpx.RequestError as exc:
|
|
188
|
+
raise APIError("Forecast transport failed; outcome may be unknown. Requests are not automatically retried.", code="transport_error") from exc
|
|
189
|
+
|
|
190
|
+
def close(self):
|
|
191
|
+
self._http.close()
|
|
192
|
+
|
|
193
|
+
def forecast_quantiles(self, context, horizon, **kwargs):
|
|
194
|
+
from .arrays import forecast_arrays
|
|
195
|
+
return forecast_arrays(self, context, horizon, **kwargs)
|
|
196
|
+
|
|
197
|
+
def __enter__(self):
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
def __exit__(self, *_):
|
|
201
|
+
self.close()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class AsyncClient(_Base):
|
|
205
|
+
def __init__(self, *args, **kwargs):
|
|
206
|
+
super().__init__(*args, **kwargs)
|
|
207
|
+
self._http = httpx.AsyncClient(timeout=self.timeout, transport=self.transport, follow_redirects=False)
|
|
208
|
+
|
|
209
|
+
async def forecast(self, series, horizon, *, model="pdm-nano", idempotency_key=None, quantiles=None, postprocess=None, model_revision=None):
|
|
210
|
+
payload = _body(series, horizon, model, quantiles=quantiles, postprocess=postprocess, model_revision=model_revision)
|
|
211
|
+
try:
|
|
212
|
+
async with self._http.stream("POST", self.endpoint, headers=self._headers(idempotency_key), json=payload) as response:
|
|
213
|
+
chunks, size = [], 0
|
|
214
|
+
async for chunk in response.aiter_bytes():
|
|
215
|
+
size += len(chunk)
|
|
216
|
+
if size > MAX_RESPONSE_BYTES:
|
|
217
|
+
raise APIError("API response too large", code="invalid_response")
|
|
218
|
+
chunks.append(chunk)
|
|
219
|
+
return _decode(response, b"".join(chunks), payload)
|
|
220
|
+
except httpx.RequestError as exc:
|
|
221
|
+
raise APIError("Forecast transport failed; outcome may be unknown. Requests are not automatically retried.", code="transport_error") from exc
|
|
222
|
+
|
|
223
|
+
async def aclose(self):
|
|
224
|
+
await self._http.aclose()
|
|
225
|
+
|
|
226
|
+
async def __aenter__(self):
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
async def __aexit__(self, *_):
|
|
230
|
+
await self.aclose()
|
flashpdm/gift.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""GIFT-Eval predictor backed only by the public forecasting HTTP API."""
|
|
2
|
+
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
3
|
+
from contextlib import nullcontext
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from threading import Lock
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
LEVELS = [.1, .2, .3, .4, .5, .6, .7, .8, .9]
|
|
15
|
+
_DATASET_LOCK = Lock()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_dataset(name, term):
|
|
19
|
+
from datasets.utils.tqdm import tqdm as dataset_progress
|
|
20
|
+
from gift_eval.data import Dataset
|
|
21
|
+
# Hugging Face uses tqdm.thread_map while loading Arrow shards. Its
|
|
22
|
+
# temporary lock can be deleted by another concurrent load. Only dataset
|
|
23
|
+
# construction is serialized; forecasts and metric scoring stay parallel.
|
|
24
|
+
with _DATASET_LOCK:
|
|
25
|
+
dataset_progress.set_lock(dataset_progress.get_lock())
|
|
26
|
+
return Dataset(name=name, term=term, to_univariate=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fullstack_projection(bands, context):
|
|
30
|
+
"""Canonical non-negative/count projection using the full observed history.
|
|
31
|
+
|
|
32
|
+
bands: (V,H,Q); context: (V,T). No future labels are inspected.
|
|
33
|
+
"""
|
|
34
|
+
result = np.asarray(bands, dtype=np.float32).copy()
|
|
35
|
+
for v, history in enumerate(context):
|
|
36
|
+
observed = np.asarray(history, dtype=np.float64)
|
|
37
|
+
observed = observed[np.isfinite(observed)]
|
|
38
|
+
if observed.size and np.all(observed >= 0):
|
|
39
|
+
result[v] = np.maximum(result[v], 0)
|
|
40
|
+
if observed.size >= 8 and np.all(observed >= 0) and np.all(observed == np.rint(observed)):
|
|
41
|
+
result[v] = np.maximum(np.rint(result[v]), 0)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PdmPredictor:
|
|
46
|
+
"""GluonTS predictor: target variates stay joint, past covariates stay hidden."""
|
|
47
|
+
def __init__(self, client, prediction_length, *, model_revision, use_past_covariates=True,
|
|
48
|
+
max_series=256, max_cells=131072, max_windows=32, concurrency=4, executor=None, cache_dir=None):
|
|
49
|
+
self.client = client
|
|
50
|
+
self.prediction_length = int(prediction_length)
|
|
51
|
+
self.model_revision = model_revision
|
|
52
|
+
self.use_past_covariates = use_past_covariates
|
|
53
|
+
self.max_series, self.max_cells, self.concurrency = max_series, max_cells, concurrency
|
|
54
|
+
self.max_windows = max_windows
|
|
55
|
+
self.executor = executor
|
|
56
|
+
self.cache_dir = Path(cache_dir) if cache_dir is not None else None
|
|
57
|
+
if self.cache_dir:
|
|
58
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
def predict(self, inputs):
|
|
61
|
+
from gluonts.model.forecast import QuantileForecast
|
|
62
|
+
entries = list(inputs)
|
|
63
|
+
targets, past = [], []
|
|
64
|
+
for entry in entries:
|
|
65
|
+
target = np.asarray(entry["target"], dtype=np.float32)
|
|
66
|
+
target = target[None] if target.ndim == 1 else target
|
|
67
|
+
targets.append(target)
|
|
68
|
+
cov = entry.get("past_feat_dynamic_real") if self.use_past_covariates else None
|
|
69
|
+
if cov is not None:
|
|
70
|
+
cov = np.asarray(cov, dtype=np.float32)
|
|
71
|
+
cov = cov[None] if cov.ndim == 1 else cov
|
|
72
|
+
cov = cov[:, :target.shape[-1]]
|
|
73
|
+
past.append(cov)
|
|
74
|
+
order = sorted(range(len(entries)), key=lambda i: min(targets[i].shape[-1], 16336))
|
|
75
|
+
packs, pack = [], []
|
|
76
|
+
for index in order:
|
|
77
|
+
candidate = pack + [index]
|
|
78
|
+
width = max(min(targets[i].shape[-1], 16336) for i in candidate)
|
|
79
|
+
rows = sum(targets[i].shape[0] + (0 if past[i] is None else past[i].shape[0]) for i in candidate)
|
|
80
|
+
output_cells = sum(targets[i].shape[0] for i in candidate) * self.prediction_length * 12
|
|
81
|
+
if pack and (len(candidate) > self.max_windows or rows > self.max_series or rows * width > self.max_cells or output_cells > 1_500_000):
|
|
82
|
+
packs.append(pack)
|
|
83
|
+
pack = []
|
|
84
|
+
pack.append(index)
|
|
85
|
+
if pack:
|
|
86
|
+
packs.append(pack)
|
|
87
|
+
outputs = [None] * len(entries)
|
|
88
|
+
def run(indices):
|
|
89
|
+
width = max(min(targets[i].shape[-1], 16336) for i in indices)
|
|
90
|
+
def pad(x):
|
|
91
|
+
x = x[:, -width:]
|
|
92
|
+
return np.pad(x, ((0, 0), (width - x.shape[-1], 0)), constant_values=np.nan)
|
|
93
|
+
context = np.stack([pad(targets[i]) for i in indices])
|
|
94
|
+
covs = None if past[indices[0]] is None else np.stack([pad(past[i]) for i in indices])
|
|
95
|
+
path = None
|
|
96
|
+
if self.cache_dir:
|
|
97
|
+
digest = hashlib.sha256(f"native-v1:{self.model_revision}:{self.prediction_length}:{LEVELS}".encode())
|
|
98
|
+
for array in (context, covs):
|
|
99
|
+
digest.update(str(None if array is None else array.shape).encode())
|
|
100
|
+
if array is not None:
|
|
101
|
+
digest.update(array.tobytes())
|
|
102
|
+
path = self.cache_dir / (digest.hexdigest() + ".npy")
|
|
103
|
+
if path.exists():
|
|
104
|
+
bands = np.load(path, allow_pickle=False)
|
|
105
|
+
if bands.shape != (*context.shape[:2], self.prediction_length, len(LEVELS)) or not np.isfinite(bands).all():
|
|
106
|
+
raise ValueError(f"Invalid cached forecast: {path}")
|
|
107
|
+
return indices, bands
|
|
108
|
+
# Request raw native quantiles; perform the recorded serving projection
|
|
109
|
+
# from FULL observed history, including observations before the model crop.
|
|
110
|
+
bands = self.client.forecast_quantiles(context, self.prediction_length,
|
|
111
|
+
past_covariates=covs, quantiles=LEVELS, postprocess=False, model_revision=self.model_revision)
|
|
112
|
+
if path is not None:
|
|
113
|
+
# Atomic per-request checkpoints retain expensive forecasts if
|
|
114
|
+
# a later request fails; no partial file can become a cache hit.
|
|
115
|
+
with tempfile.NamedTemporaryFile(dir=self.cache_dir, suffix=".npy", delete=False) as file:
|
|
116
|
+
temporary = file.name
|
|
117
|
+
np.save(file, bands, allow_pickle=False)
|
|
118
|
+
os.replace(temporary, path)
|
|
119
|
+
return indices, bands
|
|
120
|
+
# Share a bounded API pool across configurations. When only one large
|
|
121
|
+
# dataset remains, its windows can use the whole pool instead of being
|
|
122
|
+
# stranded behind a fixed two-request per-configuration limit.
|
|
123
|
+
manager = nullcontext(self.executor) if self.executor is not None else ThreadPoolExecutor(max_workers=self.concurrency)
|
|
124
|
+
with manager as pool:
|
|
125
|
+
pending, remaining = set(), iter(packs)
|
|
126
|
+
def submit():
|
|
127
|
+
indices = next(remaining, None)
|
|
128
|
+
if indices is not None:
|
|
129
|
+
pending.add(pool.submit(run, indices))
|
|
130
|
+
for _ in range(self.concurrency):
|
|
131
|
+
submit()
|
|
132
|
+
try:
|
|
133
|
+
while pending:
|
|
134
|
+
done, _ = wait(pending, return_when=FIRST_COMPLETED)
|
|
135
|
+
for future in done:
|
|
136
|
+
pending.remove(future)
|
|
137
|
+
indices, bands = future.result()
|
|
138
|
+
for j, index in enumerate(indices):
|
|
139
|
+
outputs[index] = fullstack_projection(bands[j], targets[index])
|
|
140
|
+
submit()
|
|
141
|
+
finally:
|
|
142
|
+
for future in pending:
|
|
143
|
+
future.cancel()
|
|
144
|
+
for entry, target, bands in zip(entries, targets, outputs):
|
|
145
|
+
# The point forecast is the median, matching the full-stack scorer.
|
|
146
|
+
arrays = np.concatenate([bands, bands[..., 4:5]], axis=-1).transpose(2, 1, 0)
|
|
147
|
+
if np.asarray(entry["target"]).ndim == 1:
|
|
148
|
+
arrays = arrays[..., 0]
|
|
149
|
+
yield QuantileForecast(arrays, start_date=entry["start"] + target.shape[-1],
|
|
150
|
+
forecast_keys=[str(q) for q in LEVELS] + ["mean"], item_id=entry.get("item_id"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def metrics():
|
|
154
|
+
from gluonts.ev.metrics import MAE, MAPE, MASE, MSE, MSIS, ND, NRMSE, RMSE, SMAPE, MeanWeightedSumQuantileLoss
|
|
155
|
+
return [MSE(forecast_type="mean"), MSE(forecast_type=.5), MAE(), MASE(), MAPE(), SMAPE(), MSIS(),
|
|
156
|
+
RMSE(), NRMSE(), ND(), MeanWeightedSumQuantileLoss(quantile_levels=LEVELS)]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def evaluate_config(client, name, term, *, model_revision, window_limit=None, concurrency=4, executor=None, cache_dir=None):
|
|
160
|
+
from gluonts.model import evaluate_model
|
|
161
|
+
from gluonts.time_feature import get_seasonality
|
|
162
|
+
ds = load_dataset(name, term)
|
|
163
|
+
predictor = PdmPredictor(client, ds.prediction_length, model_revision=model_revision,
|
|
164
|
+
use_past_covariates=bool(ds.past_feat_dynamic_real_dim), concurrency=concurrency, executor=executor, cache_dir=cache_dir)
|
|
165
|
+
data = ds.test_data
|
|
166
|
+
if window_limit is not None:
|
|
167
|
+
from itertools import islice
|
|
168
|
+
from types import SimpleNamespace
|
|
169
|
+
data = SimpleNamespace(input=list(islice(data.input, window_limit)), label=list(islice(data.label, window_limit)))
|
|
170
|
+
result = evaluate_model(predictor, test_data=data, metrics=metrics(), batch_size=512, axis=None,
|
|
171
|
+
mask_invalid_label=True, allow_nan_forecast=False, seasonality=get_seasonality(ds.freq))
|
|
172
|
+
return {column: float(result[column].iloc[0]) for column in result.columns}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ScoreMismatchError(AssertionError):
|
|
176
|
+
def __init__(self, report):
|
|
177
|
+
self.report = report
|
|
178
|
+
first = report["mismatches"][0]
|
|
179
|
+
super().__init__(f"{first['config']} {first['metric']}: API/official={first['measured']}, recorded={first['reference']}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def verify_results(results, reference, *, rtol=.005):
|
|
183
|
+
"""Check every measured config against the recorded full-stack run."""
|
|
184
|
+
expected = {r["config"]: r for r in reference["configs"]}
|
|
185
|
+
checks, mismatches = [], []
|
|
186
|
+
for key, values in results.items():
|
|
187
|
+
baseline = expected[key]
|
|
188
|
+
for metric, column in [("MASE", "MASE[0.5]"), ("WQL", "mean_weighted_sum_quantile_loss")]:
|
|
189
|
+
got = values[column]
|
|
190
|
+
wanted = baseline[metric]
|
|
191
|
+
if not math.isfinite(got) or not math.isclose(got, wanted, rel_tol=rtol, abs_tol=1e-7):
|
|
192
|
+
mismatches.append({"config": key, "metric": metric, "measured": got, "reference": wanted,
|
|
193
|
+
"relative_difference": got / wanted - 1 if wanted else None})
|
|
194
|
+
checks.append(key)
|
|
195
|
+
population_complete = len(checks) == 97 and set(checks) == set(expected)
|
|
196
|
+
report = {"checked_configs": len(checks), "population_complete": population_complete,
|
|
197
|
+
"complete": population_complete and not mismatches, "rtol": rtol, "mismatches": mismatches}
|
|
198
|
+
if mismatches:
|
|
199
|
+
raise ScoreMismatchError(report)
|
|
200
|
+
return report
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def load_reference(path=None, *, profile="historical"):
|
|
204
|
+
"""Load an explicit scoring target, retaining the original historical run.
|
|
205
|
+
|
|
206
|
+
The L40S target is the measured production run proposed for submission.
|
|
207
|
+
It shares the pinned dataset and baseline with the historical run, and
|
|
208
|
+
never rewrites the earlier scores or changes comparison tolerances.
|
|
209
|
+
"""
|
|
210
|
+
if profile not in {"historical", "l40s"} or (path is not None and profile != "historical"):
|
|
211
|
+
raise ValueError("Choose historical, l40s, or an explicit historical reference path")
|
|
212
|
+
target = Path(path) if path else Path(__file__).with_name("gift_reference.json")
|
|
213
|
+
reference = json.loads(target.read_text())
|
|
214
|
+
if profile == "l40s":
|
|
215
|
+
measured = json.loads(Path(__file__).with_name("gift_reference_l40s.json").read_text())
|
|
216
|
+
if measured["model_revision"] != reference["model_revision"]:
|
|
217
|
+
raise ValueError("L40S and historical reference weights differ")
|
|
218
|
+
rows = {row["config"]: row for row in measured["configs"]}
|
|
219
|
+
expected = {row["config"] for row in reference["configs"]}
|
|
220
|
+
if len(rows) != len(measured["configs"]) or set(rows) != expected or len(rows) != 97:
|
|
221
|
+
raise ValueError("L40S reference must contain every configuration exactly once")
|
|
222
|
+
reference["configs"] = [{**row, **rows[row["config"]]} for row in reference["configs"]]
|
|
223
|
+
reference.update(summary=measured["summary"], source=measured["provenance"],
|
|
224
|
+
reference_profile=measured["profile"],
|
|
225
|
+
runtime=measured["runtime"], source_results_sha256=measured["source_results_sha256"])
|
|
226
|
+
return reference
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def verify_dataset(path, reference):
|
|
230
|
+
"""Verify the exact official Arrow snapshot used for the recorded scores."""
|
|
231
|
+
root = Path(path)
|
|
232
|
+
for relative, wanted in reference["dataset_files_sha256"].items():
|
|
233
|
+
digest = hashlib.sha256()
|
|
234
|
+
with (root / relative).open("rb") as stream:
|
|
235
|
+
for block in iter(lambda: stream.read(8 * 1024 * 1024), b""):
|
|
236
|
+
digest.update(block)
|
|
237
|
+
if digest.hexdigest() != wanted:
|
|
238
|
+
raise ValueError(f"GIFT dataset integrity mismatch: {relative}")
|
|
239
|
+
return {"repository": reference["dataset_repo"], "revision": reference["dataset_revision"],
|
|
240
|
+
"verified_files": len(reference["dataset_files_sha256"])}
|