Float series
Three 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 |
aggregate_run_series |
buckets of a reduction across the runs of a group | comparing groups of runs as single lines |
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.
Aggregated across runs
Section titled “Aggregated across runs”Comparing two configurations with twenty seeds each is not forty lines; it is two lines with a band.
aggregate_run_series reduces the runs of each group at every step, server-side, then buckets the result
like fetch_float_series_buckets does:
from metrana.query import RunAggregation
frame = client.aggregate_run_series( {"baseline": baseline_runs, "candidate": candidate_runs}, "loss", aggregations=[RunAggregation.AVG, RunAggregation.STD], buckets_count=500,)Groups are named, and each one takes what a run selector takes elsewhere: a run name, a list of names, a
runs Condition, or a RunsSelection. A run may sit in several groups. At each step the values of the
group’s runs are reduced by every requested aggregation (AVG, MIN, MAX, SUM, STD, MEDIAN); the
per-step results then fill buckets on one shared step axis.
The index is (group, step), and the columns are a (series, quantity) MultiIndex with one quantity set per
aggregation plus the merged-step count:
frame["loss"]["avg"] # bucket mean of the per-step averagesframe["loss"]["avg_min"], frame["loss"]["avg_max"] # the extremes inside each bucketframe["loss"]["std"] # sample std across the group's runs, per step, averagedframe["loss"]["count"] # steps merged into the bucketSTD is the sample standard deviation across the runs that have a value at the step, so a group with one run
reads 0 there. Only float series take part: histogram reductions and group_by are rejected, and x_range is a
step selection (StepRange, FirstSteps, LastSteps) shared by every run of every group. The bucket geometry
and non-finite markers travel in frame.attrs exactly as for fetch_float_series_buckets, keyed by
(group, series).
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.