Skip to content

Introduce a Custom Algorithm

SUMMARY

The runner owns the loop — collect, bootstrap, update, log, checkpoint — and calls into the algorithm through a fixed set of methods. Anything implementing those methods can take PPO's place.

In practice, subclassing PPO and overriding one method is the shortest path, because the rollout storage, the normalizers and the checkpoint format come along with it.

The protocol the runner drives

Per iteration, OnPolicyRunner.learn() calls exactly this:

python
# collection, num_steps_per_env times
actions = alg.act(obs)
obs, rewards, dones, extras = env.step(actions)
alg.process_env_step(obs, rewards, dones, extras)

# then, once
alg.compute_returns(obs)
loss_dict = alg.update()

and reads alg.learning_rate, alg.get_action_std() and alg.diagnostics for logging. Around that it calls alg.train_mode(), alg.eval_mode(), alg.compile(mode), alg.save(), alg.load(...) and alg.get_policy().

MemberContract
act(obs)Returns actions (num_envs, num_actions). Also where the transition's value and log-probability are recorded
process_env_step(obs, rewards, dones, extras)Records one transition, updates the observation normalizers, bootstraps on extras["time_outs"], and adds the transition to storage
compute_returns(obs)Computes returns and advantages from the final observation of the rollout
update()Optimizes, clears storage, and returns a dict of scalar losses. Keys appear as Loss/<key>
train_mode() / eval_mode()Switch the models
save() / load(dict, cfg, strict)The algorithm's slice of a checkpoint
get_policy()The actor, which is what gets exported
get_action_std()Reported as Policy/action_std, or None
learning_rateAttribute, reported as Learning/learning_rate
diagnosticsDict of scalars from the last update. Keys appear as Diagnostics/<key>
compile(mode)Applies torch.compile at the requested mode, or does nothing
broadcast_parameters() / reduce_parameters()Multi-GPU only

The runner constructs it with keyword arguments:

python
alg = alg_class(
    cfg=alg_cfg,              # your algorithm config
    actor=actor,              # already sized for the observation set it consumes
    critic=critic,
    storage=storage,          # a RolloutBuffer, already sized
    obs_groups=obs_groups,
    device=device,
    multi_gpu_cfg=multi_gpu_cfg,
    env=env,
)

Note what the runner has already done for you: it built and sized the actor and critic from the observation groups, allocated the rollout buffer, and resolved the device. Your algorithm receives them rather than creating them.

The short path: subclass PPO

Most changes are one method. Everything else — storage, normalizers, checkpoint format, multi-GPU, compile — comes for free.

python
import torch

from telekinesis.rlbotics.algorithms import PPO


class EntropyDecayPPO(PPO):
    """PPO with an entropy coefficient that decays as the policy improves."""

    def update(self) -> dict[str, float]:
        # Anneal before the optimization the base class performs
        self.cfg = self.cfg.replace(
            entropy_coef=max(self.cfg.entropy_coef * 0.999, 1e-4)
        )
        loss_dict = super().update()

        # Anything added here shows up as Loss/<key> in TensorBoard
        loss_dict["entropy_coef"] = self.cfg.entropy_coef
        return loss_dict

Wire it in by passing the class itself as class_name:

python
from telekinesis.rlbotics.config import OnPolicyRunnerConfig, PPOConfig

cfg = OnPolicyRunnerConfig(
    obs_groups={"actor": ["policy"], "critic": ["policy"]},
    algorithm=PPOConfig(class_name=EntropyDecayPPO, learning_rate=1e-3),
)

Pass the class, not a string

A string class_name is resolved through the ALGORITHMS registry, which contains only {"PPO": PPO} — so "my_pkg.algorithms:MyPPO" raises a KeyError at construction. A callable is used as-is, which is why the class object is the working form.

One consequence: config.json records a class object through str(), as <class 'my_pkg.MyPPO'>, which from_dict cannot read back. A run configured with a custom algorithm is reproducible from your script, not from its config dump.

Adding hyperparameters

PPOConfig.from_dict rejects unknown keys, so a new hyperparameter needs its own config. Subclass PPOConfig — the runner accepts any subclass — and extend expected_class_names so validation admits your class name:

