ctde_v0.ppo¶
ppo
¶
MAPPO-CTDE trainer for the grounded v0 agent.
PPO optimizes the GOAL policy (the L3 goal head), NOT raw moves. Each step:
backbone(obs, kb_adj) -> belief z_i -> goal_logits (N,K) └─ [mechanism] action_mask: mask candidate goals whose greedy first move would drop true λ₂ below the floor (forbid-disconnect); soft_lambda: no mask, a λ-penalty enters the reward instead. └─ sample goal index g_i ~ masked-softmax(goal_logits) (PPO action) └─ goal cell = pos + stencil[g_i] └─ L1 greedy controller -> env move (only valid moves, STAY fallback) └─ env.step(move) -> reward terms, true λ₂, coverage
GAE runs on the centralized critic (team reward + team value = CTDE). Total loss:
total = PPO(goal) + vf_coefvalue + aux_betaaux(λ̂₂, λ₂_true) + degree_regVar_batch(mean-degree) - entropy_coefH(goal)
The aux head is supervised against the simulator's true λ₂ (mse | huber knob); the degree regularizer (SizeShiftReg-style) penalizes the across-batch variance of the per-node aggregated degree statistic to protect GNN size-transfer.
All JAX/Equinox: eqx.filter_jit rollouts + update; optax AdamW
(decoupled weight_decay) + global-norm clip. CPU-friendly (JAX_PLATFORMS=cpu).
Shapes (T horizon, B rollouts, N agents, K candidate goals): obs (B,T,N,C,H,W) central (B,T,Cg,H,W) goal (B,T,N) goal index sampled (PPO action) goal_logp (B,T,N) masked log-prob of the sampled goal goal_mask (B,T,N,K) the safe-goal mask used at sample time (replayed) rew_agent (B,T,N) composed reward rew_team (B,T) mean-over-agents v_team (B,T) centralized critic value true_l2 (B,T) true Fiedler value (aux target, broadcast to agents) l2_hat (B,T,N) per-agent local λ̂₂ estimate (head output) degree (B,T,N) per-node comm degree (degree regularizer input)
DualState
¶
Bases: Module
Functional state for the ADAPTIVE connectivity mechanisms — carried THROUGH
jax.lax/filter_jit in :class:TrainState (never host-side mutation), so
the dual variable survives the jitted update.
lam— the dual variable λ ≥ 0 (the connectivity-penalty weight the rollout reads; updated each PPO iteration by dual ascent / PID).integral— PID integral term Σ v (pid_lagrangian only; 0 otherwise).prev_v— previous iteration's violation, for the PID derivative term.
Inert for action_mask / soft_lambda (λ never enters their reward, and the update is gated to the two adaptive mechanisms) — so I1 behaviour is unchanged.
collect
¶
collect(env, actor, critic, cfg, stencil, key, dual_lambda, mvplanner=None)
Vmap _single_rollout over B seeds -> batched trajectory (leading B,T).
dual_lambda (scalar) is the current train-state dual variable, broadcast to
every rollout (it weights the adaptive connectivity penalty; see
:func:_single_rollout). mvplanner (default None) is the frozen learned L1
planner used only when action_head.controller == 'mvprop'.
Source code in experiments/ctde_v0/ppo.py
467 468 469 470 471 472 473 474 475 476 477 | |
compute_agent_advantages
¶
compute_agent_advantages(traj, cfg, reward_field='rew_agent')
(B,T,N) PER-AGENT advantage for the v3 per-agent credit schemes. Each agent's OWN
reward traj[reward_field][:,:,i] runs through GAE with the SHARED team value
v_team as the baseline (a state-only baseline -> UNBIASED for the per-agent policy
gradient, it only reshapes variance) and the team v_last bootstrap. The critic keeps
training on the team return (:func:compute_advantages); only the POLICY advantage
becomes per-agent, so each agent is credited for ITS contribution instead of the team
mean. vmap over B (episodes) then over N (agents).
reward_field selects the per-agent reward source (a mutually-exclusive credit axis):
- "rew_agent" (default) — the full per-agent composed reward, used by the top-level
cfg.credit == 'agent' axis (the balthar A2 experiment).
- "rew_agent_credit" — the EXACT submodular difference-reward variant (coverage
magnitude swapped new_coverage_i -> D_i, uniquely-provided new cells), used by
cfg.loss.credit == 'difference'; each agent's policy gradient uses its own
marginal coverage contribution while the connectivity/collision terms stay shared.
Source code in experiments/ctde_v0/ppo.py
505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 | |
loss_fn
¶
loss_fn(actor, critic, batch, cfg, key)
Total loss = PPO(goal) [+ PPO(role)] + vfvalue + betaaux + degreeReg
- ent*(goal entropy [+ role entropy]). The role terms are added ONLY when
role_picker == 'expl_relay' (off -> identical to v0).
Recurrence: with backbone.recurrence == 'recurrent' the minibatch arrives as
a batch of EPISODES (leading (B,T)); the actor is re-applied along each trajectory
via a per-episode scan (_actor_forward_recurrent) so the per-step hidden is
recomputed under the current params, then everything is flattened to (M=B*T) and
the rest of the loss is shape-identical to the feedforward path. The feedforward
path keeps the flat (M,...) minibatch and the per-row vmap forward EXACTLY as
before (byte-unchanged).
Selector (selector == 'on'): a NEW gated path mirroring the goal+role two-action
PPO math EXACTLY, only the second action is the SKILL (over {disperse,flock,hold})
rather than the role. The forward is the selector variant
(_actor_skill_forward_{ff,recurrent}) which returns the per-step skill-logits AND
all three skills' (N,K) offset-logits; the loss (a) sources the OFFSET log-prob from the
SELECTED skill's offset-logits (gather per the stored skill) — so the clipped-PPO
ratio for the goal action starts at exactly 1 — and (b) adds the SKILL clipped-PG +
entropy (a clone of the role-PG block). Roles are off when the selector is on
(selector supersedes the role picker). With selector == 'off' (default) this whole
branch is dead and the loss is byte-identical to v0.
Source code in experiments/ctde_v0/ppo.py
702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 | |
init_dual
¶
init_dual(cfg)
Initial dual state: λ = mission_safety.lambda_init (scalar f32), integral
and prev-error 0. Same shape regardless of mechanism (jit-stable).
Source code in experiments/ctde_v0/ppo.py
907 908 909 910 911 912 | |
dual_update
¶
dual_update(dual, violation, cfg)
One dual step from the realized connectivity violation
v = relu(τ − mean_rollout(true λ₂)) ≥ 0. Pure / jit-safe.
- lagrangian — dual ASCENT:
λ_next = relu(λ + lambda_lr · v). - pid_lagrangian — PID (Stooke et al. 2020):
integral += v;λ = relu(kp·v + ki·integral + kd·(v − prev_v)); carryprev_v = v. - else — returned unchanged (action_mask / soft_lambda inert).
Source code in experiments/ctde_v0/ppo.py
915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 | |
init_state_from_checkpoint
¶
init_state_from_checkpoint(env, cfg, ckpt_path, key)
Warm-start the train state from a previously-saved (actor, critic).
The scale-strategy entry point. The LPAC backbone + heads are
scale-invariant by construction: the (actor, critic) parameter shapes depend
only on the obs/central CHANNELS, width/depth/mp_rounds, the goal
head K and n_roles — NOT on the grid size or agent count (those only
set runtime tensor dims via same-padding conv + global-average-pool + the
per-agent vmap). So a model saved at one rung (e.g. 16²/4) loads into a fresh
skeleton built for the NEXT rung (e.g. 32²/10) with byte-identical param
shapes — only --grid / --n-agents / --comm-r differ, none of which
appear in the params.
We build a fresh Actor/Critic from the CURRENT cfg (so K / width / depth /
mp_rounds / agg / message_content / explorer_tool / compass / recurrence all
match the run you're launching), then eqx.tree_deserialise_leaves the saved
params INTO that skeleton. Deserialise validates leaf shapes against the
template, so a mismatched backbone (different width / channels / K) raises here
rather than corrupting the run; we additionally assert leaf-count + per-leaf
shape compatibility up front with a clear scale-strategy error message.
Optimizer state is NOT carried. Adam's moment estimates are tied to the
previous rung's loss landscape (and we deliberately do not serialise opt_state
in the deployable model.eqx snapshot), so we warm-start the POLICY and
re-initialise a FRESH optimizer on the loaded params (opt.init(params)).
The dual variable is likewise re-initialised from cfg (a per-run safety
knob, not a learned weight). Net effect: same architecture, transplanted
weights, clean Adam moments + clean dual — exactly the warm-start-ladder rung
hand-off.
Source code in experiments/ctde_v0/ppo.py
1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 | |
train_step
¶
train_step(env, state, cfg, key, opt, stencil, mvplanner=None)
One PPO iteration: collect -> GAE -> ppo_epochs of minibatch updates ->
dual update (adaptive mechanisms). The dual variable read at rollout time is
the CURRENT state.dual.lam; it is updated AFTER the policy step from the
realized connectivity violation and carried forward in the returned state.
Source code in experiments/ctde_v0/ppo.py
1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 | |
train
¶
train(env, cfg, *, key=None, log_fn=None, init_from=None, mvplanner=None)
Full training loop over cfg.iters PPO iterations.
log_fn(it, host_logs) is called each iteration. Returns (TrainState, history).
init_from (the scale-strategy / warm-start dial): if a path is given, the
train state is warm-started from that saved (actor, critic) snapshot via
:func:init_state_from_checkpoint (scale-invariant cross-rung load; fresh
optimizer + dual) instead of a random init. None (default) = random init,
byte-identical to before this option existed (the init_from branch is never
touched, so the RNG draw / param surface is unchanged).
Source code in experiments/ctde_v0/ppo.py
1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 | |