| """Gradient-space diagnostics for RL fine-tuning analysis (PyTorch). |
| |
| Measures gradient alignment between RL and BC objectives, per-layer |
| gradient norms, and PCGrad surgery metrics. |
| |
| Adapted from Craftax JAX implementation. All functions are eager |
| PyTorch; no JIT compilation. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import contextlib |
| from collections.abc import Iterator |
| from types import SimpleNamespace |
|
|
| import torch |
| from torch import Tensor, nn |
|
|
| from experiments.rl_finetuning.ablations.losses import _core_loss |
|
|
|
|
| @torch.no_grad() |
| def _collect_flat_grad(model: nn.Module) -> Tensor: |
| """Flatten all parameter gradients into a single vector. |
| |
| Args: |
| model: Model after a backward pass. |
| |
| Returns: |
| 1-D tensor of concatenated gradients (detached). |
| """ |
| parts: list[Tensor] = [] |
| for param in model.parameters(): |
| if param.grad is not None: |
| parts.append(param.grad.detach().reshape(-1)) |
| else: |
| parts.append(torch.zeros(param.numel(), device=param.device)) |
| return torch.cat(parts) |
|
|
|
|
| def _canonical_name(name: str) -> str: |
| """Parameter name with the wrappers' decorations removed. |
| |
| ``torch.compile`` prefixes ``_orig_mod.`` and |
| ``torch.nn.utils.parametrize`` rewrites ``w`` to |
| ``parametrizations.w.original``; the pretrained reference carries |
| neither, so both are stripped before pairing parameters by name. |
| |
| Args: |
| name: Name as ``named_parameters`` reports it. |
| |
| Returns: |
| The name the uninstrumented module would report. |
| """ |
| return ( |
| name.replace("_orig_mod.", "") |
| .replace("parametrizations.", "") |
| .replace(".original", "") |
| ) |
|
|
|
|
| @contextlib.contextmanager |
| def _at_reference_parameters(model: nn.Module, ref_model: nn.Module) -> Iterator[None]: |
| """Hold ``model``'s parameters at ``ref_model``'s values for the block. |
| |
| Evaluating the BC gradient by loading the reference values into the |
| current module -- rather than by differentiating ``ref_model`` itself -- |
| keeps both gradients in one parameter space and one ordering, so the |
| cosine is well defined for every ablation, including the parametrized |
| and the partly frozen ones. |
| |
| Parameters with no counterpart in the reference keep their current |
| values. LoRA's A and B factors are the case that arises: the pretrained |
| model has no analogue for them, and the sibling repo's ``params["base"]`` |
| reference leaves them out for the same reason. |
| |
| Args: |
| model: Module whose parameters are swapped. |
| ref_model: Pretrained module supplying the values. |
| |
| Yields: |
| None, with the swap in force. |
| """ |
| ref = {_canonical_name(n): p for n, p in ref_model.named_parameters()} |
| saved: list[tuple[Tensor, Tensor]] = [] |
| with torch.no_grad(): |
| for name, param in model.named_parameters(): |
| src = ref.get(_canonical_name(name)) |
| if src is not None and src.shape == param.shape: |
| saved.append((param, param.detach().clone())) |
| param.copy_(src) |
| try: |
| yield |
| finally: |
| with torch.no_grad(): |
| for param, value in saved: |
| param.copy_(value) |
|
|
|
|
| def compute_grad_alignment( |
| model: nn.Module, |
| ref_model: nn.Module, |
| local_obs: Tensor, |
| global_obs: Tensor, |
| x0: Tensor, |
| advantages: Tensor | None, |
| cfg: SimpleNamespace, |
| device: torch.device, |
| ) -> tuple[float, float, float]: |
| """Cosine similarity between RL and BC gradient vectors. |
| |
| Computes RL loss with advantages and BC loss without, then measures |
| alignment of their full-model gradient vectors. |
| |
| Both gradients are taken on the same batch **and the same ``(z_t, t)`` |
| draw**: ``_core_loss`` samples its timestep and its masking from the |
| global generator, so the generator is rewound between the two backward |
| passes. At independent draws the metric reports Monte-Carlo noise as |
| objective disagreement. Measured over six trials on one fixed batch at |
| the production architecture: two draws give a mean cosine of 0.858 |
| ranging over 0.104, where one draw gives 0.954 ranging over 0.063 -- a |
| mean shift of 0.096. The sibling repo is hit far harder, reporting |
| anti-alignment where the same-draw value is 0.98. Sharing the draw |
| leaves the generator where one loss would have left it rather than |
| where two would. |
| |
| The BC gradient is taken at the **pretrained** parameters, not at the |
| current ones: a fixed reference is comparable across iterations and is |
| the quantity the forgetting framing needs, and it is what the sibling |
| repo has always measured. ``ref_model`` supplies them, held in place by |
| :func:`_at_reference_parameters` for the BC pass alone. |
| |
| Args: |
| model: Current model (must be in train mode). |
| ref_model: Pretrained model, the reference the BC gradient is taken at. |
| local_obs: ``[B, 9, 9]``. |
| global_obs: ``[B, 21, 79]``. |
| x0: ``[B, H]`` clean actions. |
| advantages: ``[B]`` advantage weights for RL loss. |
| cfg: Config namespace with ``_schedule_fn``. |
| device: Torch device. |
| |
| Returns: |
| Tuple of (cosine_similarity, rl_grad_norm, bc_grad_norm). |
| """ |
| model.train() |
| _use_amp = getattr(cfg, "use_amp", False) and device.type == "cuda" |
|
|
| def _grad(weights: Tensor | None) -> Tensor: |
| model.zero_grad() |
| with torch.amp.autocast("cuda", enabled=_use_amp): |
| loss = _core_loss(model, local_obs, global_obs, x0, weights, cfg, device) |
| loss.backward() |
| return _collect_flat_grad(model) |
|
|
| cpu_rng = torch.get_rng_state() |
| cuda_rng = torch.cuda.get_rng_state(device) if device.type == "cuda" else None |
|
|
| |
| g_rl = _grad(advantages) |
|
|
| |
| |
| torch.set_rng_state(cpu_rng) |
| if cuda_rng is not None: |
| torch.cuda.set_rng_state(cuda_rng, device) |
| with _at_reference_parameters(model, ref_model): |
| g_bc = _grad(None) |
|
|
| model.zero_grad() |
|
|
| rl_norm = g_rl.norm().item() |
| bc_norm = g_bc.norm().item() |
| cos_sim = (torch.dot(g_rl, g_bc) / (rl_norm * bc_norm + 1e-10)).item() |
|
|
| return cos_sim, rl_norm, bc_norm |
|
|
|
|
| def compute_per_layer_grad_norms( |
| model: nn.Module, |
| ) -> dict[str, float]: |
| """L2 gradient norm for each named parameter. |
| |
| Call after a ``loss.backward()``. |
| |
| Args: |
| model: Model with populated ``.grad`` attributes. |
| |
| Returns: |
| Dict mapping parameter name to L2 norm. |
| """ |
| norms: dict[str, float] = {} |
| for name, param in model.named_parameters(): |
| if param.grad is not None: |
| norms[name] = param.grad.detach().norm().item() |
| return norms |
|
|
|
|
| def compute_surgery_metrics( |
| g_before: dict[str, Tensor], |
| g_after: dict[str, Tensor], |
| ) -> tuple[float, int]: |
| """Measure gradient mass removed by PCGrad projection. |
| |
| Args: |
| g_before: RL gradient dict before projection. |
| g_after: RL gradient dict after projection. |
| |
| Returns: |
| Tuple of (projected_mass_fraction, n_conflicting_params). |
| """ |
| total_before = 0.0 |
| total_after = 0.0 |
| n_conflicting = 0 |
|
|
| for name in g_before: |
| gb = g_before[name] |
| ga = g_after.get(name, gb) |
| sq_before = (gb * gb).sum().item() |
| sq_after = (ga * ga).sum().item() |
| total_before += sq_before |
| total_after += sq_after |
| if (gb * (gb - ga)).sum().item() > 0: |
| n_conflicting += 1 |
|
|
| mass_removed = max(total_before - total_after, 0.0) |
| fraction = mass_removed / max(total_before, 1e-10) |
| return fraction, n_conflicting |
|
|