python
from dataclasses import dataclass
from typing import ClassVar

from telekinesis.rlbotics.config import PPOConfig


@dataclass
class EntropyDecayPPOConfig(PPOConfig):
    """PPOConfig plus the decay rate."""

    expected_class_names: ClassVar[tuple[str, ...]] = ("PPO", "EntropyDecayPPO")

    entropy_decay: float = 0.999
    entropy_floor: float = 1e-4

    def __post_init__(self) -> None:
        super().__post_init__()
        if not 0.0 < self.entropy_decay <= 1.0:
            raise ValueError(f"'entropy_decay' must be in (0, 1], got {self.entropy_decay}.")

Your algorithm then reads self.cfg.entropy_decay, and the config still validates itself and still converts to and from dictionaries. Follow the library's own convention and validate in __post_init__, so a bad value fails where it was written rather than mid-run.

WARNING

The runner requires the algorithm config to be a PPOConfig or a subclass of it. A config that does not inherit from it is rejected with Unsupported algorithm config type.

Writing one from scratch: SAC

Off-policy is the interesting case, because it is where the on-policy shape stops fitting for free. SAC is worth walking through because every seam shows up.

What the runner gives you, and what it does not

The runner hands youUsable for SAC?
actor — a stochastic policy, num_actions outYes. Use SquashedGaussianDistribution so actions are bounded, which SAC assumes
critic — a deterministic scalar, obs inNo. SAC needs twin Q(s, a), so the input is obs plus action. Build your own and leave this one unused
storage — a RolloutBuffer sized num_steps_per_envNo. It is cleared at the end of every update(), so it cannot hold a replay history. Bring your own
obs_groups, device, env, multi_gpu_cfgYes, unchanged

The collection loop still calls act then process_env_step for num_steps_per_env steps, then compute_returns, then update once. SAC maps onto that as collect a chunk, then do N gradient steps from the replay buffer — which is how most parallel-environment SAC implementations behave anyway. compute_returns becomes a no-op: GAE has no meaning off-policy.

Pre-allocate your replay buffer, do not append to a list

Collection runs inside torch.inference_mode(), so every tensor produced there is an inference tensor and cannot later be saved for backward. update() runs outside it, so a buffer of appended inference tensors blows up with Inference tensors cannot be saved for backward the first time a Q network differentiates one.

RolloutBuffer avoids this by allocating its tensors up front and copy_-ing into them, and your replay buffer has to do the same.

The config

The runner rejects an algorithm config that is not a PPOConfig or a subclass, so SAC's config subclasses it. You inherit fields SAC does not use — clip_param, lam, desired_kl — which is the price of that check.

python
from dataclasses import dataclass

from telekinesis.rlbotics.config import PPOConfig


@dataclass
class SACConfig(PPOConfig):
    """PPOConfig plus what SAC needs. Inherited PPO-only fields are ignored."""

    tau: float = 0.005                  # Polyak coefficient for the target critics
    init_alpha: float = 0.2             # Initial entropy temperature
    learn_alpha: bool = True            # Tune the temperature against a target entropy
    target_entropy: float | None = None # Defaults to -num_actions when None
    replay_capacity: int = 1_000_000    # Transitions kept, across all environments
    batch_size: int = 256
    gradient_steps: int = 64            # Updates per learning iteration
    learning_starts: int = 1_000        # Transitions to collect before updating at all

    def __post_init__(self) -> None:
        super().__post_init__()
        if not 0.0 < self.tau <= 1.0:
            raise ValueError(f"'tau' must be in (0, 1], got {self.tau}.")
        if self.init_alpha <= 0.0:
            raise ValueError(f"'init_alpha' must be positive, got {self.init_alpha}.")
        for name in ("replay_capacity", "batch_size", "gradient_steps"):
            if getattr(self, name) <= 0:
                raise ValueError(f"'{name}' must be positive, got {getattr(self, name)}.")

The algorithm

python
import copy

import torch
from tensordict import TensorDict

from telekinesis.rlbotics.config import MLPConfig
from telekinesis.rlbotics.models import MLPModel


