Skip to content

Configuration

SUMMARY

One YAML file describes a whole run: env: names the simulator and the task, runner: maps onto OnPolicyRunnerConfig field for field.

Every config validates itself on construction, so a bad value fails where you wrote it rather than three hundred iterations into training. Each converts to and from plain dictionaries, which is what lets the same run be written as YAML or as Python.

The Shape of a Configuration

Two blocks at the top, four configs nested under runner, and two more inside those. Everything else is a scalar on one of them.

env
runner
logger
algorithm
symmetry_cfg
actor
distribution_cfg
critic

Each box is one config class, and each level validates itself as it is built — so a bad value is rejected where you wrote it, not at the first training step. A CNNConfig actor or critic adds one more box, cnn_cfg, for the encoder placed before the MLP head.

In the tables below, a key whose Default is required has to be given; every other key can be left out.

The Reference Configuration

configs/example.yaml is the annotated reference — every field, with its accepted values, on a Gymnasium Humanoid run. The per-task configs in configs/<framework>/ are trimmed versions of it.

yaml
# Annotated reference config (Gymnasium Humanoid-v5) — one line per field, accepted values noted.

env:
  # Simulator: gymnasium, mjlab, or isaaclab.
  framework: gymnasium

  # Registered task id for that framework (see configs/<framework>/ for real ones).
  id: Humanoid-v5

  # Parallel environments. Positive integer.
  num_envs: 32

  # Device: auto, cpu, mps, cuda, or cuda:<index>.
  device: auto

  # --- mjlab/isaaclab only; the gymnasium adapter ignores these ---
  #
  # clip_actions: symmetric action limit. Positive float; omit to pass actions through unclamped.
  # clip_actions: 1.0
  #
  # nconmax (mjlab only): max contacts MuJoCo Warp allocates for. Positive integer; omit for the task's own limit.
  # nconmax: 50000
  #
  # headless (isaaclab only): run Isaac Sim without a viewer. true or false; default true.
  # headless: true

