Run population-based training¶
Goal: let good configurations copy themselves mid-run, instead of running every configuration to completion in isolation.
Ordinary tuning treats a trial as atomic: pick a configuration, run it, score it, pick the next. Population-based training breaks that. Trials run concurrently as a population; periodically each is ranked against the others, and an underperformer exploits a winner — copying its hyperparameters and its checkpoint — then explores by perturbing what it copied.
The consequence worth understanding before you use it: the answer PBT gives you is usually a fork of a fork, and the hyperparameters that produced it were never a single fixed configuration. They are a schedule, discovered while training.
Attach the scheduler¶
let study = Study::builder()
.parallelism(THREADS)
.budget(Budget::new_with_trials(TRIALS))
// Rank every INTERVAL units; Jaderberg defaults for the rest (the bottom
// 20% exploit a random member of the top 20%, perturbing by 0.8 / 1.2).
.scheduler(Arc::new(Pbt::try_new(u64::from(INTERVAL))?))
.create(StudyConfig::new(STUDY).with_seed(SEED))?;
study.optimize(train)?;
// The genealogy, rebuilt from storage: who forked from whom, and with what
// perturbation. PBT persists no scheduler state — the tree *is* the record.
let lineage = study.lineage()?;
study = atune.create_study(
direction="minimize",
# Rank every INTERVAL units; Jaderberg defaults for the rest (the bottom
# 20% exploit a random member of the top 20%, perturbing by 0.8 / 1.2).
scheduler=atune.schedulers.Pbt(INTERVAL),
seed=SEED,
name=STUDY,
)
study.optimize(objective, n_trials=TRIALS, n_jobs=JOBS)
# The genealogy, rebuilt from storage: who forked from whom, and with what
# perturbation. PBT persists no scheduler state — the tree *is* the record.
lineage = study.lineage()
Pbt::try_new(interval)? ranks the population every interval resource units.
The rest are Jaderberg's defaults: the bottom fifth exploits a random member of
the top fifth, and perturbation multiplies a copied scalar by 0.8 or 1.2.
Nothing else about the study changes. PBT is a Scheduler, so it attaches exactly
where a pruner would — the difference is what it may return. A pruner can say
stop; PBT can also say fork, and the study loop then materialises a new trial
whose parameters and checkpoint come from another one.
The objective has to be resumable¶
This is the requirement that makes PBT different to configure. The scheduler forks with or without a checkpoint, so nothing enforces it. Exploit copies a trial's state, not just its numbers. If your objective cannot start from another trial's checkpoint, exploiting a winner gives you its learning rate and none of its progress: the child starts cold and spends its budget relearning what the winner already knew.
So an objective under PBT does three things a plain objective does not:
/// The loss this learning rate converges to — what it is *capable* of.
///
/// A quadratic bowl around [`LR_STAR`]: exactly [`FLOOR_MIN`] at the optimum and
/// worse either side of it. Pure arithmetic, in the same order as the Python arm,
/// so both arms compute the same bits.
fn floor_for(lr: f64) -> f64 {
let off = lr - LR_STAR;
FLOOR_MIN + FLOOR_SLOPE * (off * off)
}
/// Trains one segment, warm-starting from the parent's checkpoint if there is one.
fn train(ctx: &mut TrialCtx<'_>) -> Result<Outcome> {
let lr = ctx.suggest_f64("lr", LR_LOW..=LR_HIGH, Scale::Linear)?;
// The exploit half of PBT: a fork child inherits its parent's checkpoint
// reference. atune stored the string, not the state — here the state is one
// number, so the string is `{loss:?}` and `parse` recovers its exact bits.
// Parsed into an owned `f64` immediately: the borrow of the context ends
// here, before the loop needs it mutably again.
let mut loss = match ctx.parent_checkpoint() {
None => INITIAL_LOSS,
Some(reference) => reference.parse::<f64>().map_err(|error| {
Error::Objective(
format!("checkpoint reference {reference:?} is not a loss: {error}").into(),
)
})?,
};
let floor = floor_for(lr);
for step in 1..=STEPS {
loss = floor + (loss - floor) * DECAY;
if step % INTERVAL == 0 {
// Checkpoint *before* reporting: `report` is the scheduler's decision
// point, and a fork decided there copies the reference recorded here.
ctx.record_checkpoint(u64::from(step), format!("{loss:?}"))?;
ctx.report(u64::from(step), &[loss])?;
}
}
Ok(loss.into())
}
def floor_for(lr: float) -> float:
"""The loss this learning rate converges to — what it is *capable* of.
A quadratic bowl around ``LR_STAR``: exactly ``FLOOR_MIN`` at the optimum and
worse either side of it. Pure arithmetic, in the same order as the Rust arm,
so both arms compute the same bits.
"""
off = lr - LR_STAR
return FLOOR_MIN + FLOOR_SLOPE * (off * off)
def objective(trial: atune.Trial) -> float:
"""Trains one segment, warm-starting from the parent's checkpoint if any."""
lr = trial.suggest_float("lr", LR_LOW, LR_HIGH)
# The exploit half of PBT: a fork child inherits its parent's checkpoint
# reference. atune stored the string, not the state — here the state is one
# number, so the string is `repr(loss)` and `float` recovers its exact bits.
reference = trial.parent_checkpoint
loss = INITIAL_LOSS if reference is None else float(reference)
floor = floor_for(lr)
for step in range(1, STEPS + 1):
loss = floor + (loss - floor) * DECAY
if step % INTERVAL == 0:
# Checkpoint *before* reporting: `report` is the scheduler's decision
# point, and a fork decided there copies the reference recorded here.
trial.record_checkpoint(step, repr(loss))
trial.report(loss, step)
return loss
It looks for an inherited checkpoint and warm-starts from it; it reports at intervals, so the scheduler has something to rank; and it writes a checkpoint the next generation can inherit. In a real trainer that reference is a path to model weights. In the example it is deliberately small enough to read, so the mechanism is visible rather than buried in a framework.
Read the genealogy¶
A flat list of trials is the wrong shape for a PBT study — it hides the only
structure that matters. study.lineage() rebuilds the tree from storage: who
forked or restarted from whom, at which checkpoint step, and — on the Rust
side — with which perturbation; the Python view carries each node's parent,
fork and restart flags, restart mode, fork step and value.
When a fork or restart child starts, the study loop writes its provenance — the
donor, the step of the checkpoint it starts from, and any restart mode and
seed — as an atune.lineage record beside the trial, in the same write that
makes it running. Trials written before that record existed are still
rebuilt from their parent links, as plain forks.
PBT persists no scheduler state of its own. The tree is the record, which is also why a PBT study resumes correctly: reopen the storage and the population's history is all there.
Export the schedule that produced a trial¶
study.recipe(leaf) turns one trial's ancestry into a replayable schedule:
the segments from the start of its training to the leaf, each with the trial
that trained it, the step range it covered, its full configuration, and the
fork or restart that began it. The walk stops at a trial that inherited no
checkpoint — a root, a fresh restart, or a child whose donor had not
checkpointed yet — because nothing before it reached the leaf's weights.
Python's study.recipe(trial_number) returns the same schedule as a dict, and
the CLI writes it as JSON:
$ atune recipe --study pbt.atj --trial 17 --out recipe.json
The JSON is versioned ("schema": "atune.recipe", "version": 1) and its
bytes are deterministic for one study state. A recipe is built from the
lineage records only, never guessed from reports: a fork on the path that has
no atune.lineage record, or a donor missing from the study, is an error that
names the trial.
Make the population actually pay¶
Two ways a PBT study can run, report success, and demonstrate nothing.
The segment must be short enough that a warm start still matters. If every trial reaches its floor before the first ranking, exploiting a winner inherits a finished job, and a fork becomes indistinguishable from a fresh trial with the same parameters. Measured on the example above: at eight steps with decay 0.6, about 1.7% of the initial gap survives to the ranking — which is exactly what makes a fork measurably better than a cold trial that drew the same learning rate. Longer segments erase the effect, not because PBT stopped working but because there was nothing left to inherit.
The population must be large enough to have a top and a bottom fifth. The
population at a decision is every trial that has reported at that step and is
still rankable — running and paused trials and finished ones alike — so it
accumulates over the study rather than being capped by parallelism (the
example runs single-threaded and still forks). What matters is that enough
trials report at the same aligned steps, because both quantiles count whole
trials, rounded down. Of n trials at a step, the reporter included, the worst
floor(0.2 × n) may exploit and the best floor(0.2 × n) may be copied, and
while either count is zero every trial continues, so a baseline forms before
anything is copied. With the default fractions a step therefore needs five
trials before any of them can exploit; four or fewer never do. A smaller
fraction, set with Pbt::with_bottom_fraction or with_top_fraction
(Python's bottom_fraction and top_fraction), needs a larger population: a
nonzero fraction f names nobody until at least 1/f trials have reported at
the step, and a zero fraction names nobody at all.
A pruned trial stays in the population. Pruning ends a trial's training,
not the reports it already made, and those are real measurements. A trial
pruned after it reported at a step — because its own objective stopped it,
say — still ranks there, and if it is among the best it can be the donor whose
parameters and checkpoint an exploit copies. Pb2 can use a pruned child's
latest report as model data under the same conditions as a completed child's.
Only failed trials, and waiting ones, which have not reported, are left out of
the population.
Declare where trials end. Without a horizon PBT cannot tell a trial's final
report from an interior one, so a worst-ranked final report forks a child with
nothing left to train. Pbt::with_horizon(final_step) makes a report at or past
that step terminal: it is recorded and ranked by the members that report there,
but never forks. Python passes Pbt(interval, horizon=final_step).
Ties never count against a trial by default. A population that reports one
shared value — a sparse reward still at zero — has no underperformer, so it does
not exploit. Pbt::with_tie_break(TieBreak::Seeded) places each reporter at a
reproducible random position inside its tie group, so a tied population exploits
its bottom fifth in expectation. Python passes tie_break="seeded". Pb2 takes
the same with_horizon and with_tie_break.
Restart instead of fork¶
An exploit normally forks: the child loads the donor's checkpoint and keeps
training. On problems where a population collapses onto one lineage, or where
inherited weights stop being plastic, it can be better to keep the donor's
configuration and start the weights again. Pbt::with_restart(mode) makes
every exploit a restart; the choice of who exploits whom, and the explore
mutation, are drawn exactly as for a fork, so only what the child does with
the donor's state changes. Pb2 takes the same setting. Both fork by default.
RestartMode::Fresh— the child gets the donor's mutated configuration and no checkpoint, so it re-initializes from scratch. It has no parent link; its lineage record is what names the donor.RestartMode::try_new_with_shrink_perturb(shrink, noise)?— the child loads the donor's checkpoint like a fork, then shrinks the weights byshrink(in(0, 1]) and adds noise of scalenoise(finite, non-negative).
atune never touches weights, so the objective applies the restart. In Rust,
ctx.restart() returns the mode and a reproducible restart seed; a subprocess
reads ATUNE_RESTART_MODE, ATUNE_RESTART_SEED, and, for shrink-and-perturb,
ATUNE_RESTART_SHRINK and ATUNE_RESTART_NOISE (see the
environment reference). Plain forks and
ordinary trials see none of these. Python passes
Pbt(interval, restart="fresh") or
Pbt(interval, restart="shrink_perturb", shrink=0.5, noise=0.01), and the
child reads trial.restart: a dict with the mode, the seed, and for
shrink-and-perturb the shrink and noise. Leaving restart unset forks, and
shrink or noise without restart="shrink_perturb" raises.
Evaluate what the population produced¶
A PBT trial's tuning value scores a configuration schedule that ran under the tuning seeds, but what you take away is either the trained weights or the schedule itself. Three final-stage estimands rank the same top k trials by tuning value, evaluate each under the study's test seeds, and return the same report with its tune, test and gap columns:
study.reevaluate(objective, k)— the configuration: train afresh with the trial's own parameters, as for any study.study.evaluate_checkpoints(objective, k)— the artifact: evaluate the trial's latest checkpoint without training.ctx.evaluation()isEvaluation::Checkpointandctx.parent_checkpoint()is that checkpoint; a trial that never checkpointed scores NaN.study.replay_recipes(objective, k)— the schedule: train afresh following the trial's recipe.ctx.evaluation()isEvaluation::Recipeandctx.recipe()is the schedule; the sampled parameters are its first segment's, andrecipe.params_at(step)gives the configuration to switch to at each later segment. A trial whose recipe cannot be built fails the call before anything runs.
Python reads the same through trial.evaluation ("checkpoint", "recipe",
or None) and trial.recipe (the recipe dict); a subprocess reads
ATUNE_EVALUATE, plus ATUNE_CHECKPOINT or ATUNE_RECIPE (the recipe as
single-line JSON). Each estimand stores its replicates in an atune.estimate
record beside the trial and reuses them only while the test seeds and the
evaluated checkpoint or recipe are unchanged. The trial's value and its
reevaluate test replicates are never touched, so the three answers sit side
by side.
Account for the resource¶
study.resource_ledger() reports, per trial and in the study's resource unit,
what it trained (the amount the fidelity budget charged it), the
lineage resource its weights represent (where its recipe ends), its age
since initialization or the last shrink-and-perturb restart, and the number of
replicates each stored evaluation stage holds. The ledger's total is the
study's fidelity charge. Compare estimands and methods at equal trained
resource, not equal lineage: a fork's lineage counts the prefix it inherited.
The same charge is a budget. Declare what a reported step counts when the
study is created — ResourceUnit::Steps (environment steps, the default),
Episodes, Walltime, or a custom unit through StudyConfig::with_resource_unit
in Rust, create_study(resource_unit="steps") in Python — and cap a run with
Budget::with_fidelity_units(n) in Rust or study.optimize(objective,
max_resource=n) in Python. The Rust limit is on the study's total charge; the
Python amount is added to what the study had already spent when the call began,
as n_trials is. Both are cooperative: no trial starts once the charge reaches
the limit, and the trial that crossed it finishes. An objective must report
its steps to be charged at all.
PB2, and when to reach for it¶
Pb2 replaces PBT's random perturbation with one chosen by a time-varying
GP-bandit: instead of multiplying by 0.8 or 1.2, it proposes the perturbation its
model expects to help most given what the population has done so far. Each
decision fits that model to at most the newest 512 improvement rows by report
step, the window GP-EI and CARBS use, so the fit's cost stops growing in a long
study; gathering the rows still reads every trial.
It is behind the pb2 feature, which implies gp — see the
feature reference.
Reach for it when perturbation is the bottleneck: a population that exploits correctly but explores badly. It is not a drop-in improvement, because it adds a model whose own assumptions can be wrong.
Six opt-in settings, all off by default, address the ways the default model
misreads a population; Python spells them as atune.schedulers.Pb2 keywords.
with_estimated_noise fits the GP's observation noise rather than treating
rewards as exact. with_round_time counts GP time in intervals, so old data can
be forgotten. with_segments reads one improvement per interval of every trial,
roots included, instead of one per fork. with_start_fitness, which needs
segments, tells the model where each segment started. with_sibling_diversity
steers a proposal away from configurations already at the boundary.
with_trust_region(r) keeps it near the parent. None is yet measured to help
on a real learner, so treat them as experiments; the in-run controller below
uses all of them.
Populations inside one run¶
Everything above treats each member as a trial: its own process, its own environment interaction, and a checkpoint through storage at every fork. When the members can live in one process instead — off-policy learners sharing one replay buffer, PPO members owning disjoint blocks of one vectorized environment, or model-based agents sharing one world model — the population costs gradient compute rather than environment steps, and an exploit is an in-memory weight copy.
atune::pb2::InRunPopulation (the pb2 feature) is the controller for that
case. It holds no learners and touches no storage. Your loop asks it for each
member's parameters, reports every member's progress, fitness and health at an
iteration boundary, calls decide, and applies the returned exploits: copy the
donor's learner into the recipient, then set the recipient's new parameters.
Explore::Pb2 proposes them with a time-varying GP-UCB that treats every
interval as an observation, takes the starting fitness as an input, estimates
its noise, keeps one round's proposals apart, and stays within a trust region
around the donor (trust_region, half-width 0.2 of each unit axis by default,
because GP-UCB over the whole space chases its corners); Explore::Perturb is
plain PBT. An unhealthy member is never copied, and is replaced whenever a healthy
member can donate. lineage(member)
is the schedule that produced a member, and snapshot/restore checkpoint the
controller with your population.
Python exposes the same controller as atune.pb2.InRunPopulation(search_space,
members, seed=...), with the distribution dicts accepted by create_study.
Use explore="pb2" (default) or explore="perturb"; initial maps member
indices to complete parameter dicts. Call report(member, progress, fitness,
feasible=True) for each member, then decide(): each returned dict has
recipient, donor, and params. Copy the donor's learner first, then install
those parameters. members and observations are properties; params(member),
lineage(member) and best() inspect the schedule. Save the host population
alongside snapshot() bytes; restore into a controller with matching settings.
Shared substrate is not free: members sharing replay learn from each other's data, which helps off-policy learners and can mislead on-policy ones, and a shared world model is tuned by none of them. Tune its own hyperparameters on a slower timescale.
IPBT¶
atune::pb2::Ipbt is a synchronous controller for iterated population-based
training (the same pb2 feature). Start with
Ipbt::builder(space, members, budget, initial_step).seed(seed).build().
The defaults follow the authors' IPBT configuration: eight target members
are typically started in sixteen slots, and the best eight survive the first
interval. Set members explicitly; the default oversampling multiplier is two.
For each round(), evaluate every listed slot before training when
evaluate_start is true and call report_start(slot, fitness), passing NaN for an infeasible start. Train each
slot for round.step using params(slot), then
report(slot, fitness, feasible). After every active slot reports, call
decide() and apply all returned actions in order: Archive preserves a
checkpoint, Copy overwrites a recipient's learner, Reinit computes
w = shrink*w + perturb*w_fresh, and Retire stops training a slot. Install
the controller's new parameters before the next round. Initial slots need
fresh weights supplied by the host. A process-based driver can use the same
protocol; shared replay is optional.
Stagnation restarts archive the best checkpoint, reactivate oversampled slots,
double the training interval, replace all parameters (half uniform, half
restart-level BO), and reset half the weights; the remainder use shrink 0.2
and perturb 0.1. An oversampled round charges the oversampling multiplier times its
per-slot interval to the campaign clock. round() returns None when the budget is spent;
best() identifies a live member or an archived winner. Save host learners
and archives alongside the controller's snapshot() for restore(), taken
before decide() or after its actions are applied, since a snapshot holds no
pending actions.
Python uses atune.pb2.Ipbt(search_space, members, budget, initial_step,
seed=...). Its round() dict contains slots, step, and evaluate_start;
report_start, report, and params use the fixed slot indices. decide()
returns ordered dicts whose kind is archive (slot/restart), copy
(recipient/donor), reinit (slot/shrink/perturb), or retire (slot). Apply them
in order, then install the next parameters. best() returns kind="member"
with slot/fitness or kind="archived" with restart/fitness; members, slots,
restarts, and clock are properties. Both Python controllers return compact
UTF-8 JSON bytes containing the complete Rust StateBlob (kind, version, data),
and reject foreign or mismatched snapshots with atune.AtuneError. The same
IPBT snapshot timing and host-checkpoint rules apply.
The port follows pinned code 827b7c7, including its always-successful
trust-region labels and suppression of late restarts. Its deterministic exact
GP fits and clipped proposal starts approximate the reference's optimized
GPs and gradient search; the Rust module documents every difference. Deliver
the winning checkpoint: schedule replay needs its own measurement, since
the paper's Humanoid replay lost IQM (9272 to 6939). A trial-level adapter
waits with fixed population slots; sibling forks cannot retire oversampled
members or express doubled intervals.
What this costs you¶
- The result is a schedule, not a configuration. You cannot hand someone "the best hyperparameters" from a PBT study and expect them to reproduce your number — they need the trajectory. Report the lineage alongside the winner.
- Forking is history-dependent. Which trial sits in the bottom fifth at a ranking depends on what has finished, so a PBT study with several workers is not reproducible trial-for-trial. Determinism states what does still hold.
- A fidelity budget counts training, not inherited progress. A trial is
charged its highest reported step less the step of the checkpoint it started
from, so a fork's inherited prefix is charged once, to the trial that trained
it, however many children share it. A fresh restart starts from zero and is
charged in full.
study.resource_ledger()shows the charge per trial. - Checkpoints cost storage. A population that forks every interval writes a checkpoint per trial per interval. That is a real disk budget, and the reason checkpoint references are references rather than payloads.
Next¶
- Prune and schedule trials — the same seam, stopping rather than forking.
- Samplers and schedulers — what a scheduler is allowed to decide.
- Storage — where checkpoints and lineage live.