Repository navigation
Add student/teacher distillation algorithms - #232
zhenkirito123456 wants to merge 8 commits into
Conversation
Bring the student/teacher distillation stack from jinkunc/php-release-port (4a0bf04) onto main as-is, limited to what distillation itself needs: - Distillation (pure DAgger) and DistillationPPO algorithms, rollout storages - StudentTeacher modules, teacher observation normalizer, depth backbone - Algo config types and registered algo:distillation / algo:distillation-ppo - TrainingConfig.teacher_checkpoint and teacher loading in train_agent - Run-id prefix for W&B checkpoints copied into log_dir, so several teachers with the same checkpoint filename don't overwrite each other Application-specific pieces of the port (preprocess hook, registry options, W&B latest-checkpoint resolution, depth/terrain presets) are left out; they belong to separate PRs or the PHP extension. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
RolloutStorage.mini_batch_generator draws one permutation and reuses it for every epoch. The distillation algorithms were developed and tuned against a storage that reshuffles at the start of each epoch, as the reference implementation does, so they reproduce PHP training only with that order. Add a reshuffle_each_epoch option (default False, so PPO is unchanged) and make it the default for the two distillation storages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Distillation and DistillationPPO assumed a depth student: they required a "depth_camera" observation group with a "depth_cam" term, always built the depth-backbone module variants, and always exported a backbone/student ONNX pair. Both algorithms also duplicated ~400 lines of training-loop, checkpoint, teacher-loading, export, and evaluation code. - Move the shared code into DistillationBase. Each algorithm now only defines its policy, optimizer, storage, rollout, and update. - StudentTeacherModuleConfig.depth_backbone may be set to None, which builds a proprioception-only StudentTeacher(Critic), needs no depth observation group, and exports a single policy file with PPO's input and output names. The default is unchanged. - The depth observation group and term are now config fields (depth_obs_group / depth_obs_term); the defaults keep the old names. - Test the export through onnxruntime directly instead of through the depth inference policy, which is not part of holosoma core yet. With a depth backbone configured, training is bit-identical to before: same losses, weights, optimizer state, checkpoints, exports, and eval actions on a seeded run of both algorithms. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
train_agent now reads training.teacher_checkpoint, so the SimpleNamespace stand-in for TrainingConfig needs the field like the real dataclass has. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mark2000
left a comment
There was a problem hiding this comment.
Reviewed the distillation / distillation-PPO carve-out, focusing on the non-test changes.
No correctness bugs on the live training path: both reachable depth backbones honour depth_output_dim end to end, a width mismatch would fail loudly at the first forward rather than silently, and the teacher-readiness machinery looks sound — require_loaded_teachers() gates both teacher_act() and learn(), the setup() -> load() -> load_teacher() ordering in train_agent.py is deliberate, and pre-metadata checkpoints fall back to all-False so a legacy resume fails loudly instead of training against unloaded teachers.
Three comments below. The one on RecurrentDepthBackbone is the only one I'd treat as needing action before merge; the other two are a cleanup and a nit.
RecurrentDepthBackbone could not be built by the depth backbone factory, which passes only depth_output_dim, and its call signature and output width did not match the student either. Remove it, the unread loaded_teacher property, and the output_activation and num_frames parameters no config can set. The remaining backbones keep their state dicts and outputs, so existing checkpoints load unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ationPPO Both agents carried the same GAE loop, and each had its own multi-GPU advantage normalization. Move both into agents/modules/advantages.py and call it from both. The shared normalizer is PPO's, so PPO is unchanged; DistillationPPO's multi-GPU advantages move by about 1e-6 relative (PPO uses the population variance in float32 and assumes equal shards, which every rank's full rollout satisfies). Single-GPU results are unchanged for both. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
juan-g-bonilla
left a comment
There was a problem hiding this comment.
Thanks for splitting this into a reviewable self-contained PR. My main concerns is that some of the classes/logic that is meant to be generic and easily extensible/maintainable is making some specific assumptions that might make it hard to use in the future.
- added some comments from my agent, feel free to disregard if not relevant.
| self.student = _build_mlp(num_actor_obs, student_hidden_dims, num_actions, activation) | ||
| self.teachers = nn.ModuleList( | ||
| [ | ||
| _build_mlp(self._teacher_in_dim, teacher_hidden_dims, num_actions, activation) | ||
| for _ in range(num_teachers) | ||
| ] | ||
| ) |
There was a problem hiding this comment.
Here you’re building each teacher as a plain MLP (nn.Sequential), but the supplied checkpoint may come from a policy that also includes an encoder.
load_teacher_state_dict renames the checkpoint keys and filters out parameters that aren’t present in the constructed MLP before calling load_state_dict. Consequently, even strict=True cannot detect those discarded parameters.
If the remaining expected keys and tensor shapes match, loading succeeds and marks the teacher as ready, despite omitting its encoder and associated input processing. This can silently change the teacher’s behavior. We should either reconstruct the complete teacher policy or explicitly reject unsupported learned components.
There was a problem hiding this comment.
of course my preference would be to support a wider array of teacher policies
| prefixes = ( | ||
| "actor_module.module.", | ||
| "actor_module.", | ||
| "actor.", | ||
| ) |
There was a problem hiding this comment.
I dont know how I feel with all the prefix/key massaging here because it makes checkpoint compatibility depend on internal naming conventions. Those names can change during a refactor without changing the model's behaviour, and matching parameter names and shapes doesn't really establish that we're reconstructing the same computation.
Here we're already assuming an MLP, then adapting several possible checkpoint layouts to it. I worry that supporting more teacher implementations will mean accumulating special cases in this general class, without an explicit definition of the source teacher.
Could we require the teacher's model type and construction config, either saved with the checkpoint or supplied by the caller? We could then construct that policy and load its weights. PyTorch documents constructing a known model before loading its state dict, and Hugging Face's auto classes normally select the model through its configuration. That's the separation I'd expect here. https://docs.pytorch.org/tutorials/beginner/saving_loading_models.html https://huggingface.co/docs/transformers/en/model_doc/auto
Known older checkpoint formats could still have small, explicit conversion functions outside this class. Each would handle a documented naming change and reject ambiguous mappings. I'd be more comfortable with that than having the general loader accumulate assumptions about the internals of different teacher implementations.
| ``self.teachers[teacher_idx[n]]``. Negative entries mean "no teacher | ||
| applies" and yield a zero action, which callers may use to mark | ||
| samples to exclude from the behaviour-cloning loss. |
There was a problem hiding this comment.
Using an all-zero action to represent “no teacher applies” seems ambiguous, since a valid teacher can also return all zeros. A downstream consumer checking for this sentinel would incorrectly discard those valid responses. A consumer that doesn't handle it would instead treat the missing response as a real zero-action target.
Could we return a separate validity mask alongside the actions and keep it with each sample through to the loss? That would let consumers distinguish a valid zero action from a missing teacher response without inferring it from the action values.
| @property | ||
| def uses_depth(self) -> bool: | ||
| """Whether the student consumes a depth image through a backbone.""" | ||
| return self.config.module.depth_backbone is not None | ||
|
|
There was a problem hiding this comment.
I'm not sure these depth-specific assumptions belong in the shared distillation base class. For example, _extract_obs selects one configured sensor input and exposes it as depth_obs, and get_inference_policy forwards only actor_obs and depth_obs. A policy that also needs rgb_obs would lose that input here, even if the caller supplied it.
The depth export contract also assumes separate depth_backbone and student modules, with the student consuming [actor_obs | depth_latent]. These assumptions describe a particular policy architecture. Supporting another sensor input or a different network composition would therefore require overriding parts of the algorithm as well as implementing the policy.
Could we let the policy declare its required inputs and define its export layout, with the depth-specific handling kept in the depth policy or an adapter? The shared base could then collect and forward those inputs through that interface. I'd prefer that over adding more sensor-specific branches to the base as we support more applications.
| if isinstance(loaded, dict) and "actor_model_state_dict" in loaded: | ||
| # A teacher trained with empirical_normalization only works on normalized obs. | ||
| teacher_ckpt = { | ||
| "model_state_dict": loaded["actor_model_state_dict"], | ||
| "actor_obs_normalizer_state_dict": loaded.get("actor_obs_normalizer_state_dict"), | ||
| } |
There was a problem hiding this comment.
I think this duplicates checkpoint-format handling already in StudentTeacher.load_teacher_state_dict. It also preserves only the actor weights and observation normalizer, so any additional model config or preprocessing state would be discarded before the policy loader sees it.
Could we pass the complete checkpoint through and keep format-specific conversion in a single teacher loader?
- Gated adaptive KL: reduce the weighted KL sum and the weight sum across ranks and divide once, so ranks with fewer gated samples do not count as much as ranks with more. - Restore the saved teacher-slot count before setup() in the evaluation entrypoint too (restore_structure_from_checkpoint), so multi-teacher checkpoints load there. Teacher-count inference moves to agents/distillation/checkpoint.py; train_agent still exports it. - Evaluation extracts only the student's inputs. - current_learning_iteration is the next iteration to run, so repeated learn() calls and resumes continue after the completed iteration. The checkpoint's "iter" stays the last completed iteration, as PPO records it. - Export the depth pair from the policy's export_modules(), which applies _combine_student_input, and fail if the exported pair does not reproduce act_inference. - Expose max_ppo_coef, ppo_active_threshold and the adaptive learning-rate bounds as config, defaulting to the previous constants. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks Juan, these are really helpful. Fixed some, for the remaining, here's what I'd propose before changing code:
Does that match what you had in mind? |
Summary
This is the first core PR split out of #230 (PHP release support), following the review discussion on amazon-far-internal/holosoma-internal#80. It adds the student/teacher distillation algorithms to core and makes them generic. Depth sensing, WBT command changes, inference, and the PHP extension will follow in separate PRs.
Import the distillation stack from php release support #230 as-is:
Distillation(pure DAgger) andDistillationPPO(DAgger blended into PPO on a schedule)StudentTeacher*modulesTrainingConfig.teacher_checkpointand the teacher-count/loading logic intrain_agent.pyalgo:distillationandalgo:distillation-ppopresetslog_dir, so teachers whose checkpoints share a filename don't overwrite each otherLeft out because they are application-specific or belong to later PRs:
preprocess_hook,registry_name,wandb_path, W&B latest-checkpoint resolution, depth/terrain presets.Reshuffle distillation minibatches every epoch.
RolloutStorage.mini_batch_generatorreuses one permutation across epochs. The distillation algorithms were tuned against per-epoch reshuffling, as in the reference implementation. This addsreshuffle_each_epoch(defaultFalse, so PPO is unchanged) and turns it on for the two distillation storages.Make the student's depth input optional:
DistillationBase, which removes about 400 duplicated lines between the two algorithms.StudentTeacherModuleConfig.depth_backbone=Nonebuilds a proprioception-only student. That student needs no depth observation group and exports a single ONNX policy with PPO's input/output names. The default is unchanged, so existing configs behave the same.depth_obs_group/depth_obs_term) instead of hardcoded todepth_camera/depth_cam.Validation
159 passed). New tests cover:act_inferencevia onnxruntimeruffandmypy, run with the pre-commit hook's pinned versions and arguments, are clean on all changed files.4a0bf04). A seeded CPU harness runs completelearn()loops of both algorithms on a fake env, then saves, resumes and continues training, evaluates, and exports. Losses, learning rates,ppo_coef, weights, optimizer state, checkpoints, eval actions, and ONNX outputs are identical with a depth backbone configured, including the PPO phase with adaptive LR. The same holds for the PHP release's subclasses (multi-teacher routing, masked DAgger loss, PPO gate) on a branch of4a0bf04with these commits applied.4a0bf04(231 passed). The one failure,test_sim2sim_launchers, also fails on4a0bf04and is unrelated.4a0bf04vs.4a0bf04+ these commits.depth_backbone.onnx+student.onnx.ppo_coef, LR, entropy, noise std, sample count) match exactly.Loss/behavior0.0457 vs 0.0459 andTrain/mean_reward15.59 vs 15.51.🤖 Generated with Claude Code