runner:
  # Runner class: "OnPolicyRunner", or a "module:Class" import path.
  class_name: OnPolicyRunner

  # RNG seed for model init, action sampling and mini-batch order. Null, or integer in [0, 2**32).
  seed: null

  # Iterations to train for. Positive integer.
  num_learning_iterations: 3000

  # Env steps collected per env, per iteration. Positive integer.
  num_steps_per_env: 32

  # Observation groups per set: gymnasium="observation"; mjlab="actor"/"critic"; isaaclab="policy"/"critic".
  obs_groups:
    actor: [observation]
    critic: [observation]

  # Print progress to console. true or false.
  verbose: true

  # Check every env output for NaN (costs a GPU sync each step). true or false.
  check_for_nan: false

  # torch.compile mode: null, default, or max-autotune-no-cudagraphs.
  torch_compile_mode: null

  logger:
    # Log at all. true or false; false trains without writing anything.
    enabled: true

    # Logger class: "Logger", or a "module:Class" import path.
    log_name: Logger

    # Root directory every experiment is written under. String, or null to write nothing.
    log_dir: logs

    # Experiment name — the directory grouping this run's siblings: <log_dir>/<experiment>/<timestamp>/.
    experiment: gymnasium_ppo

    # Console-report interval, in iterations. Positive integer.
    log_interval: 10

    # Write scalars to the event file. true or false.
    log_metrics: true

    # Record an MP4 beside every checkpoint. true or false.
    log_video: true

    # Dump the resolved config as config.json. true or false.
    log_config: true

    # Checkpoint interval, in iterations. Positive integer.
    save_interval: 100

    # Checkpoints to keep, oldest deleted first. Non-negative integer; 0 keeps all.
    keep_last_n: 5

    # Keep model_best.pt, rewritten on every new best mean reward. true or false.
    save_best: true

    # Resume from: null (scratch), "last", "best", or a checkpoint file name/path.
    resume: null

  algorithm:
    # Algorithm class: "PPO", or a "module:Class" import path.
    class_name: PPO

    # Optimizer: adam, adamw, sgd, or rmsprop.
    optimizer: adam

    # Initial learning rate. Positive float.
    learning_rate: 0.0001

    # Optimization epochs per rollout. Positive integer.
    num_learning_epochs: 2

    # Mini-batches per epoch. Positive integer.
    num_mini_batches: 4

    # LR schedule: adaptive (KL-based, needs desired_kl) or fixed.
    schedule: adaptive

    # Target KL for the adaptive schedule. Positive float; required if schedule=adaptive.
    desired_kl: 0.01

    # PPO clip range. Positive float.
    clip_param: 0.2

    # Value loss weight. Non-negative float.
    value_loss_coef: 1.0

    # Clip the value loss too. true or false.
    use_clipped_value_loss: true

    # Entropy bonus weight. Non-negative float.
    entropy_coef: 0.0011

    # Discount factor. Float in [0, 1].
    gamma: 0.99

    # GAE lambda. Float in [0, 1].
    lam: 0.95

    # Gradient clip norm. Positive float.
    max_grad_norm: 1.0

    # Normalize advantages per mini-batch instead of over the whole rollout. true or false.
    normalize_advantage_per_mini_batch: false

    # Critic reuses the actor's CNN encoders. true or false.
    share_cnn_encoders: false

    # Random Network Distillation settings, or null to disable.
    rnd_cfg: null

    # Symmetry augmentation/mirror-loss settings, or null to disable (see README's Symmetry section).
    symmetry_cfg: null

  actor:
    # Model class: "MLPModel", or a "module:Class" import path.
    class_name: MLPModel

    # Hidden layer widths. List of positive ints, or -1 to infer a dim from the input.
    hidden_dims: [256, 256, 128]

    # Activation: relu, elu, selu, crelu, lrelu, leaky_relu, tanh, sigmoid, softplus, gelu, swish, mish, or identity.
    activation: elu

    # Normalize observations with running statistics before the MLP. true or false.
    obs_normalization: true

    # What makes the actor stochastic; required on the actor, omit on a deterministic network (see critic).
    distribution_cfg:
      # Distribution class: "GaussianDistribution" or "SquashedGaussianDistribution", or a "module:Class" path.
      class_name: GaussianDistribution

      # Initial std. Positive float within std_range.
      init_std: 1.0

      # Std clamp [min, max]. min > 0 and min < max.
      std_range: [0.2, 1.5]

      # Std parameterization: scalar or log.
      std_type: scalar

      # Std is a learnable parameter, not fixed at init_std. true or false.
      learn_std: true

  critic:
    class_name: MLPModel
    hidden_dims: [256, 256, 128]
    activation: elu
    obs_normalization: true
    # No distribution_cfg: a critic predicts one value rather than sampling an action.

Train with it:

bash
python examples/training_example.py configs/example.yaml

class_name import paths work for the runner, not for the algorithm

The comments above describe every class_name as accepting a "module:Class" path. That currently holds for runner.class_name — resolved by create_runner, which falls back to importing the path — and for symmetry_cfg.data_augmentation_func.

The algorithm is resolved as ALGORITHMS[class_name], a plain registry lookup, so "my_pkg:MyPPO" raises KeyError. Pass the class object instead. See Introduce a Custom Algorithm.

Environment Configuration

env is read by the training script rather than by OnPolicyRunnerConfig, and it is what decides which adapter gets built.

KeyTypeDefaultDescription
frameworkstrrequiredgymnasium, mjlab or isaaclab
idstrrequiredRegistered task id for that framework
num_envsintrequiredParallel environments
devicestrautoauto, cpu, mps, cuda, or cuda:<index>
clip_actionsfloat | NoneNoneSymmetric action limit applied before the actions reach the task. mjlab and Isaac Lab only
nconmaxint | NoneNoneContacts MuJoCo Warp allocates buffers for. Raise it on nconmax overflow. mjlab only
headlessboolTrueRun Isaac Sim without a viewer window. Isaac Lab only

Six command-line options override fields for a single run — --num-envs, --device, --num-learning-iterations, --log-dir, --seed and --resume. The framework and the task deliberately have none: pointing at a different file is how you train something else.

Runner Configuration

OnPolicyRunnerConfig

The training loop, and the parent of every other config.

