Reinforcement learning (from scratch)¶
The Trainer family post-tunes a policy from a dataset.
Reinforcement learning is the other half: train a policy from a reward
function by interacting with a simulation, with no demonstration data. This is
the only path to a locomotion / whole-body-control policy where no expert
trajectories exist.
RL trainers live in strands_robots.training.rl and are selected through the
same create_trainer factory:
from strands_robots.training import create_trainer
from strands_robots.training.rl import RLTrainSpec
trainer = create_trainer("ppo") # on-policy PPO, from-scratch RL
The pieces¶
| Component | Role |
|---|---|
BaseRLAlgo |
Abstract RL trainer; peer of supervised Trainer. Lifecycle setup -> collect_rollout -> update -> save_checkpoint. |
RLTrainSpec |
Reward-driven training spec (extends TrainSpec). |
PpoTrainer |
Proximal Policy Optimization (on-policy, GAE, clipped surrogate + value). |
FastSacTrainer |
Soft Actor-Critic (off-policy, replay buffer, twin Q critics, auto-tuned entropy). |
SimpleReplayBuffer |
Off-policy transition store (fixed-capacity ring buffer). |
SimEnv |
Gym-style reset -> step adapter over a SimEngine. |
EmpiricalNormalization |
Running observation normalizer (whitens inputs for stable training). Statistics update only in training mode - eval() freezes them at both entry points (forward and update), so an exported policy whitens deterministically. Both entry points want a batched (batch, num_obs) tensor and refuse anything else by name; pass obs.unsqueeze(0) for a single observation. A refused batch leaves the estimator untouched - the sample count is committed only once the fold succeeds. |
SimEnv¶
SimEnv wraps a SimEngine into the reset -> step contract, building the
observation vector from named get_observation keys and the step reward from
any reward terms you pass (each a Callable[[SimEngine], float]). It uses the holosoma
actor_obs_keys / critic_obs_keys split: the actor sees only deployable
observations, while the critic may additionally see privileged simulation-only
keys (asymmetric actor-critic).
import strands_robots as sr
from strands_robots.training.rl import SimEnv
TARGET = 0.2
def elbow_reach_reward(engine) -> float: # a RewardTerm = SimEngine -> float
elbow = engine.get_observation(skip_images=True)["Elbow"]
return -abs(float(elbow) - TARGET)
def make_env() -> SimEnv:
engine = sr.Robot("so100", mode="sim")
return SimEnv(
engine,
actor_obs_keys=["Elbow", "Elbow.vel"], # what the policy sees
reward_terms=[elbow_reach_reward], # dense reward
action_dim=6,
max_episode_steps=50,
)
Numeric arguments¶
Every numeric SimEnv stores is checked at construction, against the shared
domain for its kind, before the engine is read:
| Argument | Domain | Why |
|---|---|---|
action_scale |
positive finite | It multiplies every action sent. 0 disconnects the policy from the robot, a negative value inverts every commanded DOF, and nan/inf make each command unsendable - send_action refuses a non-finite action, and step discards that status, so the rollout banks its full return having moved nothing. |
max_episode_steps |
positive whole number | 0 or below reports a time-out on the first step, so every episode ends before it begins - and reports it as a truncation, which on-policy GAE value-bootstraps. |
n_substeps |
positive whole number | The action is a position target; the PD controller needs several substeps to track it. This is send_action's own domain for the parameter, which SimEnv forwards it to. |
action_dim |
positive integer, or None |
None is the documented "size the head from the robot's action keys" spelling. A width of 0 gives the policy no outputs. Narrower than the two above because it sizes the trainers' action head, where an integral float raises rather than being coerced. |
An unusable value raises ValueError naming the class and the argument
(SimEnv: action_scale must be > 0, got 0.0.). Each domain is the one its
consumer can honor, so nothing is refused here that the code downstream accepts:
a fractional scale (0.25) and a scale read from a config array
(np.float32(0.25)) are fine, and so is a whole-number count spelled 50.0 or
np.int64(50) - all normalized to the plain float/int the attribute
advertises.
PPO¶
from strands_robots.training import create_trainer
from strands_robots.training.rl import RLTrainSpec
trainer = create_trainer("ppo")
spec = RLTrainSpec(
env_factory=make_env, # a zero-arg callable returning a SimEnv
output_dir="/tmp/ppo_reach",
total_timesteps=250 * 150,
rollout_steps=250, # on-policy batch horizon per update
num_mini_batches=4,
num_learning_epochs=5,
learning_rate=1e-3,
gamma=0.99, lam=0.95, clip_param=0.2,
init_noise_std=0.8,
seed=0,
)
problems = trainer.validate(spec) # pure preflight (no side effects)
assert not problems
result = trainer.train(spec) # setup -> (collect_rollout -> update)* -> save
print(result.metrics) # mean_reward, mean_episode_return, surrogate_loss, value_loss
train() writes a checkpoint under output_dir/checkpoints/last/:
policy.pt- the actor-critic + observation-normalizer state (the loadable artifact returned byresult.exported_model).policy_meta.json- deployable-policy metadata:num_actions,actor_obs_keys,action_keys,hidden_dims.
action_keys names what the num_actions outputs drive, in order, and is the
robot's robot_action_keys - the vocabulary send_action binds an action
vector against. It is not the joint list: a tendon-driven gripper is one
actuator over two finger joints (so a Panda has 9 joints and 8 action keys), and
the Newton backend's floating base is a joint with no commandable scalar. SimEnv
sizes num_actions from the same list, so len(action_keys) == num_actions
always holds. Pass action_dim to SimEnv to override the width.
PpoTrainer trains fine on CPU (its hardware_floor declares no GPU
requirement); MuJoCo stepping dominates, not the network.
Device selection¶
The learner (actor-critic, normalizers, rollout buffers) is placed on
RLTrainSpec.device, defaulting to cuda when available and cpu otherwise.
The learner device is authoritative: on a GPU host setup() reconciles the
SimEnv onto it so observations, rewards, and dones are built on the same
device as the network (no cross-device tensor mismatch and no per-step
host-to-device copies). Pass device="cpu" explicitly to keep everything on
CPU even on a GPU machine.
FastSAC¶
FastSacTrainer is the off-policy trainer: it keeps a replay buffer of past
transitions and reuses each one across many gradient steps, so it reaches a
target in far fewer environment steps than on-policy PPO (at the cost of more
compute per step). It trains a tanh-squashed Gaussian actor and twin Q critics
(clipped double-Q) with Polyak-averaged target critics and an automatically
tuned entropy temperature, and writes the same policy.pt +
policy_meta.json checkpoint as PPO.
from strands_robots.training import create_trainer
from strands_robots.training.rl import RLTrainSpec
trainer = create_trainer("fast_sac")
spec = RLTrainSpec(
env_factory=make_env, # same SimEnv contract as PPO
output_dir="/tmp/fastsac_reach",
total_timesteps=50 * 80,
rollout_steps=50, # env steps collected per iteration
learning_starts=500, # random-action warmup before the first update
batch_size=256, # transitions sampled per gradient step
gradient_steps=50, # SAC updates per iteration
buffer_size=50_000, # replay-buffer capacity
learning_rate=3e-4,
gamma=0.99, tau=0.01, # discount + Polyak target-critic coefficient
seed=0,
)
result = trainer.train(spec) # setup -> (collect_rollout -> update)* -> save
print(result.metrics) # mean_reward, critic_loss, actor_loss, alpha, entropy
The off-policy fields on RLTrainSpec (buffer_size, batch_size,
learning_starts, gradient_steps, tau, autotune_alpha, init_alpha,
alpha_lr, target_entropy) are read only by SAC; on-policy PPO ignores them.
target_entropy defaults to -num_actions (the SAC heuristic) when left
None. Like PPO, FastSacTrainer trains fine on CPU.
BaseRLAlgo¶
BaseRLAlgo is the abstract RL trainer - a Trainer subclass, so RL flows
through the same create_trainer / validate / export contract while adding
the RL lifecycle hooks setup, collect_rollout, update, and
save_checkpoint. The default train() runs the standard on-policy loop over
those hooks; an off-policy algorithm overrides train() with a replay-buffer
loop while keeping the same hooks and checkpoint format.
RLTrainSpec¶
RLTrainSpec extends TrainSpec. RL ignores the dataset fields
(dataset_root etc.) and reads env_factory, total_timesteps,
rollout_steps, num_envs, the PPO hyperparameters (gamma, lam,
clip_param, num_learning_epochs, num_mini_batches, entropy_coef,
value_loss_coef, max_grad_norm, hidden_dims, init_noise_std), the
off-policy SAC fields (buffer_size, batch_size, learning_starts,
gradient_steps, tau, autotune_alpha, init_alpha, alpha_lr,
target_entropy), plus the universal output_dir / learning_rate / seed.
total_timesteps and rollout_steps must each be a positive integer, checked by
validate() on both backends against the same shared domain the supervised
backends use for steps / global_batch_size. They are the two caller-supplied
factors of the one loop bound each backend derives -
num_iters = max(1, total_timesteps // (rollout_steps * num_envs)), iterated as
range(num_iters) - and because the bound is derived through that max(1, ...)
clamp, a value that is merely "not non-positive" is not enough. Over a 16-step run
with rollout_steps=4, True, 0.5, nan and inf each used to report
status="success" after exactly one iteration, writing a checkpoint and
announcing "1 iterations x 4 steps complete" for a run asked to be tens of
thousands of steps long (inf // 4 is nan, and max(1, nan) is 1 - nan
compares false against everything). A fraction that does divide -
100.5 // 4 == 25.0 - instead raised a bare TypeError out of range() after
the environment, the networks, the optimizers and (FastSAC) the replay buffer had
been built, and a string or None raised out of the comparison itself, from a
preflight documented to return problems. rollout_steps=True was worse than a
short run because it changes the run's shape rather than its length: it makes
steps_per_iter one, so FastSAC ran 16 single-step iterations instead of 4 of 4,
and PPO normalized advantages over a length-one batch - whose standard deviation
is nan - and failed inside torch's Normal constraint.
num_envs, the third factor of steps_per_iter, is checked by each backend
separately rather than against that shared domain, because the accepted sets
differ: PPO parallelizes and accepts any count >= 1, while the MuJoCo-backed
FastSAC is single-env and requires exactly 1.
gamma must be a finite number in the closed interval [0, 1], checked by
validate() on both backends. It is the one coefficient both of them read (PPO
discounts the GAE recursion with it, FastSAC its target-Q bootstrap) and a
discounted return is a geometric series, so a value above 1 makes that series
diverge in the rollout horizon rather than merely being large - the run would
train on the inflated advantages, report success and write a checkpoint. Both
endpoints are inside the domain: gamma=1 is the undiscounted episodic return
and gamma=0 a myopic agent that optimizes the immediate reward only. The
FastSAC tau is bounded the same way, in (0, 1].
num_learning_epochs must be a positive integer, checked by validate() on the
on-policy backend only (FastSAC optimizes per gradient step from a replay buffer
and has no epoch loop). It is the loop bound of the entire optimizer step, so a
non-positive value takes no gradient step while the run still collects its
rollouts, writes a checkpoint and reports success - with losses of 0.0,
because the update averages its accumulators through max(1, n_updates). True
is likewise a silent single epoch, and a non-integer raises a bare TypeError
out of range() after the environment and the networks are already built.
FastSAC builds two optimizers from two learning-rate fields - learning_rate
for the actor and both critics, alpha_lr for the entropy temperature - so
when autotune_alpha is set alpha_lr must be a positive finite number too,
checked by the same validate(). alpha_lr=0 builds the temperature
optimizer and never moves it, so the temperature stays at init_alpha and the
automatic tuning the spec asked for silently does not happen; inf sends it
to an infinity on the first step, and because the temperature multiplies the
log-probability in the actor loss the resulting checkpoint holds non-finite
parameters. Both previously reported success. It is inert when
autotune_alpha=False, which builds no temperature optimizer.
init_alpha - the temperature that rate moves - must be a positive finite
number, checked by the same validate(). FastSAC stores the temperature's
logarithm (log_alpha = log(init_alpha), with alpha = exp(log_alpha)
scaling the entropy term in both the critic target and the actor loss), so only
a positive finite value has a usable logarithm. init_alpha=0 makes
log(0) = -inf and the temperature exactly 0, which removes the entropy term
from both losses - the run is no longer the maximum-entropy algorithm that was
asked for - and no finite gradient step moves an infinity, so autotune_alpha
cannot recover it; the run previously reported success and checkpointed
log_alpha = -inf, so the unusable temperature outlived it. True was a silent
temperature of exactly 1.0, and a negative value, nan or inf poisoned the
actor loss and raised ValueError from torch.distributions.Normal about a
tensor of nan policy means - naming that distribution's parameter rather than
the field, and only once train() was already under way. Unlike alpha_lr
this check is not conditioned on autotune_alpha: the field is read on both
branches, and with tuning off it is the temperature for the whole run.
max_grad_norm must be a positive number a 64-bit float can represent, checked
by validate() on the on-policy backend only (clip_grad_norm_ appears in
rl/ppo.py and nowhere else). It is the last thing that touches a gradient
before the optimizer steps, and clip_grad_norm_ scales every gradient by
max_norm / total_norm without judging the bound, so two values used to be
honored silently and wrongly:
max_grad_norm=0 scaled every gradient to zero, and the run reported success
with a checkpoint bit-identical to a never-trained one; a negative bound
negated the scaling ratio, so the update became gradient ascent on the loss
and moved the parameters away from the objective, also under a successful run.
True was a silent bound of one and "1.0" was silently coerced, while nan,
None and a list raised from inside torch mid-update.
inf is inside the domain: it is the field's only spelling of do not clip, and
clip_grad_norm_ honors it by leaving every gradient untouched. A real that no
64-bit float stands for is refused with the range as its reason rather than the
sign, and validate() reports it like any other unusable bound instead of
raising: Python integers are arbitrary-precision, so 10**400 is one request
away.
clip_param must be a positive number too, checked by the same validate() on
the on-policy backend only (spec.clip_param appears in rl/ppo.py and nowhere
else). It is the half-width of the trust region PPO is named for, read twice per
mini-batch - once to clip the policy ratio and once to clip the value loss - and
torch.clamp judges it not at all, so every unusable value produced a finite,
successful, deployable run whose objective was not the configured one:
nansilently removes the trust region. Both clipped terms becomenan, sotorch.max(surrogate, surrogate_clipped)returnsnan- but its gradient flows to the unclipped branch, because every comparison againstnanis false. Measured over a seeded 60-step run, the resulting checkpoint is bit-identical to an unclipped one (parameter sum140.1735330768706262) whilesurrogate_loss,value_lossandlatest_lossare all reported asnan.- A negative half-width is not a window:
1 - cexceeds1 + c, so the clamp bounds are inverted and it returns a constant regardless of the ratio, and the reported surrogate loss changes sign.0is the same failure at the boundary - the value clip becomesclamp(-0, 0), sovalue_clippedis exactlyold_valuesand the critic's clipped branch is a constant. Truewas a silent half-width of one, five times the shipped0.2, and"0.2",Noneand a list raisedTypeErrorfrom inside the update loop.
inf is inside this field's domain for the same reason as max_grad_norm's -
clamp(ratio, -inf, inf) returns the ratio unchanged, so it is the field's only
spelling of do not clip - and the two bounds share one domain helper rather
than a copy each.
Worked example¶
examples/training/train_ppo_reach.py (on-policy) and examples/training/train_fastsac_reach.py
(off-policy) both train the SO-100 Elbow joint to a target angle in MuJoCo
from scratch and print the resulting checkpoint path. The MuJoCo backend is
single-environment (num_envs == 1); vectorized backends for
massively-parallel rollouts are tracked separately.
Result¶
PPO trained from scratch on CPU (no dataset, reward only) closes the reach
gap over 150 iterations and the deterministic policy drives the Elbow joint
to the target:


FastSAC (off-policy) reaches the same target in far fewer environment steps,
reusing replayed transitions; the deterministic policy drives the Elbow
joint onto the target (0.19 rad vs. a 0.20 target):

