ctde_v0.es¶
es
¶
Evolution-Strategies (ES) coexistence trainer — evolve the SELECTOR, gradient the executor.
This module is the ES half of a two-level (feudal) cognition agent: a SMALL selector
head (it picks among skills / sets a sub-goal) sits on top of a LARGE gradient-trained
executor (the LPAC backbone + heads in nets.Actor, trained by CTDE-MAPPO in ppo.py).
The agreed division of labour is MERL / feudal-evolutionary:
- gradient / CTDE trains the executor on a dense, per-step signal (PPO on the goal / move policy, with a centralized critic);
- ES evolves the selector by TEAM FITNESS — the whole-mission return (mean episode return over a rollout), the sparse team-level credit signal that gradient methods cannot easily assign to a tiny discrete-ish selector.
Because ES and the gradient touch disjoint parameters (ES → the selector head's leaves,
gradient → everything else), the two optimizers compose: this is the "CTDE+ES coexistence"
of the plan. This file is the ES machinery only, written against an injectable
interface (eval_fn / gradient_step_fn / sync_in / sync_out) so it has no
dependency on the selector's internals — the integrator wires the real fitness + selector
get/set + ppo step later (see the wiring note at the bottom of the docstring). To keep this
file import-safe while the selector core / nets are being edited in parallel, it imports
only jax / jax.numpy / equinox / optax / numpy at module level; any ppo/nets wiring
is left to the integrator (or done lazily inside a closure the integrator supplies).
WHY this split is the right one¶
- The selector is small and its objective is sparse + non-differentiable-friendly (team return; "which skill, when"). ES is black-box: it needs only a scalar fitness, ignores non-differentiability, and — crucially for a swarm that can collapse into a huddle — its Gaussian search provides huddle-escape / plateau-escape that a local gradient cannot (it perturbs the selector globally and selects by team outcome). The antithetic + rank-shaped OpenAI-ES estimator is a low-variance gradient estimate of that sparse fitness, so the selector still moves smoothly.
- The executor is large and has a dense, well-shaped per-step signal (PPO advantages on the goal/move policy). Gradient descent is far more sample-efficient there than ES would be on millions of parameters. So we keep the cheap dense signal where it works and spend the expensive sparse team signal only on the few selector weights that need it.
- They coexist rather than fight because they optimize disjoint leaves of the same
Actor:
merl_coexistinterleaves rounds — gradient trains the executor, the gradient-current selector is injected into the ES mean ("inject the learner", MERL), ES evolves it by team fitness, and the evolved selector is written back into the train state for the next gradient round.
References¶
- Salimans, Ho, Chen, Sidor, Sutskever (2017), Evolution Strategies as a Scalable
Alternative to Reinforcement Learning — the OpenAI-ES estimator used by
kind='nes': antithetic Gaussian sampling + rank-based fitness shaping, updateθ ← θ + lr/(pop·σ) · Σ_i f̃_i · ε_i. - Pourchot & Sigaud (2019), CEM-RL: Combining Evolutionary and Gradient-Based Methods for
Policy Search — the cross-entropy-method side (
kind='cem': keep the elite fraction, re-fit the mean, shrink the covariance) and the template for combining a CEM population with a gradient learner. - Khadka & Tumer (2019) / Khadka et al. (2019), Evolutionary Reinforcement Learning /
Collaborative Evolutionary RL (MERL) — the interleave skeleton in
merl_coexist: evolve one part by sparse team fitness, train the other by gradient on a dense signal, and periodically inject the gradient learner into the evolutionary population so the two reinforce instead of diverge.
ESConfig
dataclass
¶
ESConfig(pop_size=16, sigma=0.05, lr=0.05, kind='nes', elite_frac=0.25, weight_decay=0.0)
Hyper-parameters for the ES step / coexistence loop.
pop_size— population size (number of fitness evals peres_step). With antithetic sampling the actual evaluations are2 * (pop_size // 2).sigma— Gaussian perturbation std (search radius around the meantheta).lr— OpenAI-ES learning rate (kind='nes'only).kind—'nes'(OpenAI-ES rank-shaped gradient estimate) or'cem'(cross-entropy method: elite mean + covariance shrink).elite_frac— fraction of the population kept as elites (kind='cem'only).weight_decay— optional L2 pull ofthetatoward 0 each step (0 disables).
flatten
¶
flatten(pytree)
Ravel any pytree to a 1-D parameter vector and return (theta, unflatten).
theta is a 1-D jnp.float array concatenating every leaf (in tree order);
unflatten(theta) -> pytree rebuilds the original structure. Thin wrapper over
:func:jax.flatten_util.ravel_pytree so the ES core only ever sees flat vectors.
Source code in experiments/ctde_v0/es.py
76 77 78 79 80 81 82 83 84 | |
module_theta
¶
module_theta(module)
Extract the inexact-array (float/complex) leaves of an Equinox module as a
single 1-D vector theta.
Uses eqx.partition(module, eqx.is_inexact_array) to split the trainable float leaves
from everything else (ints, bools, static fields, callables), then ravels only the
float part. This is the exact leaf set the gradient/ES touch (mirrors
ppo._perturb_actor's eqx.is_inexact_array partition), so a theta produced
here round-trips cleanly through :func:set_module_theta.
Source code in experiments/ctde_v0/es.py
87 88 89 90 91 92 93 94 95 96 97 98 99 | |
set_module_theta
¶
set_module_theta(module, theta)
Return a copy of module with its inexact-array leaves replaced by theta.
Inverse of :func:module_theta: partition off the float leaves, rebuild them from the
flat theta with the matching unflatten, and eqx.combine them back with the
untouched static part. set_module_theta(m, module_theta(m)) is the identity.
Source code in experiments/ctde_v0/es.py
102 103 104 105 106 107 108 109 110 111 112 | |
es_step
¶
es_step(theta, eval_fn, es_cfg, key)
One Evolution-Strategies update of the mean parameter vector theta.
Antithetic Gaussian sampling draws half = pop_size // 2 direction pairs
{+ε, −ε} (so P = 2·half candidates), evaluates each at θ + σ·ε via the
injected eval_fn (HIGHER fitness = better), and updates theta by one of:
kind == 'nes'(OpenAI-ES, Salimans 2017): rank-shape the fitnesses to[-0.5, 0.5](_rank_normalize) and take the estimated natural-gradient stepθ ← θ + (lr / (P·σ)) · Σ_i f̃_i · ε_i(with the unscaled directionsε_i). Optionalweight_decayadds a−lr·wd·θpull toward 0.sigmais unchanged.kind == 'cem'(CEM-RL, Pourchot 2019): keep the topelite_fraccandidates by raw fitness, setθ ← mean(elites), and shrinksigmatoward the per-dim elite std (new_sigmareported ininfo).lr/ rank-shaping are unused.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
theta
|
Array
|
(D,) current mean parameter vector. |
required |
eval_fn
|
Callable[[Array], float]
|
|
required |
es_cfg
|
ESConfig
|
:class: |
required |
key
|
PRNG key for the population noise. |
required |
Returns:
| Type | Description |
|---|---|
Array
|
|
dict
|
|
Source code in experiments/ctde_v0/es.py
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 | |
merl_coexist
¶
merl_coexist(*, theta0, eval_fn, gradient_step_fn, sync_in, sync_out, n_outer, grad_steps_per_outer, es_cfg, key, gstate0=None)
The MERL / feudal-evolutionary interleave skeleton — coexist gradient + ES.
Each outer round (Khadka 2019 MERL):
(a) train the executor: call gstate = gradient_step_fn(gstate)
grad_steps_per_outer times (e.g. one ppo.train_step on the executor each);
(b) inject the learner: theta = sync_in(gstate, theta) — read the
gradient-current selector OUT of the train state INTO the ES mean, so ES searches
around the policy gradient has reached (the MERL "inject the gradient learner");
(c) evolve the selector: theta, info = es_step(theta, eval_fn, es_cfg, key_t)
— one ES update of the selector by team fitness;
(d) write back: gstate = sync_out(gstate, theta) — push the evolved selector
BACK into the train state so the next gradient round uses it.
(outer, info) is appended to history each round. Every collaborator is an
injected callable so this file stays decoupled from ppo/nets (the integrator supplies
the real ones; see the module docstring's wiring note). gstate0 defaults to None
when the caller threads the train state purely through gradient_step_fn / sync_*
closures, but is accepted explicitly so a real ppo.TrainState can be passed in.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
theta0
|
Array
|
(D,) initial selector mean (ES parameters). |
required |
eval_fn
|
Callable[[Array], float]
|
team-fitness eval |
required |
gradient_step_fn
|
Callable[[Any], Any]
|
|
required |
sync_in
|
Callable[[Any, Array], Array]
|
|
required |
sync_out
|
Callable[[Any, Array], Any]
|
|
required |
n_outer
|
int
|
number of outer interleave rounds. |
required |
grad_steps_per_outer
|
int
|
executor gradient steps per outer round. |
required |
es_cfg
|
ESConfig
|
:class: |
required |
key
|
PRNG key (split per outer round for the ES population). |
required | |
gstate0
|
Any
|
optional initial gradient/train state (default |
None
|
Returns:
| Type | Description |
|---|---|
Array
|
|
Any
|
|
Source code in experiments/ctde_v0/es.py
272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 | |