KeyTypeDefaultDescription
obs_groupsdict[str, list[str]]requiredMaps "actor" and "critic" to the environment observation groups each consumes
class_namestr | Callable"OnPolicyRunner"Runner class, as a registered name, a "module:Class" path, or the class itself. Only honoured when the runner is built by create_runner
seedint | NoneNoneSeeds Python, NumPy and PyTorch once when the runner is built, making model init, action sampling and mini-batch order reproducible. Must be in [0, 2**32). Does not seed the environment
num_learning_iterationsint1000Iterations to train for. learn() uses this when called with no argument
num_steps_per_envint24Environment steps collected per environment per learning iteration
verboseboolTrueWhether the runner prints progress. The cadence is logger.log_interval
check_for_nanboolFalseCheck environment outputs for NaN each step
torch_compile_modestr | NoneNoneNone, "default", or "max-autotune-no-cudagraphs"
loggerLoggerConfigLoggerConfig()Logging, checkpointing and resume
algorithmPPOConfigPPOConfig()Algorithm hyperparameters
actorMLPConfig | CNNConfigMLPConfig(distribution_cfg=GaussianDistributionConfig())Actor model. Requires a distribution_cfg
criticMLPConfig | CNNConfigMLPConfig()Critic model. Deterministic scalar output

num_envs × num_steps_per_env is the batch one iteration optimizes over. CPU Gymnasium tasks want tens of environments with long rollouts; GPU simulators want thousands with short ones.

check_for_nan costs throughput

Each check forces a GPU-to-CPU sync — a Python if on a CUDA tensor — so leaving it on every step noticeably caps GPU utilization on GPU-resident environments like mjlab and Isaac Lab. Turn it on while debugging a new environment or reward function, off for real runs.

CUDA-graph torch.compile modes are rejected: they are incompatible with the multi-model forward pattern the algorithms use.

LoggerConfig

KeyTypeDefaultDescription
enabledboolTrueFalse trains without writing anything
log_namestr | Callable"Logger"Logger class, as a name or the class itself
log_dirstr | None"logs"Root directory for all experiments. None keeps metrics in memory only
experimentstr"experiment"Name of this experiment, the directory grouping its runs
log_intervalint100Iterations between console reports. Scalars still go to the event file every iteration
log_metricsboolTrueWrite scalars to the event file
log_videoboolFalseRecord an MP4 beside every checkpoint
log_configboolTrueDump the resolved config as config.json
save_intervalint1000Iterations between checkpoints
keep_last_nint5Checkpoints kept, oldest deleted first. 0 keeps all. model_best.pt is never rotated away
save_bestboolTrueKeep model_best.pt, rewritten whenever the mean episode reward beats every earlier iteration's
resumestr | NoneNoneWhich checkpoint to continue from

The run directory

Everything a run produces lands in one place, logs/<experiment>/<timestamp>/:

logs/gymnasium_ppo/2026-08-06_18-08-47/
├── config.json        the full config the run used
├── events.out.*       TensorBoard scalars
├── model_100.pt       checkpoints, rotated to keep the last keep_last_n
├── model_100.mp4      with log_video, the rollout that produced that checkpoint
├── model_best.pt      the highest mean reward of the run, never rotated away
└── policy.onnx        the exported policy

log_video renders from that iteration's own rollout — no second simulator, and one render per step only on the iterations that checkpoint. Rotation deletes a video along with the checkpoint it belongs to.

Resuming

resume continues from an earlier checkpoint and writes a fresh timestamped directory, so a resumed run never overwrites the one it continued from. It takes one value and searches every run of the experiment:

ValueStarts from
"last"The most recently written checkpoint
"best"The highest-scoring checkpoint of the experiment, which may be in an older run
"model_500.pt"That file, found among the experiment's runs
"path/to/model.pt"Exactly that file, no searching

The best score travels inside the checkpoint, so a run that continues from a strong policy and then does worse cannot demote it.

Algorithm Configuration

PPOConfig

PPO is the only algorithm registered. To plug in your own, see Introduce a Custom Algorithm.