class ReplayBuffer:
    """A flat, pre-allocated replay buffer over all environments."""

    def __init__(self, capacity, num_envs, obs_dim, num_actions, device):
        self.capacity, self.num_envs, self.device = capacity, num_envs, device
        shape = (capacity, obs_dim)
        # Allocated here, outside inference mode, so update() can differentiate what it reads
        self.obs = torch.zeros(shape, device=device)
        self.next_obs = torch.zeros(shape, device=device)
        self.actions = torch.zeros(capacity, num_actions, device=device)
        self.rewards = torch.zeros(capacity, 1, device=device)
        self.dones = torch.zeros(capacity, 1, device=device)
        self.position, self.size = 0, 0

    def add(self, obs, actions, rewards, next_obs, dones):
        """Copy one step from every environment in, wrapping around at capacity."""
        index = (torch.arange(self.num_envs, device=self.device) + self.position) % self.capacity
        self.obs[index] = obs
        self.actions[index] = actions
        self.rewards[index] = rewards.view(-1, 1)
        self.next_obs[index] = next_obs
        self.dones[index] = dones.view(-1, 1)
        self.position = int((self.position + self.num_envs) % self.capacity)
        self.size = min(self.size + self.num_envs, self.capacity)

    def sample(self, batch_size):
        index = torch.randint(0, self.size, (batch_size,), device=self.device)
        return self.obs[index], self.actions[index], self.rewards[index], self.next_obs[index], self.dones[index]


class SAC:
    """Soft Actor-Critic against the runner's algorithm protocol."""

    def __init__(self, cfg, actor, critic, storage, obs_groups=None, device="cpu",
                 multi_gpu_cfg=None, env=None):
        self.cfg, self.device, self.obs_groups = cfg, device, obs_groups
        self.actor = actor.to(device)

        # The runner's critic is V(s); SAC needs Q(s, a), so build twin critics of our own
        obs_dim = actor.input_dim
        num_actions = env.num_actions
        q_cfg = MLPConfig(hidden_dims=(256, 256), activation="elu", obs_normalization=False)
        self.q1 = MLPModel(q_cfg, input_dim=obs_dim + num_actions, output_dim=1).to(device)
        self.q2 = MLPModel(q_cfg, input_dim=obs_dim + num_actions, output_dim=1).to(device)
        self.q1_target = copy.deepcopy(self.q1)
        self.q2_target = copy.deepcopy(self.q2)

        self.log_alpha = torch.tensor(cfg.init_alpha, device=device).log().requires_grad_(cfg.learn_alpha)
        self.target_entropy = cfg.target_entropy if cfg.target_entropy is not None else -float(num_actions)

        self.actor_optimizer = torch.optim.Adam(self.actor.parameters(), lr=cfg.learning_rate)
        self.critic_optimizer = torch.optim.Adam(
            list(self.q1.parameters()) + list(self.q2.parameters()), lr=cfg.learning_rate
        )
        self.alpha_optimizer = torch.optim.Adam([self.log_alpha], lr=cfg.learning_rate)

        self.replay = ReplayBuffer(cfg.replay_capacity, env.num_envs, obs_dim, num_actions, device)
        self.learning_rate = cfg.learning_rate      # read by the runner for logging
        self.diagnostics = {}                       # published as Diagnostics/<key>
        self._pending = None                        # the obs/action awaiting its outcome

    # --- collection -------------------------------------------------------------------

    def get_obs_set(self, obs, set_name):
        groups = self.obs_groups[set_name]
        if len(groups) == 1:
            return obs[groups[0]]
        return torch.cat([obs[group] for group in groups], dim=-1)

    def act(self, obs: TensorDict) -> torch.Tensor:
        actor_obs = self.get_obs_set(obs, "actor")
        actions = self.actor(actor_obs, stochastic=True).detach()
        self._pending = (actor_obs, actions)
        return actions

    def process_env_step(self, obs, rewards, dones, extras) -> None:
        actor_obs, actions = self._pending
        self.actor.update_normalization(self.get_obs_set(obs, "actor"))

        # A time-limit truncation is not a real terminal state, so it must not stop the bootstrap
        terminal = dones.float()
        if "time_outs" in extras:
            terminal = (dones & ~extras["time_outs"].to(dones.device)).float()

        self.replay.add(actor_obs, actions, rewards, self.get_obs_set(obs, "actor"), terminal)

    def compute_returns(self, obs) -> None:
        """No-op: GAE has no meaning off-policy."""

    # --- update -----------------------------------------------------------------------

    def update(self) -> dict[str, float]:
        if self.replay.size < self.cfg.learning_starts:
            return {"critic": 0.0, "actor": 0.0, "alpha": 0.0}

        alpha = self.log_alpha.exp().detach()
        totals = {"critic": 0.0, "actor": 0.0, "alpha": 0.0}

        for _ in range(self.cfg.gradient_steps):
            obs, actions, rewards, next_obs, dones = self.replay.sample(self.cfg.batch_size)

            # Critics: regress onto the entropy-regularized soft Bellman target
            with torch.no_grad():
                next_actions = self.actor(next_obs, stochastic=True)
                next_log_prob = self.actor.get_output_log_prob(next_actions)
                next_q = torch.min(
                    self.q1_target(torch.cat([next_obs, next_actions], dim=-1)),
                    self.q2_target(torch.cat([next_obs, next_actions], dim=-1)),
                )
                target = rewards + self.cfg.gamma * (1.0 - dones) * (next_q - alpha * next_log_prob)

            q_input = torch.cat([obs, actions], dim=-1)
            critic_loss = (
                torch.nn.functional.mse_loss(self.q1(q_input), target)
                + torch.nn.functional.mse_loss(self.q2(q_input), target)
            )
            self.critic_optimizer.zero_grad()
            critic_loss.backward()
            torch.nn.utils.clip_grad_norm_(
                list(self.q1.parameters()) + list(self.q2.parameters()), self.cfg.max_grad_norm
            )
            self.critic_optimizer.step()

            # Actor: maximize Q minus the entropy penalty
            fresh_actions = self.actor(obs, stochastic=True)
            log_prob = self.actor.get_output_log_prob(fresh_actions)
            q_input = torch.cat([obs, fresh_actions], dim=-1)
            actor_loss = (alpha * log_prob - torch.min(self.q1(q_input), self.q2(q_input))).mean()
            self.actor_optimizer.zero_grad()
            actor_loss.backward()
            torch.nn.utils.clip_grad_norm_(self.actor.parameters(), self.cfg.max_grad_norm)
            self.actor_optimizer.step()

            # Temperature: drive the policy's entropy towards the target
            alpha_loss = torch.zeros((), device=self.device)
            if self.cfg.learn_alpha:
                alpha_loss = -(self.log_alpha.exp() * (log_prob.detach() + self.target_entropy)).mean()
                self.alpha_optimizer.zero_grad()
                alpha_loss.backward()
                self.alpha_optimizer.step()
                alpha = self.log_alpha.exp().detach()

            # Polyak-average the targets
            with torch.no_grad():
                for online, target_net in ((self.q1, self.q1_target), (self.q2, self.q2_target)):
                    for p, p_target in zip(online.parameters(), target_net.parameters()):
                        p_target.mul_(1.0 - self.cfg.tau).add_(self.cfg.tau * p)

            totals["critic"] += critic_loss.item()
            totals["actor"] += actor_loss.item()
            totals["alpha"] += float(alpha_loss)

        self.diagnostics = {"alpha": float(alpha), "replay_size": float(self.replay.size)}
        return {key: value / self.cfg.gradient_steps for key, value in totals.items()}

    # --- the rest of the protocol -------------------------------------------------------

    def train_mode(self):
        for model in (self.actor, self.q1, self.q2):
            model.train()

    def eval_mode(self):
        for model in (self.actor, self.q1, self.q2):
            model.eval()

    def get_policy(self):
        """The actor, which is what export() ships."""
        return self.actor

    def get_action_std(self):
        return float(self.actor.output_distribution_params[1].mean()) if hasattr(
            self.actor, "output_distribution_params"
        ) else None

    def save(self) -> dict:
        return {
            "actor_state_dict": self.actor.state_dict(),
            "q1_state_dict": self.q1.state_dict(),
            "q2_state_dict": self.q2.state_dict(),
            "log_alpha": self.log_alpha.detach(),
        }

    def load(self, loaded_dict, load_cfg, strict) -> bool:
        self.actor.load_state_dict(loaded_dict["actor_state_dict"], strict=strict)
        self.q1.load_state_dict(loaded_dict["q1_state_dict"], strict=strict)
        self.q2.load_state_dict(loaded_dict["q2_state_dict"], strict=strict)
        self.q1_target, self.q2_target = copy.deepcopy(self.q1), copy.deepcopy(self.q2)
        self.log_alpha = loaded_dict["log_alpha"].clone().requires_grad_(self.cfg.learn_alpha)
        return True

    def compile(self, mode=None):
        """No-op: compiling is optional, and the runner only asks."""

    def broadcast_parameters(self):
        raise NotImplementedError("SAC here is single-device.")

    def reduce_parameters(self):
        raise NotImplementedError("SAC here is single-device.")

