Struct Pbt
pub struct Pbt { /* private fields */ }Expand description
Population-based training: exploit-and-explore at a fixed interval — the guide’s population-based training runs one end to end.
A population of trials trains in parallel; at a fixed interval (in the
study’s ResourceUnit) each trial that reports
is ranked against the population, and an underperformer (in the worst
bottom_fraction) is told to exploit and
explore:
- exploit — copy a randomly chosen top performer (from the best
top_fraction): its parameters and its checkpoint reference. That is aDecision::Forkwhoseparentis the top trial, so the child resumes from the parent’s checkpoint; - explore — perturb the exploited scalar hyperparameters (a learning
rate, an entropy coefficient) by a documented factor
(
Perturb); these become the forkmutation. Shape parameters (categorical/boolean) are never perturbed — they change the model’s architecture and the parent checkpoint would not load into a differently-shaped model, which is why the loop refuses a shape mutation.
§Frozen parameters — the scalar shape trap
“Shape parameter” and “categorical” are not the same set, and conflating
them is a live footgun. The skip rule above is typed: Cat/Bool are never
perturbed. But a network width is usually declared as an integer
(int(64, 512)), and PBT perturbs integers — so a fork would hand the child
a different architecture and the parent checkpoint would refuse to load into
it (oniro fails the resume with checkpoint load incomplete). The loop’s
scalar-only guard does not catch
this: the mutation is scalar.
with_frozen is the fix — an explicit list of parameter
names PBT must never perturb:
use atune_core::scheduler::Pbt;
// `sac.hidden` is an Int, so it would otherwise be perturbed; freezing it
// makes every fork child inherit the parent's width verbatim.
let pbt = Pbt::new(1_000)?.with_frozen(["sac.hidden"]);
assert_eq!(pbt.frozen().collect::<Vec<_>>(), ["sac.hidden"]);Freezing is deliberately not the same as removing the parameter from the search space: the sampler still tunes it across trials, which is sound (one trial’s segments all share one configuration); only perturbing it on a fork, where a checkpoint crosses the boundary, is not.
A trial above the exploit quantile just continues. PBT thereby learns a hyperparameter schedule: a lineage can carry one learning rate early and, through a later exploit-and-explore, inherit and perturb it to another — an adaptation no single fixed value achieves.
§The checkpoint contract the objective must honour
Exploit is only real if there is a checkpoint to copy. A PBT objective is
therefore expected to
record_checkpoint at each
interval (an oniro run-dir path, an artifact id — atune stores the
reference, never the bytes). A forked child then
receives that reference through
parent_checkpoint — for a
Subprocess child, as the
ENV_CHECKPOINT environment variable — and is
expected to warm-restart from it. If an objective records no checkpoint,
exploit degrades gracefully to copy the parameters only (a cold start with
the winning configuration); nothing panics.
§The execution model, and one seam limitation reported honestly
PBT works against today’s oniro through the chained
run_training(steps = interval) → run_training_resume model: a trial
trains toward the interval,
record_checkpoints and
reports there; PBT ranks it; the child
it forks resumes the winner. 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 (see occupies_population) — so it
accumulates over the study rather than being capped by
parallelism(N); a
single-threaded run forks once enough trials have crossed the same
aligned step. Parallelism decides how much of the population is alive at
once, not how large it is.
The one limitation. The frozen Decision cannot both stop the
reporting underperformer and fork a replacement in a single answer: a
Fork deliberately does not stop the trial that
emitted it — it spawns a sibling. So atune’s PBT
does not overwrite an underperformer in place the way the canonical
algorithm does; it adds the exploit child and lets the underperformer run out
its own segment. Over a run the exploit children dominate the population (they
carry the winning configurations forward), so the search is sound, but a true
in-place replacement would want a small additive seam — a companion
Command that stops the parent, emitted
alongside the fork. This is reported, not silently worked around.
§Single-seed vs multi-seed
This is single-seed PBT: it works directly on the M4.0
Fork/checkpoint seam, where each trial is one evaluation
and streams a value per report. A
fanned population receives deterministic aligned aggregates only after all
replicates finish a fan. PBT can therefore fork at aggregate report
intervals, but cannot make a per-replicate or partially evaluated mid-fan
decision.
§Determinism
Single-worker PBT with a fixed seed is reproducible: the population a
decision reads is the ask-time StudyView, the exploited top performer is
chosen by an RNG seeded from the reporting trial’s derived seed and the step
(never ambient entropy), and the perturbation draws from the same RNG. Under
parallelism the population at each
interval depends on completion order, so — exactly like every
history-dependent scheduler (where determinism
stops) — a PBT study is
replayable, not pre-determined. The genealogy is fully reconstructable
from storage (parent links + the recorded parameters, read through
Study::lineage), so PBT keeps no persistent
scheduler state — state is None, like ASHA’s rung
tables are recomputed from the view.
use atune_core::scheduler::Pbt;
// Decide every 10 resource units; Jaderberg defaults (bottom/top 20%,
// perturb by 0.8 / 1.2).
let pbt = Pbt::new(10)?;
assert_eq!(pbt.interval(), 10);Implementations§
§impl Pbt
impl Pbt
pub fn new(interval: u64) -> Result<Self>
pub fn new(interval: u64) -> Result<Self>
Builds a PBT scheduler that decides every interval resource units, with
the Jaderberg defaults (DEFAULT_BOTTOM_FRACTION,
DEFAULT_TOP_FRACTION, Perturb::default).
§Errors
Error::InvalidSpace if interval is 0 — an interval of zero would
gate every trial before it has run, exactly as a rung at step 0 does
(RungLadder::new).
pub fn with_bottom_fraction(self, fraction: f64) -> Self
pub fn with_bottom_fraction(self, fraction: f64) -> Self
Sets the exploit quantile — the worst fraction of the population
exploit (finite values clamped to [0, 1]; 0 disables exploiting).
A non-finite value is retained and rejected when a report uses the
scheduler.
pub fn with_top_fraction(self, fraction: f64) -> Self
pub fn with_top_fraction(self, fraction: f64) -> Self
Sets the top quantile — an exploit copies a random member of the best
fraction (finite values clamped to [0, 1]; 0 disables exploiting).
A non-finite value is retained and rejected when a report uses the
scheduler.
pub const fn with_perturb(self, perturb: Perturb) -> Self
pub const fn with_perturb(self, perturb: Perturb) -> Self
Sets the explore perturbation (default Perturb::default).
pub fn with_frozen<I, S>(self, names: I) -> Self
pub fn with_frozen<I, S>(self, names: I) -> Self
Names parameters the explore step must never perturb, so a fork child inherits the parent’s value for them verbatim.
This is the escape hatch for a scalar shape parameter — a network
width declared as int(64, 512), a layer count — which the typed
Cat/Bool skip rule cannot recognise and which makes the parent
checkpoint unloadable in the child
(see the type docs). Names
are matched exactly against the parameter names the trial recorded; a name
that no trial carries is simply inert.
Calling it twice adds to the set rather than replacing it, so a caller may combine a hand-written name with a list a helper computed.
§Determinism
A frozen parameter is skipped before its perturbation draw, so freezing one shifts the (still fully deterministic, still seed-derived) draw sequence of the parameters after it. The empty default therefore leaves every pre-existing study byte-identical.
pub fn frozen(&self) -> impl Iterator<Item = &str>
pub fn frozen(&self) -> impl Iterator<Item = &str>
The parameter names the explore step never perturbs, in sorted order.
pub const fn with_objective(self, obj: usize) -> Self
pub const fn with_objective(self, obj: usize) -> Self
Selects which objective dimension PBT ranks by (default 0).
pub const fn interval(&self) -> u64
pub const fn interval(&self) -> u64
The interval, in the study’s resource unit, at which PBT decides.
pub const fn bottom_fraction(&self) -> f64
pub const fn bottom_fraction(&self) -> f64
The exploit quantile.
pub const fn top_fraction(&self) -> f64
pub const fn top_fraction(&self) -> f64
The top quantile.
Trait Implementations§
§impl Scheduler for Pbt
impl Scheduler for Pbt
§fn on_report(
&self,
study: &StudyView,
trial: &TrialMeta,
step: u64,
values: &[f64],
) -> Result<Decision>
fn on_report( &self, study: &StudyView, trial: &TrialMeta, step: u64, values: &[f64], ) -> Result<Decision>
Exploits-and-explores at each interval; see the type docs.
§Errors
Error::InvalidSpace if a
configuration knob or selected objective is invalid, or if an explored
scalar cannot represent its requested perturbation.
§fn state(&self) -> Result<Option<SchedulerState>>
fn state(&self) -> Result<Option<SchedulerState>>
None: PBT’s genealogy lives in storage (parent links + checkpoints), so
the scheduler recomputes every decision from the StudyView and persists
nothing — see the type docs.