Using the tuned PIDs¶
Every environment ships a tuned PID controller. It is the baseline every published number is measured against, so it is useful in three ways: as a working controller to run, as the reference your agent's score is paired against, and as a prior to build learning on. This page shows where to get it, how to run it, how to evaluate against it, and how to plug it into training.
Where to get one¶
target_gym.make_expert builds it by registry name:
import target_gym as tg
from target_gym.registry import REGISTRY
ctrl = tg.make_expert("cstr") # the pure controller
pid = ctrl.host() # the form the baselines are scored with
spec = REGISTRY["cstr"]
print(spec.has_pid, spec.baselines_note or "no caveat")
All 24 environments ship one (spec.has_pid is true everywhere). Where a
baseline comes with a caveat, spec.baselines_note states it. The gains were
tuned by scripts/tune_pid.py and live in
src/target_gym/data/pid_gains.json; you do not need to read that file to use
them. Baselines describes how each one is structured and tuned.
The interface¶
A PID remembers its integral and its last error, so something has to hold that memory from one step to the next. Pick the form by where your loop runs:
| Your loop | Use | Each step | At each episode start |
|---|---|---|---|
Python, NumPy, outside jax.jit |
pid = tg.make_expert(name).host(), which keeps its own memory |
action = pid(obs) |
pid.reset() |
jit, vmap, lax.scan, GPU |
ctrl = tg.make_expert(name); you carry its memory |
action, pid_state = ctrl.act(pid_state, obs) |
pid_state = ctrl.init_state |
For many environments at once, policy = tg.make_expert(name).batched() is the
second form already batched: start from pid_state = policy.init_state(num_envs),
then actions, pid_state = policy(pid_state, obs_batch).
Always pass the state to the batched expert
policy(obs), with one argument, starts from a fresh state on
every call. It never builds an integral, so it is not the tuned PID and
does not reproduce its scores.
Both forms act alike and return the action in the environment's own units, already clipped to its bounds. They read the observation only, never the hidden state, which is what makes the PID a fair peer for a learned policy. (The MPC reads the true state, so it is an upper bound, not a peer.)
Two rules keep the memory right:
- Reset it at the start of every episode, or the previous episode's integral leaks in. In a jitted loop nothing does this for you; see Episode boundaries.
- Call
pid(obs)exactly once per environment step. Each call advances its memory, so an extra call, for logging or on replayed observations, counts as a step that never happened.ctrl.actadvances only the state you keep.
Running it in a loop¶
The host object, as the baselines run it:
import jax
import numpy as np
import target_gym as tg
from target_gym.benchmark import episode_keys
from target_gym.registry import REGISTRY
spec = REGISTRY["cstr"]
env = spec.make_env()
params = spec.make_test_params() # the configuration the baselines are scored on
pid = tg.make_expert("cstr").host()
# Seed 0's keys as the scored episodes take them: one to reset, a fresh one
# every step.
T = int(params.max_steps_in_episode)
reset_keys, step_keys = episode_keys(jax.random.PRNGKey(0), 1, T)
obs, state = env.reset_env(reset_keys[0], params)
pid.reset()
total = 0.0
for t in range(T):
action = pid(np.asarray(obs))
obs, state, reward, terminated, _ = env.step_env(step_keys[t, 0], state, action, params)
total += float(reward)
if terminated:
break
print("PID return:", total)
Use spec.make_test_params() rather than the default params. On every
aircraft task and the reactor the defaults run a longer episode, and some
aircraft tasks also set a different reference, so only the test params
reproduce the scored numbers.
Evaluating against it¶
Episode returns, paired on seeds¶
The recorded PID returns are in src/target_gym/data/baseline_returns/,
one per seed. runners.rollout is the loop they were recorded with, so running
your policy through it on the same seeds gives a paired comparison without
re-running the PID:
import numpy as np
from target_gym.provenance import load_recorded_baselines
from target_gym.registry import REGISTRY
from target_gym.runners.runners import baseline_policy, rollout
spec = REGISTRY["cstr"]
params = spec.make_test_params()
recorded = load_recorded_baselines()["cstr"]
seeds = range(recorded["seeds"])
def episode_return(make_policy, seed):
return float(np.sum(rollout(spec, params, make_policy(), seed=seed)[2]))
# A fresh, reset PID per episode reproduces the recorded returns exactly.
pid_returns = [episode_return(lambda: baseline_policy(spec, "pid", params), s) for s in seeds]
assert np.allclose(pid_returns, recorded["pid_returns"])
# Your policy goes in the same slot: any callable obs -> action
# (or (obs, state) -> action) built fresh for each episode.
mine = [episode_return(lambda: baseline_policy(spec, "pid", params), s) for s in seeds]
wins = sum(m > p for m, p in zip(mine, recorded["pid_returns"]))
print(f"beats the PID on {wins} of {len(mine)} seeds")
The protocol score¶
Returns mix the cost of reaching a target with the cost of holding it, in a
proportion set by the episode length. The RL protocol
scores a policy against the expert on three numbers: how many times cheaper it
is over the episode (ratio, with the share of seeds it wins), where it sits
between the expert (0) and the ceiling (1) while holding (score), and how much
room the task leaves (headroom, in decades). One call runs your policy and the
expert on the same seeds:
from target_gym.registry import REGISTRY
from target_gym.runners.runners import baseline_policy
from target_gym.scoring import score_policy
name = "cstr"
spec = REGISTRY[name]
params = spec.make_test_params()
make_policy = lambda: baseline_policy(spec, "pid", params) # swap in your own
m = score_policy(name, make_policy, seeds=3) # 10 for a result
print(f"{m['ratio']:.3g}x cheaper than the expert on {m['win_rate']:.0%} of seeds")
print(f"score {m['score']:.2f}, room {m['headroom']:.2f} decades")
With the expert itself in the slot, the ratio comes out at 1 and the score at
0. A score of nan means the expert already tracks within the instrument's
resolution, so the task has no room to score.
Using it in learning¶
As a residual base¶
The structure the RL protocol treats as primary: the agent learns a bounded correction on top of the PID, so a zero correction recovers the PID exactly.
import numpy as np
import target_gym as tg
from target_gym.registry import REGISTRY
spec = REGISTRY["cstr"]
env, params = spec.make_env(), spec.make_test_params()
space = env.action_space(params)
low, high = np.asarray(space.low), np.asarray(space.high)
alpha = 0.25 * (high - low) / 2 # the correction spans a quarter of the range
def residual_policy(pid, correction):
"""``correction(obs)`` is your network's output, in [-1, 1]."""
def act(obs):
return np.clip(pid(obs) + alpha * correction(obs), low, high)
return act
pid = tg.make_expert("cstr").host()
pid.reset()
act = residual_policy(pid, lambda obs: np.zeros_like(low)) # untrained: the PID
The protocol sweeps the scale over 0.1, 0.25 and 1.0 and reports all three, so a reader can tell "learning improved the PID" from "learning ignored it".
Because the PID has memory, three things matter for a residual agent:
- Store the PID's action with each transition. It depends on the PID's
memory, not only on
obs, so recomputing it from a replayed observation gives a different action and disturbs the live controller. - Feed the PID's action to the network. The integral is not in
obs, so without it the agent cannot see what the base is about to do. - Watch for windup at large scales. The PID cannot see the correction. When the correction pushes the sum past a bound, the PID's integral keeps growing. At zero correction it is exactly the PID.
As demonstrations¶
To pre-fill a replay buffer or pretrain by behavioural cloning, record the PID's own transitions:
import jax
import numpy as np
import target_gym as tg
from target_gym.registry import REGISTRY
spec = REGISTRY["cstr"]
env, params = spec.make_env(), spec.make_test_params()
pid = tg.make_expert("cstr").host()
transitions = []
for seed in range(3):
key, reset_key = jax.random.split(jax.random.PRNGKey(seed))
obs, state = env.reset(reset_key, params)
pid.reset()
for _ in range(params.max_steps_in_episode):
key, step_key = jax.random.split(key) # a fresh key every step
action = np.atleast_1d(pid(np.asarray(obs)))
next_obs, state, reward, terminated, truncated, _ = env.step(step_key, state, action, params)
transitions.append((np.asarray(obs), action, float(reward), np.asarray(next_obs)))
obs = next_obs
if terminated or truncated:
break
print(len(transitions), "transitions")
Inside a jitted loop, on GPU¶
A fully jitted learner (a jax.lax.scan over environment steps, as in
PureJaxRL-style agents) uses the pure form from The interface
and keeps pid_state in the scan's carry, which is where the integral lives.
It matches the host object up to float32 rounding, close enough to reproduce
the recorded returns, as the example checks.
import jax
import numpy as np
import target_gym as tg
from target_gym.benchmark import episode_keys
from target_gym.provenance import load_recorded_baselines
from target_gym.registry import REGISTRY
spec = REGISTRY["cstr"]
env, params = spec.make_env(), spec.make_test_params()
ctrl = tg.make_expert("cstr")
def episode_return(key):
# The scored episodes' keys: one to reset, a fresh one every step.
reset_keys, step_keys = episode_keys(key, 1, int(params.max_steps_in_episode))
obs, env_state = env.reset_env(reset_keys[0], params)
def step(carry, step_key):
obs, env_state, pid_state = carry
action, pid_state = ctrl.act(pid_state, obs)
obs, env_state, reward, _, _ = env.step_env(step_key, env_state, action, params)
return (obs, env_state, pid_state), reward
carry = (obs, env_state, ctrl.init_state)
_, rewards = jax.lax.scan(step, carry, step_keys[:, 0])
return rewards.sum()
keys = jax.numpy.stack([jax.random.PRNGKey(s) for s in range(10)])
returns = jax.jit(jax.vmap(episode_return))(keys) # ten episodes at once
recorded = load_recorded_baselines()["cstr"]["pid_returns"]
assert np.allclose(returns, recorded, rtol=1e-3) # the host-scored numbers
For a batch of environments, tg.make_expert(name).batched() does the same
with one state per environment, as in the next example.
Episode boundaries¶
Nothing calls pid.reset() inside a scan. env.step restarts an environment
when its episode ends, but the pid_state you carry keeps the old integral
unless you reset it too. reset_where_done does that, for one environment or a
batch:
import jax
import target_gym as tg
from target_gym.experts import reset_where_done
from target_gym.registry import REGISTRY
spec = REGISTRY["cstr"]
env, params = spec.make_env(), spec.make_test_params()
policy, num_envs = tg.make_expert("cstr").batched(), 4
key, reset_key = jax.random.split(jax.random.PRNGKey(0))
obs, env_state = jax.vmap(env.reset, in_axes=(0, None))(
jax.random.split(reset_key, num_envs), params
)
step_all = jax.vmap(env.step, in_axes=(0, 0, 0, None))
def step(carry, step_key):
obs, env_state, pid_state = carry
actions, pid_state = policy(pid_state, obs)
keys = jax.random.split(step_key, num_envs) # a fresh key every step
obs, env_state, reward, terminated, truncated, _ = step_all(keys, env_state, actions, params)
# env.step has already restarted the environments that finished; restart their PIDs too.
pid_state = reset_where_done(terminated | truncated, pid_state, policy.init_state(num_envs))
return (obs, env_state, pid_state), reward
carry = (obs, env_state, policy.init_state(num_envs))
n_steps = 2 * int(params.max_steps_in_episode) # two episodes per environment
run = jax.jit(lambda c, k: jax.lax.scan(step, c, jax.random.split(k, n_steps)))
_, rewards = run(carry, key)
For a single environment, pass a scalar done and ctrl.init_state. Reset on
terminated | truncated only: in a continuing environment a trip restarts the
plant without raising either flag, and the PID keeps its memory through it, as
the scored baselines do.