KeyTypeDefaultDescription
class_namestr | Callable"PPO"Algorithm class, as a registry name or the class itself
optimizerstr"adam"adam, adamw, sgd or rmsprop
learning_ratefloat1e-3Initial rate. Adapted at runtime under the adaptive schedule
num_learning_epochsint5Optimization epochs over each collected rollout
num_mini_batchesint4Mini-batches the rollout is split into per epoch
schedulestr"adaptive""adaptive" (KL-based) or "fixed"
value_loss_coeffloat1.0Weight of the value function loss. Non-negative
clip_paramfloat0.2PPO surrogate clipping parameter
use_clipped_value_lossboolTrueWhether to clip the value function loss
desired_klfloat | None0.01Target KL for the adaptive schedule. None disables adaptation, and is rejected when schedule is "adaptive"
entropy_coeffloat0.01Weight of the entropy bonus. Non-negative
gammafloat0.99Discount factor, in [0, 1]
lamfloat0.95GAE lambda, in [0, 1]
max_grad_normfloat1.0Gradient clipping norm
normalize_advantage_per_mini_batchboolFalseNormalize advantages per mini-batch instead of over the whole rollout
share_cnn_encodersboolFalseCritic reuses the actor's CNN encoders. Requires both to be CNN models
rnd_cfgdict[str, Any] | NoneNoneRandom Network Distillation. See Extension Configuration
symmetry_cfgSymmetryConfig | dict | NoneNoneSymmetry augmentation and mirror loss. See Extension Configuration

Which of these actually decide whether a run learns, with measured numbers behind each, is covered in Tuning Best Practices.

Model Configuration

Both the actor and the critic take a model config. The actor additionally requires a distribution, which is what makes it stochastic; a critic without one predicts a single scalar.

MLPConfig

KeyTypeDefaultDescription
class_namestr | Callable"MLPModel"Model class, as a registry name or the class itself
hidden_dimstuple[int, ...] | list[int](256, 256, 256)Hidden layer widths. A -1 is inferred from the input dimension
activationstr"elu"relu, elu, selu, crelu, lrelu/leaky_relu, tanh, sigmoid, softplus, gelu, swish, mish, identity
obs_normalizationboolFalseNormalize observations with running statistics before the network
distribution_cfgGaussianDistributionConfig | NoneNoneOutput distribution. None gives a deterministic output, which is what a critic wants

Turn on obs_normalization

It is worth keeping on for every task: observation entries span angles, velocities and contact forces with wildly different scales, and the normalization is exported with the policy, so deployment sees the same inputs training did.

CNNConfig

For image observations. Inherits every MLPConfig field — those configure the MLP head placed after the encoder — and adds:

KeyTypeDefaultDescription
class_namestr | Callable"CNNModel"Model class, as a registry name or the class itself
cnn_cfgCNNEncoderConfig | dict[str, CNNEncoderConfig] | NoneNoneA single encoder config shared by all image observations, or a mapping from group name to encoder config

A CNN model requires its observation set to name exactly one group shaped (channels, height, width). Set share_cnn_encoders on the algorithm to have the critic reuse the actor's encoders.

CNNEncoderConfig

Per-layer settings accept either a scalar shared by all layers or a sequence with exactly one entry per layer.

KeyTypeDefaultDescription
output_channelstuple[int, ...] | list[int](32, 64, 64)Output channels per layer. Its length defines the layer count
kernel_sizeint | tuple[int, ...] | list[int]3Convolution kernel size(s)
strideint | tuple[int, ...] | list[int]1Convolution stride(s)
dilationint | tuple[int, ...] | list[int]1Convolution dilation(s)
paddingstr"none"none, zeros, reflect, replicate, circular
normstr | tuple[str, ...] | list[str]"none"none, batch, layer
activationstr"elu"Activation after each layer
max_poolbool | tuple[bool, ...] | list[bool]FalseMax pooling after each layer
global_poolstr"none"none, max, avg on the final feature map
flattenboolTrueFlatten before the MLP head

Distribution Configuration

GaussianDistributionConfig

KeyTypeDefaultDescription
class_namestr | Callable"GaussianDistribution"Or "SquashedGaussianDistribution"
init_stdfloat1.0Initial standard deviation. Must lie within std_range
std_rangetuple[float, float](1e-6, 1e6)Bounds the standard deviation is clamped to. min > 0 and min < max
std_typestr"scalar""scalar" or "log" parameterization
learn_stdboolTrueWhether the standard deviation is a learnable parameter

Two things worth setting rather than leaving at the default:

  • A real std_range floor, e.g. (0.2, 1.5). The entropy bonus has no target, so without a floor the standard deviation decays until the policy is near-deterministic and stops improving — measured on Humanoid-v5, 1.00 → 0.29 while the reward peaked and fell back.
  • The plain Gaussian over the squashed one at high action dimension. SquashedGaussianDistribution is numerically fragile once the mean drifts: tanh saturates, its log probability explodes and the ratio overflows.

Extension Configuration

Both extensions hang off PPOConfig and are off by default.

Symmetry Augmentation

