Download code/losses.py from SaltedLemon/lejepa-control-pusht: direct link, hf CLI and curl.
- Browser
- Download file 10.8 kB
-
https://huggingface.co/SaltedLemon/lejepa-control-pusht/resolve/main/code/losses.py
- Command line
-
hf download hf://SaltedLemon/lejepa-control-pusht/code/losses.py
-
curl -L -o losses.py https://huggingface.co/SaltedLemon/lejepa-control-pusht/resolve/main/code/losses.py
10.8 kB
| """The assembled objective for the recursive planner. | |
| Six terms, per ``recursive_planner_design.pdf`` section 8 with the halting head | |
| (Change 10, OPTIONAL) dropped: | |
| 1. arrival-and-hold at the relabel offset ``q`` (Change 4) | |
| 2. late-rising path weighting, coefficient ``alpha`` (Change 5) | |
| 3. deep supervision over cycles, ``lambda_cycle`` (Change 3) | |
| 4. support hinge against the fitted GMM, | |
| ``lambda_support`` (Change 8) | |
| 5. manifold anchor on the answer, ``lambda_anchor`` (Change 7) | |
| 6. pre-tanh saturation barrier, ``lambda_sat`` (Change 6) | |
| No cross-entropy against dataset actions, no entropy term, no progress term. | |
| Weight decay plays the role of ``lambda_reg``. | |
| Terms 1, 2 and 4 reuse the phase-1 implementations unchanged — they take | |
| per-step distances and do not care how the actions were produced. | |
| """ | |
| import torch | |
| from torch import nn | |
| from lejepa_control.losses import arrival_hold_loss, path_weights, support_loss | |
| __all__ = [ | |
| 'DistanceScale', | |
| 'anchor_loss', | |
| 'cycle_weights', | |
| 'deep_supervision_loss', | |
| 'planner_loss', | |
| 'saturation_loss', | |
| ] | |
| class DistanceScale(nn.Module): | |
| """EMA of the median context-to-goal distance; ``d~ = d / (m + eps)``. | |
| Every loss coefficient in section 10 assumes this normalization is in | |
| place. Without it the goal term's magnitude depends on the encoder and the | |
| goal-offset curriculum, and the six weights stop meaning what the document | |
| says they mean. | |
| The median, not the mean, because relabeled pairs at the far end of the | |
| offset curriculum produce a long right tail that would drag a mean around | |
| every time the curriculum advances. | |
| Kept as a module so the running scale rides along in the checkpoint and a | |
| resumed run does not restart with a cold normalizer. | |
| """ | |
| def __init__(self, momentum=0.99, eps=1e-6): | |
| super().__init__() | |
| self.momentum = momentum | |
| self.eps = eps | |
| self.register_buffer('scale', torch.zeros(())) | |
| self.register_buffer('initialized', torch.zeros((), dtype=torch.bool)) | |
| def update(self, ctx_emb, goal_emb): | |
| """Fold this batch's median context-to-goal distance into the EMA.""" | |
| median = (ctx_emb[:, -1] - goal_emb).pow(2).mean(dim=-1).median() | |
| if bool(self.initialized): | |
| self.scale.mul_(self.momentum).add_(median, alpha=1 - self.momentum) | |
| else: | |
| self.scale.copy_(median) | |
| self.initialized.fill_(True) | |
| return self.scale | |
| def normalize(self, distances): | |
| if not bool(self.initialized): | |
| return distances | |
| return distances / (self.scale + self.eps) | |
| def cycle_weights(cycles, device=None, dtype=None): | |
| """``rho_j = 2^j / sum_i 2^i`` over ``j = 1..T``. | |
| For ``T = 3`` this is ``[0.143, 0.286, 0.571]``: the last cycle — the one | |
| that runs at deployment — carries 57% of the weight, while cycle 1 still | |
| receives 14%, enough that a budget-cut deployment at ``T = 1`` emits a | |
| trained action rather than an untrained one. | |
| """ | |
| j = torch.arange(1, cycles + 1, device=device, dtype=dtype or torch.float32) | |
| rho = torch.pow(2.0, j) | |
| return rho / rho.sum() | |
| def deep_supervision_loss(cycle_distances, weights=None): | |
| """``sum_j rho_j * d_hat^(j)``, averaged over batch and horizon steps. | |
| Args: | |
| cycle_distances: ``(B, H, T)`` normalized per-cycle lookahead | |
| distances from the planner. | |
| weights: ``(T,)`` ``rho``; computed from the tensor's own ``T`` if | |
| omitted. | |
| Note that under the Change-1 gradient policy only the final cycle's term | |
| carries a gradient — cycles ``1..T-1`` are produced inside the ``no_grad`` | |
| region and enter as constants. They still shape the logged value and are | |
| the acceptance-test diagnostic. Moving the boundary to gradient-supervise | |
| the earlier cycles is the explicit trade-off the document names; see | |
| ``--cycle-grad-boundary`` in the training script. | |
| """ | |
| if weights is None: | |
| weights = cycle_weights( | |
| cycle_distances.size(-1), | |
| cycle_distances.device, | |
| cycle_distances.dtype, | |
| ) | |
| return (cycle_distances * weights).sum(dim=-1).mean() | |
| def anchor_loss(action_embed, answers): | |
| """``(1/W) * ||phi(psi(y)) - y||^2`` over every supervised answer. | |
| Change 7. Without it ``y`` is whatever ``g`` emits, under no constraint to | |
| resemble ``phi(b)`` for any real block ``b``. Over ``T`` cycles and ``H`` | |
| steps it drifts into a region of answer space ``phi`` never maps into; | |
| ``psi`` still decodes it to a valid block, so nothing crashes and the | |
| failure is silent — the encoder becomes dead weight and ``g``'s input | |
| degenerates into an unconstrained hidden state. | |
| Returns: | |
| The loss, and the relative anchor ratio ``||.||^2 / ||y||^2`` used as | |
| the health metric (healthy: under 0.05 and stable). | |
| """ | |
| y_hat = action_embed.round_trip(answers) | |
| err = (y_hat - answers).pow(2) | |
| loss = err.mean() | |
| ratio = ( | |
| err.sum(dim=-1) / answers.pow(2).sum(dim=-1).clamp(min=1e-8) | |
| ).mean() | |
| return loss, ratio.detach() | |
| def saturation_loss(raw, limit=2.0): | |
| """``mean(max(0, |r| - limit)^2)`` on ``psi``'s pre-tanh output. | |
| Change 6. Entropy is moot for a continuous answer; the real degeneracy is | |
| that the goal loss rewards extreme actions, driving ``r`` into the flat | |
| region where ``tanh'(r) -> 0`` and the dimension freezes at +-1 with | |
| nothing able to pull it back. At ``|r| = 4`` the tanh gradient is already | |
| attenuated ~43x. The barrier is zero inside ``|r| <= 2``, which covers | |
| ~96% of the action range, and grows quadratically outside it. | |
| Returns: | |
| The loss, and the fraction of activations past the limit (healthy: | |
| under 10%). | |
| """ | |
| excess = (raw.abs() - limit).clamp(min=0) | |
| return excess.pow(2).mean(), (raw.abs() > limit).float().mean().detach() | |
| def planner_loss( | |
| out, | |
| goal_offset, | |
| scale, | |
| action_embed, | |
| density=None, | |
| c95=None, | |
| hold_weight=0.5, | |
| alpha=0.05, | |
| lambda_cycle=0.3, | |
| lambda_support=0.01, | |
| lambda_anchor=0.05, | |
| lambda_sat=1e-3, | |
| sat_limit=2.0, | |
| terminal_only=False, | |
| path_weighting='late', | |
| gamma=0.9, | |
| ): | |
| """Assemble the six-term objective from one planner rollout. | |
| Args: | |
| out: The dict returned by :class:`~lejepa_control_2.planner.RecursivePlanner`. | |
| goal_offset: ``(B,)`` long, the ``q`` each sample's goal was relabeled | |
| from — the deadline the arrival term is indexed by. | |
| scale: :class:`DistanceScale`, already updated for this batch. | |
| action_embed: The planner's ``phi``/``psi`` pair. | |
| density / c95: Fitted :class:`BehaviorDensity` and its held-out 95th | |
| percentile threshold. Both ``None`` disables the support term. | |
| hold_weight, alpha, lambda_*: Section 10 coefficients. | |
| Returns: | |
| ``(loss, metrics)`` where ``metrics`` holds detached scalars and the | |
| per-step / per-cycle distance curves for the diagnostics log. | |
| """ | |
| d = scale.normalize(out['distances']) # (B, H) | |
| H = d.size(1) | |
| # --- 1. arrival-and-hold at the relabel offset (Change 4) ------------- | |
| # terminal_only is ablation 5's control: score at a fixed d_H instead. The | |
| # deadline then resets to H at every replan, so deferring arrival forever | |
| # is optimal under the loss. | |
| if terminal_only: | |
| loss_arrival = d[:, -1].mean() | |
| else: | |
| loss_arrival = arrival_hold_loss(d, goal_offset, hold_weight).mean() | |
| # --- 2. late-rising path weighting (Change 5) ------------------------- | |
| if H > 1 and alpha != 0: | |
| if path_weighting == 'discount': | |
| # ablation 6's control: a discount puts the most weight on step 1, | |
| # which in PushT prefers jamming against the block over the detour | |
| # the task actually requires | |
| k = torch.arange(1, H, device=d.device, dtype=d.dtype) | |
| w = torch.pow(gamma, k) | |
| w = w / w.sum() | |
| else: | |
| w = path_weights(H, d.device, d.dtype) | |
| loss_path = (d[:, :-1] * w).sum(dim=1).mean() | |
| else: | |
| loss_path = d.new_zeros(()) | |
| loss = loss_arrival + alpha * loss_path | |
| metrics = { | |
| 'arrival': loss_arrival.detach(), | |
| 'path': loss_path.detach(), | |
| } | |
| # --- 3. deep supervision over cycles (Change 3) ----------------------- | |
| if lambda_cycle != 0 and out['cycle_distances'] is not None: | |
| cycle_d = scale.normalize(out['cycle_distances']) # (B, H, T) | |
| loss_cycle = deep_supervision_loss(cycle_d) | |
| loss = loss + lambda_cycle * loss_cycle | |
| metrics['cycle'] = loss_cycle.detach() | |
| # --- 4. support hinge (Change 8) -------------------------------------- | |
| # scored whenever a density model is available, penalized only when | |
| # lambda_support is on — a violation fraction that rises while the goal | |
| # loss falls is the signature of model exploitation, and it shows up | |
| # 1-2k steps before real-env success starts dropping | |
| if density is not None and c95 is not None: | |
| ctx = out['contexts'].flatten(0, 1) # (B*H, N, D) | |
| blocks = out['blocks'].flatten(0, 1) # (B*H, A) | |
| loss_support, violation = support_loss(density, ctx, blocks, c95) | |
| metrics['support'] = loss_support.detach() | |
| metrics['violation'] = violation.detach() | |
| if lambda_support != 0: | |
| loss = loss + lambda_support * loss_support | |
| # --- 5. manifold anchor (Change 7) ------------------------------------ | |
| # computed even when disabled: the anchor ratio is the drift diagnostic, | |
| # and a stage that is not yet paying for the anchor is exactly the stage | |
| # where you want to watch it | |
| loss_anchor, anchor_ratio = anchor_loss(action_embed, out['answers']) | |
| metrics['anchor'] = loss_anchor.detach() | |
| metrics['anchor_ratio'] = anchor_ratio | |
| if lambda_anchor != 0: | |
| loss = loss + lambda_anchor * loss_anchor | |
| # --- 6. pre-tanh saturation barrier (Change 6) ------------------------ | |
| loss_sat, sat_fraction = saturation_loss(out['raw'], sat_limit) | |
| metrics['sat'] = loss_sat.detach() | |
| metrics['sat_fraction'] = sat_fraction | |
| if lambda_sat != 0: | |
| loss = loss + lambda_sat * loss_sat | |
| metrics['loss'] = loss.detach() | |
| # curves for the diagnostics log: healthy per-step is decreasing with its | |
| # minimum near q, healthy per-cycle is strictly decreasing in j | |
| metrics['per_step'] = d.mean(dim=0).detach() | |
| if out['cycle_distances'] is not None: | |
| metrics['per_cycle'] = ( | |
| scale.normalize(out['cycle_distances']).mean(dim=(0, 1)).detach() | |
| ) | |
| return loss, metrics | |