Who picks the tool
-->
Direkt zum Hauptbereich
Zeitreihen-Forecasting ist klassisch ein Feld für ARIMA (statistisches Modell aus Autoregression, Differenzbildung und gleitendem Mittel) und Prophet (Metas additives Modell für Trend, Saisonalität und Feiertagseffekte) & Co. — Modelle, die man pro Datensatz trainiert. Foundation-Modelle drehen das um: ein einziges, vortrainiertes Modell prognostiziert ohne Training beliebige neue Reihen. Googles TimesFM ist genau so ein Modell. In diesem Post baue ich eine kleine Phoenix-LiveView-App, die eine CSV-Zeitreihe entgegennimmt, sie mit TimesFM prognostiziert und das Ergebnis mit VegaLite zeichnet — und ich erkläre, warum das Modell weder über Bumblebee noch über Ortex geladen wird, sondern über Pythonx.
TimesFM ("Time Series Foundation Model") ist ein decoder-only Transformer von Google Research — architektonisch ein naher Verwandter von GPT, nur dass die Tokens keine Wörter sind, sondern Patches einer Zeitreihe. Das Modell zerlegt den historischen Verlauf in nicht-überlappende Patches, verarbeitet sie autoregressiv und sagt die nächsten Patches voraus. Längere Horizonte entstehen durch rekursives Rolling-Window-Decoding: Die Vorhersage wird zurückgefüttert.
Der eigentliche Nutzen ist das Zero-Shot-Forecasting:
Für ein Dashboard, das „mal eben" eine Prognose über eine hochgeladene CSV legen soll, ist das ideal: Man spart sich die komplette Trainings-Pipeline.
Hier wird es interessant — und hier liegt die Entscheidung, die die gesamte Architektur bestimmt. Es gibt drei naheliegende Wege, und zwei davon funktionieren nicht so, wie man hofft.
Bumblebee ist die offensichtliche erste Idee — „HuggingFace-Modelle in Elixir". Aber Bumblebee kann keine beliebigen HuggingFace-Repos laden. Jede Architektur braucht eine eigene Implementierung in Axon im Bumblebee-Quellcode: BERT, GPT-2, Llama, Whisper, Stable Diffusion … TimesFM ist dort schlicht nicht implementiert.
# Das hier schlägt fehl — nicht wegen falscher Konfiguration,
# sondern weil die Architektur in Bumblebee fehlt:
Bumblebee.load_model({:hf, "google/timesfm-2.5-200m-pytorch"})
Das ist kein Bug, den man umgeht — es fehlt der Architektur-Port. Bumblebee fällt also aus.
Ortex lädt ONNX-Modelle über die ONNX Runtime — elixir-nativ, kein Python. Technisch ginge das, aber es bedeutet zwei unangenehme Aufgaben:
Für einen Production-Service mit hohem Durchsatz und ohne Python-Abhängigkeit kann sich dieser Aufwand lohnen. Für „echtes TimesFM, schnell lauffähig" ist er unverhältnismäßig.
Pythonx (von Dashbit/Livebook) bettet einen Python-Interpreter direkt in den BEAM-Prozess ein und konvertiert Daten zwischen Elixir und Python. Damit lässt sich das offizielle timesfm-Paket verwenden — echtes Modell, keine nachgebaute Pipeline.
Pythonx verwaltet sein eigenes Python-Environment über uv. Wir deklarieren die Abhängigkeiten als pyproject.toml-String und initialisieren lazy beim ersten Aufruf (der lädt einmalig torch, timesfm und den Checkpoint — mehrere GB):
@pyproject """
[project]
name = "timesfm_forecaster"
version = "0.0.0"
requires-python = "==3.11.*"
dependencies = ["timesfm[torch]", "numpy"]
"""
@load_code """
import numpy as np
import timesfm
model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
"google/timesfm-2.5-200m-pytorch"
)
model.compile(
timesfm.ForecastConfig(
max_context=1024, max_horizon=256,
normalize_inputs=True, use_continuous_quantile_head=True,
force_flip_invariance=True, infer_is_positive=True,
fix_quantile_crossing=True,
)
)
True
"""
Der eigentliche Forecast ist dann nur noch ein paar Zeilen Python, denen wir die Reihe und den Horizont als Globals übergeben:
@forecast_code """
arr = np.asarray(series, dtype=np.float32)
point_forecast, quantile_forecast = model.forecast(horizon=int(horizon), inputs=[arr])
[float(v) for v in point_forecast[0]]
"""
Wichtig: Python hat einen GIL — zwei Threads können nicht gleichzeitig Python ausführen. Deshalb läuft der gesamte Modellzugriff durch einen einzigen GenServer. Er serialisiert die Aufrufe, lädt das Modell genau einmal und hält die Python-Globals (inklusive model) zwischen den Aufrufen:
def handle_call({:forecast, series, horizon}, _from, state) do
state = ensure_python(state) # uv_init + Modell-Load, je einmal
context = Enum.take(series, -1024) # Kontext auf max_context begrenzen
h = horizon |> min(256) |> max(1)
globals = Map.merge(state.globals, %{"series" => context, "horizon" => h})
{result, _} = Pythonx.eval(@forecast_code, globals)
{:reply, {:ok, Pythonx.decode(result)}, state}
end
Weil der erste Aufruf minutenlang dauern kann (Download!), ruft die LiveView den GenServer nicht direkt auf, sondern aus einem Task heraus — so bleibt die UI responsiv und zeigt einen „Rechne …"-Status.
Der Rest ist klassisches Phoenix. Drei bewegliche Teile:
allow_upload/3 und live_file_input. Ein kleiner Parser (NimbleCSV) erkennt eine Kopfzeile automatisch und unterstützt sowohl ein- als auch zweispaltige Reihen (Wert bzw. Zeitstempel + Wert).Forecaster-GenServer (siehe oben).push_event/3 an einen kleinen JS-Hook geschickt, der vegaEmbed aufruft.Vl.new(width: "container", height: 420, title: "TimesFM 2.5 — Zeitreihe & Forecast")
|> Vl.data_from_values(history ++ forecast_points)
|> Vl.mark(:line, point: true, tooltip: true)
|> Vl.encode_field(:x, "x", type: :quantitative, title: "Zeitschritt")
|> Vl.encode_field(:y, "y", type: :quantitative, title: "Wert")
|> Vl.encode_field(:color, "Reihe",
type: :nominal,
scale: [domain: ["Verlauf", "Forecast"], range: ["#2563eb", "#dc2626"]]
)
|> Vl.to_spec()
Und so sieht das Ergebnis aus — eine saisonale Reihe mit Aufwärtstrend, blau der hochgeladene Verlauf (120 Punkte), rot der TimesFM-Forecast über 64 Schritte:
Schön zu sehen: TimesFM setzt Saisonalität und Trend der Reihe ohne jedes Training korrekt fort.
Zwei ehrliche Stolpersteine: Der erste Aufruf lädt mehrere GB (Torch + Checkpoint), und die timesfm-API ist historisch wandelbar — beides ist im Code an genau einer Stelle gekapselt. Für ein internes Forecasting-Tool ist dieser Weg in überschaubarer Zeit lauffähig — und nutzt das echte Modell, nicht eine Annäherung.
Kommentare