A left-right symmetric robot gives the algorithm an invariance for free. Mirrored samples are appended to every mini-batch, and an optional term penalizes the policy for disagreeing with itself on them.

KeyTypeDefaultDescription
data_augmentation_funcCallable | str | NoneNoneThe mirror function, or an import path such as "my_robot.symmetry:mirror". Required — constructing the config without one raises
use_data_augmentationboolTrueAppend the mirrored samples to every mini-batch
use_mirror_lossboolTrueAdd the mirror loss to the objective
mirror_loss_coefffloat1.0Weight of the mirror loss. Non-negative

It is configured in Python rather than from a config file alone, because the piece it needs cannot be shipped: a mirror function saying which observation entry mirrors which, which only somebody who knows the robot's joint order can write. Setting both flags to False still computes and reports the loss, detached — a cheap way to watch how symmetric a policy is without changing what it optimizes. Not supported for recurrent policies.

See symmetry for how to write a mirror function and, more importantly, how to verify one.

Random Network Distillation

Not implemented

rnd_cfg exists on PPOConfig and is validated, but the extension behind it is not implemented: setting it to anything other than None raises at construction. Treat it as reserved.

Observation Groups

obs_groups is the wiring between what your environment publishes and what each network reads. The two keys on the left are fixed, "actor" and "critic"; the group names on the right are your environment's.

python
# Symmetric: both networks see the same thing
obs_groups={"actor": ["observation"], "critic": ["observation"]}

# Asymmetric: the critic also sees state the robot cannot measure
obs_groups={"actor": ["policy"], "critic": ["policy", "privileged"]}

An environment returns its observations as a TensorDict of named groups, and each name is a group. Each backend names them differently:

BackendGroups it publishes
Gymnasiumobservation — one flat group
mjlabactor, plus a privileged critic, plus camera on the vision tasks
Isaac Labpolicy, plus critic on tasks set up for an asymmetric actor-critic

observation_spec(env) reports what a task actually publishes, which is the reliable way to find out before writing a config:

python
from telekinesis.rlbotics.envs.base import observation_spec

observation_spec(env)   # {"policy": (48,), "privileged": (235,)}

A group named in obs_groups that the environment does not publish raises at construction, listing what was available.

Why the critic gets its own set

Only the actor is exported for deployment, so anything the critic reads costs nothing at runtime. That makes it free to give the critic state the robot could never measure — contact forces, terrain height under each foot, the true base velocity — which produces a better value estimate and therefore better advantages, without making the deployed policy depend on information it will not have. This is the asymmetric actor-critic setup, and it is why mjlab and Isaac Lab tasks publish a privileged group at all.

One group or several

How a set is sized follows from how many groups it names:

  • One group keeps that group's shape, including a 3-dimensional (channels, height, width) image. A CNNConfig model therefore requires its set to name exactly one group of images.
  • Several groups are concatenated along the feature dimension, which requires every one of them to be a flat vector. Mixing an image into a multi-group set raises rather than silently flattening it, with a message naming the offending group and its shape.

So {"critic": ["policy", "privileged"]} builds a critic sized 48 + 235 = 283, while {"actor": ["camera"]} keeps (3, 64, 64) for a CNN to consume.

Building a Configuration in Python

From a dictionary

from_dict is what the training example uses, and it validates on the way in — so a typo in a YAML file is caught at load rather than ignored:

python
import yaml

from telekinesis.rlbotics.config import OnPolicyRunnerConfig

data = yaml.safe_load(open("configs/gymnasium/Humanoid-v5.yaml"))
runner_cfg = OnPolicyRunnerConfig.from_dict(data["runner"])

env_cfg = data["env"]        # the training script reads this itself

Unknown keys are rejected with a message listing the valid ones. Nested configs may be given as dictionaries or as config instances, so algorithm={"learning_rate": 1e-4} is coerced into a PPOConfig.

Two more methods come with every config:

python
runner_cfg.to_dict()                            # nested plain dictionaries, the inverse of from_dict
runner_cfg.replace(num_steps_per_env=48)        # a revalidated copy

to_dict round-trips, which is how configuration_example.py writes a Python-built configuration back out as a YAML file that training_example.py can then train from as-is.

Field by field

Worth doing when a configuration is computed rather than fixed — a sweep, a network sized from the observation, a hyperparameter that comes from somewhere else. This is make_runner_config from configuration_example.py:

python
def make_runner_config(
    obs_dim: int,
    log_dir: str,
    num_learning_iterations: int = 20,
    experiment: str = "configuration_example",
    seed: int | None = None,
) -> OnPolicyRunnerConfig:
    """Build a runner configuration field by field.

    Args:
        obs_dim: Observation size of the task, used to size the networks. A 348-dimensional Humanoid
            observation needs more capacity than a 3-dimensional Pendulum one, which is the kind of
            decision a YAML file cannot make for itself.
        log_dir: Directory the run is written under.
        num_learning_iterations: Iterations to train for, which :meth:`OnPolicyRunner.learn` reads
            from the config when called with no argument.
        experiment: Experiment name, the directory its runs are grouped under.
        seed: Seed for model init, action sampling and mini-batch order, or None to leave it random.

    Returns:
        The runner configuration.
    """
    hidden_dims = (256, 256, 128) if obs_dim >= 100 else (64, 64)

    # What makes the actor stochastic. The std floor keeps exploration alive: without one the
    # standard deviation decays until the policy is near-deterministic and stops improving.
    distribution_cfg = GaussianDistributionConfig(
        class_name="GaussianDistribution",
        init_std=1.0,
        std_range=(0.2, 1.5),
    )

    # Observation normalization is worth keeping on for every task, since observation entries span
    # angles, velocities and contact forces with wildly different scales. It is exported with the
    # policy, so a deployment sees the same inputs training did.
    actor = MLPConfig(
        class_name="MLPModel",
        hidden_dims=hidden_dims,
        activation="elu",
        obs_normalization=True,
        distribution_cfg=distribution_cfg,
    )

    # No distribution: a critic predicts one value rather than sampling
    critic = MLPConfig(
        class_name="MLPModel",
        hidden_dims=hidden_dims,
        activation="elu",
        obs_normalization=True,
    )

    algorithm = PPOConfig(
        # A registered name, or an import path such as "my_pkg.algorithms:MyPPO" for your own
        class_name="PPO",
        optimizer="adam",
        learning_rate=3.0e-4,
        num_learning_epochs=10,
        num_mini_batches=4,
        # An adaptive schedule holds each update inside the KL trust region, which is what stops the
        # reward climbing and then collapsing
        schedule="adaptive",
        desired_kl=0.01,
        clip_param=0.2,
        entropy_coef=0.01,
        gamma=0.99,
        lam=0.95,
        max_grad_norm=1.0,
        value_loss_coef=1.0,
    )

    # The run lands in <log_dir>/<experiment>/<timestamp>, with the event file, the config dump and
    # the checkpoints flat inside it. Resuming looks across the experiment's runs.
    logger_cfg = LoggerConfig(
        log_dir=log_dir,
        experiment=experiment,
        log_interval=10,
        save_interval=25,
        keep_last_n=5,
        save_best=True,
        log_video=False,
        resume=None,
    )

    return OnPolicyRunnerConfig(
        # A registered name, or an import path such as "my_pkg.runner:MyRunner" for your own
        class_name="OnPolicyRunner",
        # Left None by default: set it for a reproducible run, e.g. when comparing two configs
        seed=seed,
        # Which observation groups each network reads. The Gymnasium adapter publishes one,
        # "observation"; a simulator with privileged state gives the critic its own group here.
        obs_groups={"actor": ["observation"], "critic": ["observation"]},
        num_learning_iterations=num_learning_iterations,
        num_steps_per_env=128,
        verbose=True,
        # A host sync every step, so leave it off unless an environment may return NaN
        check_for_nan=False,
        torch_compile_mode=None,
        logger=logger_cfg,
        algorithm=algorithm,
        actor=actor,
        critic=critic,
    )

The tree is built inside out: a distribution config goes into the actor's model config, and the model, logger and algorithm configs go into the runner config. Each piece validates as it is constructed, so a bad value is rejected at its own line.

Then build the runner through create_runner, which is what makes class_name mean something — OnPolicyRunner(...) always builds that one class:

python
from telekinesis.rlbotics.runner import create_runner

runner = create_runner(env=env, runner_cfg=runner_cfg, device="auto")
runner.learn()          # uses num_learning_iterations from the config
Which of these actually matter?
The handful of settings that decide whether a run learns, with measured numbers behind each one.
Tuning Best Practices →