An example to evaluate: a training run, its dashboard, and two bugs it found
make seed-demo builds demo_training — prepare on the engine, a GPU-bound train, evaluate back here — and a panel that draws the loss curve while the training is still going. It is the session's whole argument in one flow: batch runs with parameters and a result, a generator yielding on a declared port rather than logging, fluksio.emit from inside a callback, artifacts carrying the dataset and the weights between machines, and a sweep whose configs are isolated from each other. The train node prefers its label rather than requiring it, so it runs before a GPU box exists and says which machine and which numeric backend it actually used. Building it turned up two real bugs. A run waited for a worker its flow only *preferred*, because required_labels ignored device_policy — so the example hung on a label it did not need. And a run's seed never reached the flow, so sweeping over seeds ran the same experiment N times; it now fills an input of that name when the flow declares one, which is what the field looked like it did all along. Pressing Run on a batch flow now submits a run rather than taking the old non-durable path — that button is the first thing anyone evaluating will press, and it was quietly doing something else. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
# The workspace root delegates to these (see ../Makefile).
|
# The workspace root delegates to these (see ../Makefile).
|
||||||
|
|
||||||
.PHONY: dev-utils dev dev-local up down update install dev-backend dev-frontend \
|
.PHONY: dev-utils dev dev-local up down update install dev-backend dev-frontend \
|
||||||
generate-client seed-example test test-backend test-frontend soak bench-startup lint lint-backend \
|
generate-client seed-example seed-demo test test-backend test-frontend soak bench-startup lint lint-backend \
|
||||||
lint-frontend umami clean help
|
lint-frontend umami clean help
|
||||||
|
|
||||||
COMPOSE_ROOT := $(CURDIR)
|
COMPOSE_ROOT := $(CURDIR)
|
||||||
@@ -94,6 +94,9 @@ generate-client: ## Regenerate the frontend SDK from the backend's OpenAPI sche
|
|||||||
seed-example: ## Seed the querying-chart example (needs a running stack + InfluxDB)
|
seed-example: ## Seed the querying-chart example (needs a running stack + InfluxDB)
|
||||||
cd backend && uv run python ../scripts/seed_example_chart.py
|
cd backend && uv run python ../scripts/seed_example_chart.py
|
||||||
|
|
||||||
|
seed-demo: ## Seed the training-run example: a batch flow and its dashboard
|
||||||
|
cd backend && uv run python ../scripts/seed_demo_training.py
|
||||||
|
|
||||||
# ── Testing ───────────────────────────────────────────────────────
|
# ── Testing ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
test: test-backend test-frontend ## Run all tests (backend + frontend)
|
test: test-backend test-frontend ## Run all tests (backend + frontend)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ should reopen it.
|
|||||||
|
|
||||||
### To be sorted
|
### To be sorted
|
||||||
|
|
||||||
|
- BUG/UI find a good way to represent the dictionary style of messages (truncated test, click to enlarge.. ). Show dtype instead of actual values -> actual values when clicking expand
|
||||||
- BUG/UI loop-lag in home view can't have a value when there are no flows (push back if I'm wrong here)
|
- BUG/UI loop-lag in home view can't have a value when there are no flows (push back if I'm wrong here)
|
||||||
- BUG/UI brain view in the home view should be hidden when there are no flows/nodes
|
- BUG/UI brain view in the home view should be hidden when there are no flows/nodes
|
||||||
- FEAT/UI in addition to the two issues above we should introduce a default flow and dashboard for every new user. These should be very minimal to quickly understand what's happening but still show off most of the features of the application. We could make this optional by offering a "Create Demo" button showing at the location of the brain view if there are no existing flows. This demo could then also serve as a demo.fluksio.com site later to play around without signup
|
- FEAT/UI in addition to the two issues above we should introduce a default flow and dashboard for every new user. These should be very minimal to quickly understand what's happening but still show off most of the features of the application. We could make this optional by offering a "Create Demo" button showing at the location of the brain view if there are no existing flows. This demo could then also serve as a demo.fluksio.com site later to play around without signup
|
||||||
@@ -220,6 +221,9 @@ Open on purpose. Each names what should bring it back.
|
|||||||
- CHORE/INFRA: NATS JetStream as the work-queue backend — durable streams whose consumer semantics match the `WorkQueue` interface, in one small binary. Reopen with M5 remote workers, when the queue crosses hosts. NOTE: remote workers landed without it — a worker dials the engine's own socket and never touches Redis, so the queue still does not cross a host. Reopen if a second engine ever pulls from the same stream.
|
- CHORE/INFRA: NATS JetStream as the work-queue backend — durable streams whose consumer semantics match the `WorkQueue` interface, in one small binary. Reopen with M5 remote workers, when the queue crosses hosts. NOTE: remote workers landed without it — a worker dials the engine's own socket and never touches Redis, so the queue still does not cross a host. Reopen if a second engine ever pulls from the same stream.
|
||||||
- FEAT/RUNS: stage caching. `run_node.cache_key` is written on every run and the artifact store is content-addressed, so the pieces are in place; what is missing is computing the key from the node's source digest plus its input values and skipping a node whose key already has an `ok` row with its artifacts still present. The two research repos want this more than they want resume — neither persists checkpoints, and both re-run unchanged preprocessing every time.
|
- FEAT/RUNS: stage caching. `run_node.cache_key` is written on every run and the artifact store is content-addressed, so the pieces are in place; what is missing is computing the key from the node's source digest plus its input values and skipping a node whose key already has an `ok` row with its artifacts still present. The two research repos want this more than they want resume — neither persists checkpoints, and both re-run unchanged preprocessing every time.
|
||||||
- FEAT/RUNS: per-label requirements overlays (`requirements-gpu.txt`) synced into a remote worker's venv, with drift surfaced against the engine's manifest. Today a worker's environment is whatever `--python` points at, which is fine for one hand-managed GPU box and not for several. `venv_digest` already arrives at attach and is shown on `/workers`, so the reporting half exists.
|
- FEAT/RUNS: per-label requirements overlays (`requirements-gpu.txt`) synced into a remote worker's venv, with drift surfaced against the engine's manifest. Today a worker's environment is whatever `--python` points at, which is fine for one hand-managed GPU box and not for several. `venv_digest` already arrives at attach and is shown on `/workers`, so the reporting half exists.
|
||||||
|
- FEAT/UI: a dashboard shows a run's curve only while it is running. Emissions reach the socket live, but a run's values live in its own state namespace, so reloading the panel afterwards leaves the chart empty — the durable series is on the run (`/runs/{id}/metrics`) and nothing binds a widget to it. A chart variant that reads a run's series, or the existing querying chart pointed at `/runs/series/compare`, is what would close it. This is also what a demo needs to show a finished experiment rather than only a live one.
|
||||||
|
- FEAT/UI: nothing submits a run with parameters from the UI. Pressing Run on a batch flow submits one with the declared defaults, which is enough to try it; choosing parameters, or launching a sweep, is API-only. A form generated from the flow's `inputs` is the obvious shape.
|
||||||
|
- FEAT/UI: `scripts/seed_demo_training.py` (`make seed-demo`) builds a batch flow and dashboard covering runs, streaming outputs, artifacts, device routing and sweeps. It is the content a "Create Demo" button would seed; what it needs to become one is a route that runs it server-side for the signed-in user.
|
||||||
- FEAT/RUNS: a run detail screen. The API answers everything — params, per-node status with logs and tracebacks, artifacts, metrics, and `/runs/series/compare` in the chart widget's own `series` shape — but nothing in the dashboard reads it yet, so a run is inspected over HTTP. Comparing curves is a widget binding once someone builds the page around it.
|
- FEAT/RUNS: a run detail screen. The API answers everything — params, per-node status with logs and tracebacks, artifacts, metrics, and `/runs/series/compare` in the chart widget's own `series` shape — but nothing in the dashboard reads it yet, so a run is inspected over HTTP. Comparing curves is a widget binding once someone builds the page around it.
|
||||||
- FEAT/RUNS: a thin client CLI (`fluksio run/runs/sweep/worker`) over the same API. The engine being resident is what makes runs cheap; a CLI is ergonomics on top, and `curl` covers it until someone is running sweeps daily.
|
- FEAT/RUNS: a thin client CLI (`fluksio run/runs/sweep/worker`) over the same API. The engine being resident is what makes runs cheap; a CLI is ergonomics on top, and `curl` covers it until someone is running sweeps daily.
|
||||||
- FEAT/RUNS: the step on a run's series is the count of emissions on that message, so a node yielding every tenth training step records steps 0, 1, 2 rather than 0, 10, 20 — a faithful x-axis of its own emissions, not of the loop inside it. If a real step number ever matters, a `record`-typed streaming port carrying its own `step` is the shape to read it from; the column is already there.
|
- FEAT/RUNS: the step on a run's series is the count of emissions on that message, so a node yielding every tenth training step records steps 0, 1, 2 rather than 0, 10, 20 — a faithful x-axis of its own emissions, not of the loop inside it. If a real step number ever matters, a `record`-typed streaming port carrying its own `step` is the shape to read it from; the column is already there.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fastapi import (
|
|||||||
APIRouter,
|
APIRouter,
|
||||||
Depends,
|
Depends,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
|
Request,
|
||||||
WebSocket,
|
WebSocket,
|
||||||
WebSocketDisconnect,
|
WebSocketDisconnect,
|
||||||
)
|
)
|
||||||
@@ -27,6 +28,7 @@ from app.flow.dashboards import DashboardStore
|
|||||||
from app.flow.events import event_bus
|
from app.flow.events import event_bus
|
||||||
from app.flow.messages import qualify
|
from app.flow.messages import qualify
|
||||||
from app.flow.pipeline import ValidationIssue
|
from app.flow.pipeline import ValidationIssue
|
||||||
|
from app.flow.runs import RunRejected
|
||||||
from app.flow.schemas import (
|
from app.flow.schemas import (
|
||||||
NAME_PATTERN,
|
NAME_PATTERN,
|
||||||
BrainGraph,
|
BrainGraph,
|
||||||
@@ -616,15 +618,38 @@ def validate_flow(name: str, controller: FlowControllerDep) -> Any:
|
|||||||
async def run_flow(
|
async def run_flow(
|
||||||
name: str,
|
name: str,
|
||||||
body: RunRequest,
|
body: RunRequest,
|
||||||
|
request: Request,
|
||||||
controller: FlowControllerDep,
|
controller: FlowControllerDep,
|
||||||
|
user: CurrentUser,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Run every node of a flow once.
|
"""Run every node of a flow once.
|
||||||
|
|
||||||
With unpublished changes this runs the draft, so the button matches what is
|
With unpublished changes this runs the draft, so the button matches what is
|
||||||
on the canvas. Nothing is deployed by running it.
|
on the canvas. Nothing is deployed by running it.
|
||||||
|
|
||||||
|
A *batch* flow is submitted as a run instead, because that is what running
|
||||||
|
one means: it is what keeps the parameters, the series and the result, and
|
||||||
|
a button that quietly did something else would be a trap.
|
||||||
"""
|
"""
|
||||||
_read_flow(controller, name)
|
definition = _read_flow(controller, name)
|
||||||
_require_enabled(controller, name)
|
_require_enabled(controller, name)
|
||||||
|
if definition.mode == "batch":
|
||||||
|
service = getattr(request.app.state, "run_service", None)
|
||||||
|
if service is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Runs are not available")
|
||||||
|
try:
|
||||||
|
await run_in_threadpool(
|
||||||
|
service.submit,
|
||||||
|
name,
|
||||||
|
params=body.inputs,
|
||||||
|
cause="api",
|
||||||
|
actor=user.email,
|
||||||
|
draft=controller.store.has_draft(name),
|
||||||
|
)
|
||||||
|
except RunRejected as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
return _flow_state(controller, name)
|
||||||
|
|
||||||
inputs = {qualify(name, key): value for key, value in body.inputs.items()}
|
inputs = {qualify(name, key): value for key, value in body.inputs.items()}
|
||||||
if controller.store.has_draft(name):
|
if controller.store.has_draft(name):
|
||||||
await run_in_threadpool(controller.run_preview, name, inputs)
|
await run_in_threadpool(controller.run_preview, name, inputs)
|
||||||
|
|||||||
@@ -124,15 +124,35 @@ def batch_issues(flow: FlowDef) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def required_labels(flow: FlowDef) -> list[str]:
|
def required_labels(flow: FlowDef) -> list[str]:
|
||||||
"""Worker labels this flow's nodes ask for."""
|
"""Worker labels this flow cannot run without.
|
||||||
|
|
||||||
|
Only the nodes that *require* their device. One that merely prefers it runs
|
||||||
|
on the engine when no such worker is attached, so holding the whole run
|
||||||
|
back to wait for one would be waiting for something it does not need.
|
||||||
|
"""
|
||||||
return sorted(
|
return sorted(
|
||||||
{node.device for node in flow.nodes if node.device and node.device.strip()}
|
{
|
||||||
|
node.device
|
||||||
|
for node in flow.nodes
|
||||||
|
if node.device and node.device.strip() and node.device_policy == "require"
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def seed_values(flow: FlowDef, params: dict[str, Any]) -> dict[str, Any]:
|
def seed_values(
|
||||||
"""Turn a run's parameters into the messages the flow starts from."""
|
flow: FlowDef, params: dict[str, Any], seed: int | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Turn a run's parameters into the messages the flow starts from.
|
||||||
|
|
||||||
|
A run's ``seed`` fills an input of that name when the flow declares one, so
|
||||||
|
the field that distinguishes two runs of one configuration is the same
|
||||||
|
number the flow draws from — otherwise it would only look like it was, and
|
||||||
|
a sweep over seeds would run the same experiment N times. An explicit
|
||||||
|
parameter still wins, and a flow that declares no ``seed`` ignores it.
|
||||||
|
"""
|
||||||
specs = {declared.spec.name: declared.spec for declared in flow.inputs}
|
specs = {declared.spec.name: declared.spec for declared in flow.inputs}
|
||||||
|
if seed is not None and "seed" in specs and "seed" not in params:
|
||||||
|
params = {**params, "seed": seed}
|
||||||
values: dict[str, Any] = {}
|
values: dict[str, Any] = {}
|
||||||
for key, value in params.items():
|
for key, value in params.items():
|
||||||
spec = specs.get(key)
|
spec = specs.get(key)
|
||||||
@@ -335,7 +355,7 @@ class RunService:
|
|||||||
params = params or {}
|
params = params or {}
|
||||||
# Checked here rather than in the driver: a caller who mistyped a
|
# Checked here rather than in the driver: a caller who mistyped a
|
||||||
# parameter should be told now, not by a run that fails in a minute.
|
# parameter should be told now, not by a run that fails in a minute.
|
||||||
seed_values(flow, params)
|
seed_values(flow, params, seed)
|
||||||
|
|
||||||
run = Run(
|
run = Run(
|
||||||
id=new_run_id(),
|
id=new_run_id(),
|
||||||
@@ -580,7 +600,7 @@ class RunService:
|
|||||||
if run_id in self._cancelled:
|
if run_id in self._cancelled:
|
||||||
pipeline.pause(flow.name)
|
pipeline.pause(flow.name)
|
||||||
self._publish(run, "run_started")
|
self._publish(run, "run_started")
|
||||||
pipeline.run(seed_values(flow, run.params))
|
pipeline.run(seed_values(flow, run.params, run.seed))
|
||||||
result = collect_result(flow, state)
|
result = collect_result(flow, state)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
cancelled = run_id in self._cancelled
|
cancelled = run_id in self._cancelled
|
||||||
|
|||||||
@@ -308,3 +308,28 @@ def test_labels_come_from_the_nodes_that_ask_for_a_device():
|
|||||||
assert required_labels(flow) == []
|
assert required_labels(flow) == []
|
||||||
flow.nodes[0].device = "gpu"
|
flow.nodes[0].device = "gpu"
|
||||||
assert required_labels(flow) == ["gpu"]
|
assert required_labels(flow) == ["gpu"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_preferred_device_does_not_hold_a_run_back():
|
||||||
|
flow = double_flow()
|
||||||
|
flow.nodes[0].device = "gpu"
|
||||||
|
flow.nodes[0].device_policy = "prefer"
|
||||||
|
# It runs on the engine when no such worker is attached, so waiting for
|
||||||
|
# one would be waiting for something the run does not need.
|
||||||
|
assert required_labels(flow) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_runs_seed_fills_an_input_of_that_name():
|
||||||
|
flow = double_flow()
|
||||||
|
flow.inputs.append(FlowInput(spec=spec("seed", DType.INT), initial=0))
|
||||||
|
|
||||||
|
# Otherwise the field that tells two runs of one configuration apart would
|
||||||
|
# only look like the number the flow draws from.
|
||||||
|
assert seed_values(flow, {}, seed=7)["study.seed"] == 7
|
||||||
|
# An explicit parameter still wins.
|
||||||
|
assert seed_values(flow, {"seed": 3}, seed=7)["study.seed"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_flow_without_a_seed_input_ignores_the_runs_seed():
|
||||||
|
flow = double_flow()
|
||||||
|
assert seed_values(flow, {"lr": 1.0}, seed=7) == {"study.lr": 1.0}
|
||||||
|
|||||||
@@ -736,6 +736,10 @@ export class FlowsService {
|
|||||||
*
|
*
|
||||||
* With unpublished changes this runs the draft, so the button matches what is
|
* With unpublished changes this runs the draft, so the button matches what is
|
||||||
* on the canvas. Nothing is deployed by running it.
|
* on the canvas. Nothing is deployed by running it.
|
||||||
|
*
|
||||||
|
* A *batch* flow is submitted as a run instead, because that is what running
|
||||||
|
* one means: it is what keeps the parameters, the series and the result, and
|
||||||
|
* a button that quietly did something else would be a trap.
|
||||||
* @param data The data for the request.
|
* @param data The data for the request.
|
||||||
* @param data.name
|
* @param data.name
|
||||||
* @param data.requestBody
|
* @param data.requestBody
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Seed the ML-pipeline example: a batch flow, a dashboard, and a worker to try.
|
||||||
|
|
||||||
|
What it builds::
|
||||||
|
|
||||||
|
demo_training prepare -> train -> evaluate a batch flow, run on demand
|
||||||
|
demo_training a dashboard watching a run as it happens
|
||||||
|
|
||||||
|
The flow is a small supervised fit — recover ``y = 3x + 2`` from noisy samples
|
||||||
|
— chosen because it is a real training loop with none of the setup. What it is
|
||||||
|
actually demonstrating is the shape of an ML pipeline on this engine:
|
||||||
|
|
||||||
|
* **Batch runs.** ``mode: batch``, so nothing is activated and nothing fires
|
||||||
|
until a run asks. Its ``inputs`` are the run's parameters and its ``outputs``
|
||||||
|
are the run's result.
|
||||||
|
* **Streaming outputs.** ``train`` is a generator: every ``yield`` publishes on
|
||||||
|
the ``loss`` port the instant it happens, so the dashboard chart draws the
|
||||||
|
curve while the training is still running. There is no logging call — the
|
||||||
|
numbers are outputs, and the run keeps the whole series because ``loss``
|
||||||
|
declares ``stream: true``.
|
||||||
|
* **``fluksio.emit``** for where a yield cannot reach: ``progress`` is
|
||||||
|
published from inside a helper the loop calls, which is the shape a training
|
||||||
|
framework's callback has.
|
||||||
|
* **Artifacts.** The dataset and the trained weights are far too big to be
|
||||||
|
messages, so they travel as references into the content-addressed store and
|
||||||
|
are opened on the other side — including when the other side is a different
|
||||||
|
machine.
|
||||||
|
* **A GPU node beside CPU nodes.** ``train`` declares ``device: gpu``, so it
|
||||||
|
runs on an attached worker carrying that label. Its policy is ``prefer``, so
|
||||||
|
with no worker attached it runs on the engine instead — and it reports which
|
||||||
|
machine and which numeric backend it used, so the routing is visible in the
|
||||||
|
result rather than taken on trust.
|
||||||
|
* **Sweeps.** Runs of one flow are isolated from each other, so the same flow
|
||||||
|
can be swept over parameters and the runs compared.
|
||||||
|
|
||||||
|
Run it against a stack that is already up::
|
||||||
|
|
||||||
|
make -C app seed-demo
|
||||||
|
|
||||||
|
Then, from the dashboard or the API::
|
||||||
|
|
||||||
|
POST /api/v1/runs/flows/demo_training {"params": {"epochs": 40}}
|
||||||
|
POST /api/v1/runs/flows/demo_training/sweep {"runs": [...]}
|
||||||
|
|
||||||
|
Environment (the Makefile passes these):
|
||||||
|
API_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
API = os.environ.get("API_URL", "http://api.localhost")
|
||||||
|
EMAIL = os.environ.get("FIRST_SUPERUSER", "")
|
||||||
|
PASSWORD = os.environ.get("FIRST_SUPERUSER_PASSWORD", "")
|
||||||
|
|
||||||
|
FLOW = "demo_training"
|
||||||
|
PANEL = "demo_training"
|
||||||
|
|
||||||
|
PREPARE_SOURCE = '''"""Make the dataset, and hand it on as an artifact.
|
||||||
|
|
||||||
|
Runs on the engine: no device, so it goes to the local worker pool. Only the
|
||||||
|
standard library, because the engine's venv is its own and this node has no
|
||||||
|
business asking it for anything.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
|
||||||
|
import fluksio
|
||||||
|
|
||||||
|
#: The line the training is supposed to recover.
|
||||||
|
TRUE_SLOPE = 3.0
|
||||||
|
TRUE_INTERCEPT = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def process(seed, noise, samples, params):
|
||||||
|
rng = random.Random(int(seed))
|
||||||
|
rows = [
|
||||||
|
[x, TRUE_SLOPE * x + TRUE_INTERCEPT + rng.gauss(0.0, float(noise))]
|
||||||
|
for x in (rng.uniform(-1.0, 1.0) for _ in range(int(samples)))
|
||||||
|
]
|
||||||
|
payload = json.dumps(
|
||||||
|
{"rows": rows, "truth": {"slope": TRUE_SLOPE, "intercept": TRUE_INTERCEPT}}
|
||||||
|
).encode()
|
||||||
|
# Data between stages is an artifact, not a message: the reference is
|
||||||
|
# what travels, and it stays valid on whichever machine opens it.
|
||||||
|
return {
|
||||||
|
"dataset": fluksio.save_artifact(payload, "dataset.json", "application/json"),
|
||||||
|
"sample_count": len(rows),
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
|
TRAIN_SOURCE = '''"""Fit the dataset, publishing the loss as it goes.
|
||||||
|
|
||||||
|
A generator, so this node produces values over time: every ``yield`` is a dict
|
||||||
|
keyed by output port and is published the moment it happens, and what the
|
||||||
|
function returns at the end is the node's result. Nothing here logs anything —
|
||||||
|
the loss is an output of the graph, which is why a chart can bind to it.
|
||||||
|
|
||||||
|
Bound to ``device: gpu``. With a worker carrying that label attached it runs
|
||||||
|
there; without one it falls back to the engine, and ``trained_on`` says which
|
||||||
|
happened. numpy is used when the machine it landed on has it, which is how the
|
||||||
|
two environments tell themselves apart.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import platform
|
||||||
|
import time
|
||||||
|
|
||||||
|
import fluksio
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except ImportError: # The engine's own venv has no numpy; a GPU box will.
|
||||||
|
np = None
|
||||||
|
|
||||||
|
|
||||||
|
def process(dataset, learning_rate, epochs, pace, params):
|
||||||
|
with open(fluksio.load_artifact(dataset)) as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
|
||||||
|
xs = [row[0] for row in data["rows"]]
|
||||||
|
ys = [row[1] for row in data["rows"]]
|
||||||
|
count = len(xs)
|
||||||
|
total = int(epochs)
|
||||||
|
rate = float(learning_rate)
|
||||||
|
slope, intercept = 0.0, 0.0
|
||||||
|
|
||||||
|
if np is not None:
|
||||||
|
axis_x, axis_y = np.array(xs), np.array(ys)
|
||||||
|
|
||||||
|
def report(epoch):
|
||||||
|
# Published from inside a helper, where a yield cannot reach — the
|
||||||
|
# shape a training framework's callback has. Same port, same checking.
|
||||||
|
fluksio.emit(progress=round(100.0 * (epoch + 1) / total, 1))
|
||||||
|
|
||||||
|
loss = 0.0
|
||||||
|
for epoch in range(total):
|
||||||
|
if np is not None:
|
||||||
|
error = (slope * axis_x + intercept) - axis_y
|
||||||
|
loss = float((error**2).mean())
|
||||||
|
slope -= rate * float((2 * error * axis_x).mean())
|
||||||
|
intercept -= rate * float((2 * error).mean())
|
||||||
|
else:
|
||||||
|
error = [slope * x + intercept - y for x, y in zip(xs, ys)]
|
||||||
|
loss = sum(e * e for e in error) / count
|
||||||
|
slope -= rate * sum(2 * e * x for e, x in zip(error, xs)) / count
|
||||||
|
intercept -= rate * sum(2 * e for e in error) / count
|
||||||
|
|
||||||
|
yield {"loss": round(loss, 6)}
|
||||||
|
report(epoch)
|
||||||
|
# Only so the curve is watchable; a real epoch takes as long as it takes.
|
||||||
|
if float(pace) > 0:
|
||||||
|
time.sleep(float(pace))
|
||||||
|
|
||||||
|
weights = json.dumps({"slope": slope, "intercept": intercept}).encode()
|
||||||
|
return {
|
||||||
|
"weights": fluksio.save_artifact(weights, "weights.json", "application/json"),
|
||||||
|
"final_loss": round(loss, 6),
|
||||||
|
"trained_on": "{} ({})".format(
|
||||||
|
platform.node(),
|
||||||
|
"numpy " + np.__version__ if np is not None else "pure python",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
|
EVALUATE_SOURCE = '''"""Score the fit, back on the engine.
|
||||||
|
|
||||||
|
Opens two artifacts: the dataset this run prepared and the weights the
|
||||||
|
training produced — which may have been written on another machine entirely.
|
||||||
|
A reference names content, so where it came from does not matter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import fluksio
|
||||||
|
|
||||||
|
|
||||||
|
def process(weights, dataset, params):
|
||||||
|
with open(fluksio.load_artifact(weights)) as handle:
|
||||||
|
fit = json.load(handle)
|
||||||
|
with open(fluksio.load_artifact(dataset)) as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
|
||||||
|
rows = data["rows"]
|
||||||
|
truth = data["truth"]
|
||||||
|
mean = sum(y for _x, y in rows) / len(rows)
|
||||||
|
total = sum((y - mean) ** 2 for _x, y in rows)
|
||||||
|
residual = sum((y - (fit["slope"] * x + fit["intercept"])) ** 2 for x, y in rows)
|
||||||
|
r2 = 1.0 - residual / total if total else 0.0
|
||||||
|
|
||||||
|
good = r2 > 0.9
|
||||||
|
return {
|
||||||
|
"accuracy": round(100.0 * max(0.0, r2), 2),
|
||||||
|
"report": {
|
||||||
|
"title": "Recovered {:.2f}x + {:.2f}".format(
|
||||||
|
fit["slope"], fit["intercept"]
|
||||||
|
),
|
||||||
|
"body": "Truth is {}x + {}. R² = {:.4f} over {} samples.".format(
|
||||||
|
truth["slope"], truth["intercept"], r2, len(rows)
|
||||||
|
),
|
||||||
|
"severity": "info" if good else "warning",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
|
NODES = [
|
||||||
|
{
|
||||||
|
"id": "prepare",
|
||||||
|
"type": "python",
|
||||||
|
"title": "Make the dataset",
|
||||||
|
"requires": [
|
||||||
|
{"name": "seed", "dtype": "int"},
|
||||||
|
{"name": "noise", "dtype": "float"},
|
||||||
|
{"name": "samples", "dtype": "int"},
|
||||||
|
],
|
||||||
|
"provides": [
|
||||||
|
{"name": "dataset", "dtype": "artifact"},
|
||||||
|
{"name": "sample_count", "dtype": "int"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "train",
|
||||||
|
"type": "python",
|
||||||
|
"title": "Fit it",
|
||||||
|
# Runs on a worker carrying this label; falls back here without one,
|
||||||
|
# which is what makes the example work before a GPU box is attached.
|
||||||
|
"device": "gpu",
|
||||||
|
"device_policy": "prefer",
|
||||||
|
# Generous, and an *idle* timeout: a node that keeps publishing keeps
|
||||||
|
# its deadline reset, so this is how long it may go quiet.
|
||||||
|
"timeout": 300,
|
||||||
|
"requires": [
|
||||||
|
{"name": "dataset", "dtype": "artifact"},
|
||||||
|
{"name": "learning_rate", "dtype": "float"},
|
||||||
|
{"name": "epochs", "dtype": "int"},
|
||||||
|
{"name": "pace", "dtype": "float"},
|
||||||
|
],
|
||||||
|
"provides": [
|
||||||
|
# The curve. `stream` says this port publishes repeatedly while the
|
||||||
|
# node runs, and is what makes a run keep every value it takes.
|
||||||
|
{"name": "loss", "dtype": "float", "stream": True},
|
||||||
|
{"name": "progress", "dtype": "float", "stream": True},
|
||||||
|
{"name": "weights", "dtype": "artifact"},
|
||||||
|
{"name": "final_loss", "dtype": "float"},
|
||||||
|
{"name": "trained_on", "dtype": "str"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evaluate",
|
||||||
|
"type": "python",
|
||||||
|
"title": "Score it",
|
||||||
|
"requires": [
|
||||||
|
{"name": "weights", "dtype": "artifact"},
|
||||||
|
{"name": "dataset", "dtype": "artifact"},
|
||||||
|
],
|
||||||
|
"provides": [
|
||||||
|
{"name": "report", "dtype": "record"},
|
||||||
|
{"name": "accuracy", "dtype": "float"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
#: The run's parameters, with the values a run gets when it names none.
|
||||||
|
INPUTS = [
|
||||||
|
{"spec": {"name": "seed", "dtype": "int"}, "initial": 1},
|
||||||
|
{"spec": {"name": "noise", "dtype": "float"}, "initial": 0.25},
|
||||||
|
{"spec": {"name": "samples", "dtype": "int"}, "initial": 200},
|
||||||
|
{"spec": {"name": "learning_rate", "dtype": "float"}, "initial": 0.2},
|
||||||
|
{"spec": {"name": "epochs", "dtype": "int"}, "initial": 40},
|
||||||
|
# Only so a human can watch the curve arrive; set it to 0 for a sweep.
|
||||||
|
{"spec": {"name": "pace", "dtype": "float"}, "initial": 0.15},
|
||||||
|
]
|
||||||
|
|
||||||
|
#: What a run reports as its result. Everything else the flow computed stays
|
||||||
|
#: in the run's own state and is dropped with it.
|
||||||
|
OUTPUTS = ["report", "accuracy", "final_loss", "trained_on", "sample_count"]
|
||||||
|
|
||||||
|
WIDGETS = [
|
||||||
|
{
|
||||||
|
"id": "curve",
|
||||||
|
"type": "chart",
|
||||||
|
"title": "Training loss",
|
||||||
|
"layout": {"lg": {"x": 0, "y": 0, "w": 8, "h": 5}},
|
||||||
|
"config": {
|
||||||
|
# Bound to the port the node yields on — the same binding a
|
||||||
|
# temperature would use. Nothing here knows what a metric is.
|
||||||
|
"series": [{"message": f"{FLOW}.loss", "dtype": "float", "label": "loss"}],
|
||||||
|
"history": {"points": 600},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "how",
|
||||||
|
"type": "markdown",
|
||||||
|
"title": "",
|
||||||
|
"layout": {"lg": {"x": 8, "y": 0, "w": 4, "h": 5}},
|
||||||
|
"config": {
|
||||||
|
"content": (
|
||||||
|
"## Try it\n"
|
||||||
|
"- Press Run on the flow, or POST /api/v1/runs/flows/demo_training\n"
|
||||||
|
"- The curve fills in while the training runs. It is a port"
|
||||||
|
" the node yields on, not a log.\n"
|
||||||
|
"- Sweep it: POST .../sweep with a list of params. Runs are"
|
||||||
|
" isolated, so they go in parallel.\n"
|
||||||
|
"- Attach a worker labelled gpu and 'Trained on' changes"
|
||||||
|
" machine.\n"
|
||||||
|
"- The finished series lives on the run:"
|
||||||
|
" GET /api/v1/runs/{id}/metrics?name=demo_training.loss"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "progress",
|
||||||
|
"type": "gauge",
|
||||||
|
"title": "Progress",
|
||||||
|
"layout": {"lg": {"x": 0, "y": 5, "w": 3, "h": 3}},
|
||||||
|
"config": {
|
||||||
|
"message": f"{FLOW}.progress",
|
||||||
|
"dtype": "float",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"unit": "%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "accuracy",
|
||||||
|
"type": "gauge",
|
||||||
|
"title": "Fit quality (R²)",
|
||||||
|
"layout": {"lg": {"x": 3, "y": 5, "w": 3, "h": 3}},
|
||||||
|
"config": {
|
||||||
|
"message": f"{FLOW}.accuracy",
|
||||||
|
"dtype": "float",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"unit": "%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "loss_now",
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Final loss",
|
||||||
|
"layout": {"lg": {"x": 6, "y": 5, "w": 3, "h": 3}},
|
||||||
|
"config": {"message": f"{FLOW}.final_loss", "dtype": "float", "precision": 4},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "samples",
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Samples",
|
||||||
|
"layout": {"lg": {"x": 9, "y": 5, "w": 3, "h": 3}},
|
||||||
|
"config": {"message": f"{FLOW}.sample_count", "dtype": "int"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "where",
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Trained on",
|
||||||
|
"layout": {"lg": {"x": 0, "y": 8, "w": 5, "h": 3}},
|
||||||
|
"config": {"message": f"{FLOW}.trained_on", "dtype": "str"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "verdict",
|
||||||
|
"type": "notification",
|
||||||
|
"title": "Result",
|
||||||
|
"layout": {"lg": {"x": 5, "y": 8, "w": 7, "h": 3}},
|
||||||
|
"config": {"message": f"{FLOW}.report", "dtype": "record"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Api:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.http = httpx.Client(base_url=f"{API}/api/v1", timeout=60)
|
||||||
|
token = self.http.post(
|
||||||
|
"/login/access-token",
|
||||||
|
data={"username": EMAIL, "password": PASSWORD},
|
||||||
|
).json()["access_token"]
|
||||||
|
self.http.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
def __call__(self, method: str, path: str, body: Any = None) -> Any:
|
||||||
|
response = self.http.request(method, path, json=body)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json() if response.content else None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not EMAIL or not PASSWORD:
|
||||||
|
print("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
api = Api()
|
||||||
|
|
||||||
|
try:
|
||||||
|
api("DELETE", f"/flows/{FLOW}")
|
||||||
|
except httpx.HTTPStatusError:
|
||||||
|
pass
|
||||||
|
api(
|
||||||
|
"PUT",
|
||||||
|
f"/flows/{FLOW}",
|
||||||
|
{
|
||||||
|
"name": FLOW,
|
||||||
|
"title": "Training run (example)",
|
||||||
|
"mode": "batch",
|
||||||
|
"outputs": OUTPUTS,
|
||||||
|
"nodes": NODES,
|
||||||
|
"inputs": INPUTS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for node_id, code in (
|
||||||
|
("prepare", PREPARE_SOURCE),
|
||||||
|
("train", TRAIN_SOURCE),
|
||||||
|
("evaluate", EVALUATE_SOURCE),
|
||||||
|
):
|
||||||
|
api("PUT", f"/flows/{FLOW}/nodes/{node_id}/source", {"code": code})
|
||||||
|
version = api("GET", f"/flows/{FLOW}?draft=true")["definition"]["version"]
|
||||||
|
published = api("POST", f"/flows/{FLOW}/publish", {"version": version})
|
||||||
|
print(f" {FLOW}: {len(NODES)} nodes, published")
|
||||||
|
for issue in published.get("issues") or []:
|
||||||
|
print(f" ! {issue['message']}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
api("DELETE", f"/dashboards/{PANEL}")
|
||||||
|
except httpx.HTTPStatusError:
|
||||||
|
pass
|
||||||
|
api("POST", f"/dashboards/{PANEL}", {"name": PANEL, "title": "Training"})
|
||||||
|
current = api("GET", f"/dashboards/{PANEL}")
|
||||||
|
api(
|
||||||
|
"PUT",
|
||||||
|
f"/dashboards/{PANEL}",
|
||||||
|
{
|
||||||
|
**current,
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"id": "main",
|
||||||
|
"title": "Overview",
|
||||||
|
"sections": [{"id": "main", "widgets": WIDGETS}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
version = api("GET", f"/dashboards/{PANEL}?draft=true")["version"]
|
||||||
|
api("POST", f"/dashboards/{PANEL}/publish", {"version": version})
|
||||||
|
print(f" dashboard '{PANEL}': published")
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\nOpen /view/{PANEL}, then start a run:\n"
|
||||||
|
f' POST {API}/api/v1/runs/flows/{FLOW} {{"params": {{}}}}\n'
|
||||||
|
"The curve fills in as it trains. For a sweep, set pace to 0."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user