Running it

python
cfg = OnPolicyRunnerConfig(
    obs_groups={"actor": ["observation"], "critic": ["observation"]},
    num_learning_iterations=2000,
    # Off-policy wants short collection chunks between updates
    num_steps_per_env=8,
    algorithm=SACConfig(class_name=SAC, learning_rate=3e-4, batch_size=256, gradient_steps=64),
    actor=MLPConfig(
        hidden_dims=(256, 256),
        obs_normalization=True,
        # Squashed, so actions stay inside the action range SAC assumes
        distribution_cfg=GaussianDistributionConfig(class_name="SquashedGaussianDistribution"),
    ),
    critic=MLPConfig(hidden_dims=(256, 256)),      # built, unused: SAC makes its own Q networks
)
runner = create_runner(env=env, runner_cfg=cfg, device="cuda:0")
runner.learn()
runner.export()          # unchanged: export ships get_policy(), which is the actor

Two things worth noting about this. Deployment does not changeexport() calls get_policy(), so the ONNX file is the same shape as a PPO one and Deploy a Policy applies verbatim. And critic is still built from the config and handed over, costing one unused network; the runner has no way to skip it.

What you inherit from PPO's implementation by not writing SAC — and therefore have to supply yourself above — is the same short list every from-scratch algorithm faces: observation-set selection, the normalizer updates that keep the exported policy honest, time-limit handling from extras["time_outs"], and a checkpoint dict that save(), load() and export() agree on.

Replacing the runner instead

If what you need to change is the loop rather than the update — a different collection schedule, an off-policy runner, extra bookkeeping per iteration — subclass the runner instead of the algorithm. runner.class_name is a real extension point, and a more permissive one than the algorithm's:

python
from telekinesis.rlbotics.config import OnPolicyRunnerConfig
from telekinesis.rlbotics.runner import OnPolicyRunner, create_runner


class MyRunner(OnPolicyRunner):
    """Same protocol, different loop."""


cfg = OnPolicyRunnerConfig(
    obs_groups={"actor": ["policy"], "critic": ["policy"]},
    # The class itself, a registered name, or an import path — all three work here
    class_name="my_pkg.runner:MyRunner",
)
runner = create_runner(env=env, runner_cfg=cfg, device="cuda:0")

create_runner tries RUNNERS first — which holds {"OnPolicyRunner": OnPolicyRunner} and is meant to grow — and falls back to importing a "module:Class" path, so a custom runner has to be importable but not registered. That is the one place where the string form works, and the reason it works there and not for the algorithm.

create_runner, not the class

OnPolicyRunner(...) always builds that one class, so class_name is silently ignored when you construct the runner directly. Only create_runner reads it.

Things that are not extension points

ThingStatus
rnd_cfg (Random Network Distillation)Configured for but not implemented — setting it raises. Do not treat it as a hook
Symmetry with a recurrent policyRejected at construction
CUDA-graph torch.compile modesRejected: incompatible with the multi-model forward pattern the algorithms use
A non-PPOConfig algorithm configRejected with Unsupported algorithm config type. Subclass PPOConfig rather than writing a config from scratch
Back to the full option list
Every configuration field with its default, and how the config tree fits together.
Configuration →