Skip to content

Float series

Two methods read float series. They differ in what you get per step, and you pick by what you are doing:

Method Gives you Use for
fetch_float_series every point analysis, exact values, further computation
fetch_float_series_buckets a fixed number of buckets, with min/max/count plotting, long runs, many runs at once
frame = client.fetch_float_series("run-001", ["loss", "accuracy"])

The result is wide: index (run, step), one column per matched series, NaN where a series has no point at that step. Series that are logged at different cadences therefore line up on one index without you aligning them.

frame.loc["run-001", "loss"] # one run, one series
frame["loss"].unstack("run") # one column per run — plot-ready

Narrow the step axis rather than fetching everything and slicing:

from metrana.query import StepRange, FirstSteps, LastSteps
client.fetch_float_series("run-001", "loss", steps=StepRange(1000, 2000))
client.fetch_float_series("run-001", "loss", steps=LastSteps(500)) # the tail
client.fetch_float_series("run-001", "loss", steps=FirstSteps(100)) # the warm-up

include_timestamps=True adds a per-series timestamp column level (datetime64[ms], UTC, NaT where the point carries none). include_inherited=True includes points inherited from a forking parent.

A run with 10 million points has more data than a chart has pixels, and pulling it all to throw most of it away is waste at both ends. fetch_float_series_buckets compresses server-side to a fixed number of buckets:

frame = client.fetch_float_series_buckets(run_names, "loss", buckets_count=500)

The columns are a (series, quantity) MultiIndex — value, min, max, count — so you can draw a line with a band around it and show honestly what the compression hid:

frame["loss"]["value"]
frame["loss"]["min"], frame["loss"]["max"] # the extremes inside each bucket
frame["loss"]["count"] # points that went into it

The bucket geometry travels with the frame:

frame.attrs["x_mode"], frame.attrs["buckets_count"], frame.attrs["bucket_width"]

NaN/±inf points are stored verbatim and come back as-is from the raw fetch_float_series. Buckets, though, aggregate finite values only: value/min/max/count never include them, and a bucket whose points are all non-finite has no row at all. Which buckets carry non-finite points travels with the frame:

frame.attrs["non_finite_buckets"]
# {(run, series): {"nan": [x, ...], "inf": [...], "neg_inf": [...]}}

The listed values are positions on the frame’s x axis, so a divergence can be marked on a chart without it wrecking the line.

x_mode chooses what the buckets are cut along:

XAxisMode Index Bounds via
STEP (default) step StepRange / FirstSteps / LastSteps
ABSOLUTE_TIME wall-clock timestamp TimeRange
RELATIVE_TIME elapsed time since the run started TimeRange
from datetime import datetime, timedelta, timezone
from metrana.query import XAxisMode, TimeRange
# The first six hours of each run, however far apart they actually ran.
client.fetch_float_series_buckets(
runs, "loss", x_mode=XAxisMode.RELATIVE_TIME, buckets_count=300,
x_range=TimeRange(to_time=timedelta(hours=6)),
)
# A wall-clock window.
client.fetch_float_series_buckets(
runs, "loss", x_mode=XAxisMode.ABSOLUTE_TIME,
x_range=TimeRange(from_time=datetime(2026, 7, 1, tzinfo=timezone.utc)),
)

TimeRange takes the natural spelling for each frame: a datetime under ABSOLUTE_TIME, a timedelta (time since the run started) under RELATIVE_TIME, or raw milliseconds for either. Naive datetimes are read as UTC.

RELATIVE_TIME is what makes runs of different wall-clock start times comparable on one chart.

A bare metric name means “this metric, at the default scale, whatever its labels”. A SeriesQuery says more:

from metrana.query import SeriesQuery
SeriesQuery("reward").with_scale("EPISODE") # a non-default step scale

A selector that names no scale means ML_STEP, which is what metrana.log(...) writes. RL series are written at ENVIRONMENT_STEP or EPISODE, so reading those requires an explicit with_scale — see RL series.

If your series were logged with labels, the same selector narrows on them (not using labels? skip to the next section):

SeriesQuery("loss").with_label("worker", "w0") # equality
SeriesQuery("loss").with_label_matches("worker", "^w1") # regex
SeriesQuery("loss").with_label_in("worker", ["w0", "w1"])
SeriesQuery("loss").with_label_ne("worker", "w9")

Labels may be partial: unmentioned label keys are unconstrained, so one query can match several series, each becoming its own column.

To collapse them instead, group_by reduces at query time:

from metrana.query import GroupByReducer
SeriesQuery("loss").group_by(["worker"], GroupByReducer.AVG) # one column per worker value

Histogram series can be read as floats by reducing each point — see Histograms.

Every series fetch names its runs in the request, and reads at most 1000 of them. That is the query service’s own ceiling, and going over is an error, not a truncation — a chart quietly missing runs is worse than one that refuses to draw.

Three ways to stay inside it:

# 1. Name them.
client.fetch_float_series(["run-001", "run-002"], "loss")
# 2. Filter them — one ListRuns round trip resolves the names first.
client.fetch_float_series(AttrFilter("config/lr") < 0.01, "loss")
# 3. Filter, order, and cap — "the best 100 by accuracy".
from metrana.query import RunsSelection
client.fetch_float_series(
RunsSelection(filter=AttrFilter("sys/status") == "CLOSED",
order_by=SeriesOrderBy("accuracy", "max").desc()),
"accuracy",
max_runs=100,
)

max_runs applies in order_by order, so it is a deliberate top-N, not an arbitrary slice.