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 |
Raw points
Section titled “Raw points”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 seriesframe["loss"].unstack("run") # one column per run — plot-readyNarrow 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 tailclient.fetch_float_series("run-001", "loss", steps=FirstSteps(100)) # the warm-upinclude_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.
Buckets, for charts
Section titled “Buckets, for charts”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 bucketframe["loss"]["count"] # points that went into itThe bucket geometry travels with the frame:
frame.attrs["x_mode"], frame.attrs["buckets_count"], frame.attrs["bucket_width"]Non-finite values in buckets
Section titled “Non-finite values in buckets”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.
The x axis
Section titled “The x axis”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, timezonefrom 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.
Selecting series precisely
Section titled “Selecting series precisely”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 scaleA 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") # equalitySeriesQuery("loss").with_label_matches("worker", "^w1") # regexSeriesQuery("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 valueHistogram series can be read as floats by reducing each point — see Histograms.
The 1000-run limit
Section titled “The 1000-run limit”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.