Pinned Post

OpenSpec UI: ein Live-Dashboard für deine Specs

Bild
OpenSpec UI: ein Live-Dashboard für deine Specs Wer mit einem KI-Agenten und OpenSpec arbeitet, kennt das Bild. Im Projekt wächst ein Verzeichnis openspec/ heran: Changes mit proposal.md , design.md , tasks.md und ihren Delta-Specs, daneben die kanonischen Specs der einzelnen Capabilities. Die Absicht hinter dem Code steht damit endlich geschrieben, statt sich in ihm zu verstecken. Nur verteilt sie sich über Dutzende Markdown-Dateien, und der Editor zeigt eben Dateien. Was er nicht zeigt, ist der Zustand. Woran arbeitet der Agent gerade? Welcher Task ist der nächste offene? Und was hat sich verändert, während ich zehn Minuten woanders hingeschaut habe? Genau diese Lücke füllt OpenSpec UI , ein kleines Phoenix-LiveView-Dashboard, das lokal neben deinem Projekt läuft und den OpenSpec-Workspace im Browser zeigt — live, während gearbeitet wird. Der Code liegt offen auf GitLab: https://gitlab.com/public_elixir/openspec_ui Ein Blick statt Datei-Hopping Du startest den Server, gibst d...

Hochladen, prognostizieren, plotten — TimesFM in Phoenix LiveView

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.

Was ist TimesFM — und wozu?

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:

  • Kein Training, kein Tuning. Reihe rein, Forecast raus. Kein Pro-Datensatz-Fitting.
  • Ein Modell für viele Domänen — Absatzzahlen, Sensordaten, Lastkurven, Metriken.
  • Sinnvolle Baseline in Minuten statt eines Modellierungs-Projekts.

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.

Der schwierige Teil: das Modell in Elixir laden

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.

Warum nicht Bumblebee?

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.

Warum nicht Ortex?

Ortex lädt ONNX-Modelle über die ONNX Runtime — elixir-nativ, kein Python. Technisch ginge das, aber es bedeutet zwei unangenehme Aufgaben:

  • Einen ONNX-Export von TimesFM beschaffen. Es gibt kein offizielles Standard-Artefakt; Community-Exporte schwanken in Qualität und Signatur.
  • Die Pre-/Post-Processing-Pipeline in Nx nachbauen. Patching, Normalisierung und vor allem das rekursive Rolling-Window-Decoding liegen außerhalb des ONNX-Graphen. Genau dieser Teil ist der aufwändigste — und fehleranfällig.

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.

Der pragmatische Weg: Pythonx

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.

Die App: Upload → Forecast → Plot

Der Rest ist klassisches Phoenix. Drei bewegliche Teile:

  • CSV-Upload über 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).
  • Forecast asynchron über den Forecaster-GenServer (siehe oben).
  • Visualisierung mit VegaLite: Verlauf und Forecast werden in einem Liniendiagramm gelayert. Der Forecast beginnt am letzten historischen Punkt, damit die Linien nahtlos anschließen. Die fertige Spec wird per 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:

App-Screenshot: Upload, Horizont-Eingabe und das kombinierte VegaLite-Diagramm mit Verlauf (blau) und Forecast (rot)
Die App: CSV-Upload, Horizont-Eingabe und das kombinierte VegaLite-Diagramm.

Schön zu sehen: TimesFM setzt Saisonalität und Trend der Reihe ohne jedes Training korrekt fort.

Fazit

  • TimesFM ist ein decoder-only Foundation-Model, das Zeitreihen zero-shot prognostiziert — ideal für „schnell eine Prognose über fremde Daten legen".
  • Bumblebee scheidet aus (Architektur nicht portiert), Ortex wäre möglich, verlangt aber ONNX-Export plus den Nachbau der Decoding-Pipeline in Nx.
  • Pythonx ist der pragmatische Mittelweg: das echte Python-Modell im BEAM-Prozess, serialisiert über einen GenServer, mit Phoenix/LiveView als Frontend und VegaLite für die Visualisierung.

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

Beliebte Posts aus diesem Blog

Splitting an ML model and a web app across two BEAM nodes — the technical blueprint

Jido in Practice: Agents in Elixir as Composable Actions

A Foundation Model in the BEAM: On-Chain Anomalies with Google TimesFM in Elixir