Add remote TimesFM service integration
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Dict, List
|
||||
|
||||
import numpy as np
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
DEFAULT_MODEL_ID = "google/timesfm-2.5-200m-pytorch"
|
||||
|
||||
|
||||
class SeriesPoint(BaseModel):
|
||||
timestamp: str
|
||||
value: float
|
||||
|
||||
|
||||
class DailyPredictRequest(BaseModel):
|
||||
city: str
|
||||
series_frequency: str = Field(default="D")
|
||||
series_kind: str = Field(default="actual_high")
|
||||
series: List[SeriesPoint]
|
||||
future_dates: List[str]
|
||||
daily_model_forecasts: Dict[str, Dict[str, float]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TimesFMPredictor:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._model = None
|
||||
self._model_id = (
|
||||
str(os.getenv("TIMESFM_MODEL_ID", DEFAULT_MODEL_ID)).strip()
|
||||
or DEFAULT_MODEL_ID
|
||||
)
|
||||
self._max_context = int(os.getenv("TIMESFM_MAX_CONTEXT", "1024"))
|
||||
self._max_horizon = int(os.getenv("TIMESFM_MAX_HORIZON", "7"))
|
||||
self._normalize_inputs = str(
|
||||
os.getenv("TIMESFM_NORMALIZE_INPUTS", "true")
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
self._use_quantile_head = str(
|
||||
os.getenv("TIMESFM_USE_QUANTILE_HEAD", "true")
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
self._force_flip_invariance = str(
|
||||
os.getenv("TIMESFM_FORCE_FLIP_INVARIANCE", "true")
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
self._infer_is_positive = str(
|
||||
os.getenv("TIMESFM_INFER_IS_POSITIVE", "false")
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
self._fix_quantile_crossing = str(
|
||||
os.getenv("TIMESFM_FIX_QUANTILE_CROSSING", "true")
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
@property
|
||||
def model_id(self) -> str:
|
||||
return self._model_id
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
return self._model is not None
|
||||
|
||||
def _ensure_loaded(self):
|
||||
if self._model is not None:
|
||||
return self._model
|
||||
|
||||
with self._lock:
|
||||
if self._model is not None:
|
||||
return self._model
|
||||
|
||||
import torch
|
||||
import timesfm
|
||||
|
||||
model_type = getattr(timesfm, "TimesFM_2p5_200M_torch", None)
|
||||
forecast_config_type = getattr(timesfm, "ForecastConfig", None)
|
||||
if model_type is None or forecast_config_type is None:
|
||||
raise RuntimeError("Unsupported official timesfm package layout.")
|
||||
|
||||
torch.set_float32_matmul_precision("high")
|
||||
model = model_type.from_pretrained(self._model_id)
|
||||
model.compile(
|
||||
forecast_config_type(
|
||||
max_context=self._max_context,
|
||||
max_horizon=self._max_horizon,
|
||||
normalize_inputs=self._normalize_inputs,
|
||||
use_continuous_quantile_head=self._use_quantile_head,
|
||||
force_flip_invariance=self._force_flip_invariance,
|
||||
infer_is_positive=self._infer_is_positive,
|
||||
fix_quantile_crossing=self._fix_quantile_crossing,
|
||||
)
|
||||
)
|
||||
self._model = model
|
||||
return self._model
|
||||
|
||||
def predict_daily(self, series: List[float], future_dates: List[str]) -> Dict[str, object]:
|
||||
model = self._ensure_loaded()
|
||||
horizon = len(future_dates)
|
||||
if horizon <= 0:
|
||||
raise ValueError("future_dates must not be empty")
|
||||
if horizon > self._max_horizon:
|
||||
raise ValueError(
|
||||
"future_dates exceeds configured max horizon: "
|
||||
f"{horizon} > {self._max_horizon}"
|
||||
)
|
||||
if len(series) < 8:
|
||||
raise ValueError("series must contain at least 8 points")
|
||||
|
||||
point_forecast, quantile_forecast = model.forecast(
|
||||
horizon=horizon,
|
||||
inputs=[np.asarray(series, dtype=np.float32)],
|
||||
)
|
||||
|
||||
point_row = point_forecast[0]
|
||||
predictions: Dict[str, float] = {}
|
||||
for index, date_str in enumerate(future_dates):
|
||||
if index >= len(point_row):
|
||||
break
|
||||
predictions[date_str] = round(float(point_row[index]), 1)
|
||||
|
||||
quantiles: Dict[str, Dict[str, float]] = {}
|
||||
try:
|
||||
if quantile_forecast is not None:
|
||||
q_arr = np.asarray(quantile_forecast)
|
||||
if (
|
||||
q_arr.ndim == 3
|
||||
and q_arr.shape[0] > 0
|
||||
and q_arr.shape[1] >= horizon
|
||||
and q_arr.shape[2] >= 10
|
||||
):
|
||||
for index, date_str in enumerate(future_dates):
|
||||
quantiles[date_str] = {
|
||||
"mean": round(float(q_arr[0, index, 0]), 1),
|
||||
"p10": round(float(q_arr[0, index, 1]), 1),
|
||||
"p50": round(float(q_arr[0, index, 5]), 1),
|
||||
"p90": round(float(q_arr[0, index, 9]), 1),
|
||||
}
|
||||
except Exception:
|
||||
quantiles = {}
|
||||
|
||||
return {
|
||||
"predictions": predictions,
|
||||
"quantiles": quantiles,
|
||||
}
|
||||
|
||||
|
||||
predictor = TimesFMPredictor()
|
||||
app = FastAPI(title="PolyWeather TimesFM Service", version="0.1.0")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> Dict[str, object]:
|
||||
return {
|
||||
"ok": True,
|
||||
"model_loaded": predictor.is_loaded(),
|
||||
"model_id": predictor.model_id,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/predict/daily")
|
||||
def predict_daily(payload: DailyPredictRequest) -> Dict[str, object]:
|
||||
values = [float(point.value) for point in payload.series]
|
||||
future_dates = [
|
||||
str(date_str or "").strip()
|
||||
for date_str in payload.future_dates
|
||||
if str(date_str or "").strip()
|
||||
]
|
||||
if not future_dates:
|
||||
raise HTTPException(status_code=400, detail="future_dates must not be empty")
|
||||
if not values:
|
||||
raise HTTPException(status_code=400, detail="series must not be empty")
|
||||
|
||||
try:
|
||||
result = predictor.predict_daily(values, future_dates)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"TimesFM inference failed: {exc}")
|
||||
|
||||
return {
|
||||
"model": "TimesFM",
|
||||
"model_id": predictor.model_id,
|
||||
"city": payload.city,
|
||||
"series_frequency": payload.series_frequency,
|
||||
"series_kind": payload.series_kind,
|
||||
"input_points": len(values),
|
||||
**result,
|
||||
}
|
||||
Reference in New Issue
Block a user