Skip to content

Add student/teacher distillation algorithms - #232

Open
zhenkirito123456 wants to merge 8 commits into
mainfrom
zhwuuu/distillation-core
Open

zhenkirito123456 wants to merge 8 commits into
mainfrom
zhwuuu/distillation-core

Conversation

@zhenkirito123456

Copy link
Copy Markdown

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) and DistillationPPO (DAgger blended into PPO on a schedule)
    • their rollout storages and the StudentTeacher* modules
    • TrainingConfig.teacher_checkpoint and the teacher-count/loading logic in train_agent.py
    • registered algo:distillation and algo:distillation-ppo presets
    • a run-id prefix on W&B checkpoints copied into log_dir, so teachers whose checkpoints share a filename don't overwrite each other

    Left 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_generator reuses one permutation across epochs. The distillation algorithms were tuned against per-epoch reshuffling, as in the reference implementation. This adds reshuffle_each_epoch (default False, so PPO is unchanged) and turns it on for the two distillation storages.

  • Make the student's depth input optional:

    • Shared training-loop, checkpoint, teacher-loading, export, and eval code moves into DistillationBase, which removes about 400 duplicated lines between the two algorithms.
    • StudentTeacherModuleConfig.depth_backbone=None builds 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.
    • The depth observation group and term are configurable (depth_obs_group / depth_obs_term) instead of hardcoded to depth_camera / depth_cam.

Validation

  • Unit tests: distillation, config-registry, rollout-storage, and checkpoint tests pass (159 passed). New tests cover:
    • proprioception-only training and single-file export
    • the depth group being required only when a backbone is set
    • configurable depth group and term names
    • per-epoch reshuffling
    • ONNX export checked against act_inference via onnxruntime
  • Lint and types: ruff and mypy, run with the pre-commit hook's pinned versions and arguments, are clean on all changed files.
  • Bit-for-bit equivalence with php release support #230 (4a0bf04). A seeded CPU harness runs complete learn() 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 of 4a0bf04 with these commits applied.
  • PHP test suite against that branch: same results as on 4a0bf04 (231 passed). The one failure, test_sim2sim_launchers, also fails on 4a0bf04 and is unrelated.
  • IsaacSim A/B on 4×L40S per run: one-teacher PHP distillation, 16384 envs, seed 42, 200 iterations, 4a0bf04 vs. 4a0bf04 + these commits.
    • Both finish cleanly with the same 58 logged scalars, checkpoint structure, and exported depth_backbone.onnx + student.onnx.
    • Schedule-determined values (ppo_coef, LR, entropy, noise std, sample count) match exactly.
    • Stochastic metrics agree within run-to-run noise, e.g. final Loss/behavior 0.0457 vs 0.0459 and Train/mean_reward 15.59 vs 15.51.

🤖 Generated with Claude Code

zhenkirito123456 and others added 3 commits October 12, 2026 16:45
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 Mark2000 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/holosoma/holosoma/agents/modules/depth_backbone.py Outdated
Comment thread src/holosoma/holosoma/agents/modules/student_teacher_modules.py Outdated
Comment thread src/holosoma/holosoma/agents/modules/depth_backbone.py Outdated
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>
@zhenkirito123456

Copy link
Copy Markdown
Author

Thanks @Mark2000! All three are addressed in 2396f04. The CPU equivalence check is still bit-identical, and existing checkpoints load unchanged. Would you mind taking another look?

Comment thread src/holosoma/holosoma/agents/distillation_ppo/distillation_ppo.py
Comment thread src/holosoma/holosoma/agents/distillation/base.py
…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 juan-g-bonilla left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +121 to +127
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)
]
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

of course my preference would be to support a wider array of teacher policies

Comment on lines +225 to +229
prefixes = (
"actor_module.module.",
"actor_module.",
"actor.",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +312 to +314
``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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +120 to +124
@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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +384 to +389
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"),
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread src/holosoma/holosoma/agents/distillation_ppo/distillation_ppo.py Outdated
Comment thread src/holosoma/holosoma/agents/distillation/base.py Outdated
Comment thread src/holosoma/holosoma/agents/distillation/base.py
Comment thread src/holosoma/holosoma/agents/distillation/base.py Outdated
Comment thread src/holosoma/holosoma/agents/distillation_ppo/distillation_ppo.py Outdated
zhenkirito123456 and others added 2 commits October 14, 2026 16:05
- 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>
@zhenkirito123456

Copy link
Copy Markdown
Author

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?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants