| """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)) |
|
|
| @torch.no_grad() |
| 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']) |
| H = d.size(1) |
|
|
| |
| |
| |
| |
| if terminal_only: |
| loss_arrival = d[:, -1].mean() |
| else: |
| loss_arrival = arrival_hold_loss(d, goal_offset, hold_weight).mean() |
|
|
| |
| if H > 1 and alpha != 0: |
| if path_weighting == 'discount': |
| |
| |
| |
| 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(), |
| } |
|
|
| |
| if lambda_cycle != 0 and out['cycle_distances'] is not None: |
| cycle_d = scale.normalize(out['cycle_distances']) |
| loss_cycle = deep_supervision_loss(cycle_d) |
| loss = loss + lambda_cycle * loss_cycle |
| metrics['cycle'] = loss_cycle.detach() |
|
|
| |
| |
| |
| |
| |
| if density is not None and c95 is not None: |
| ctx = out['contexts'].flatten(0, 1) |
| blocks = out['blocks'].flatten(0, 1) |
| 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 |
|
|
| |
| |
| |
| |
| 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 |
|
|
| |
| 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() |
| |
| |
| 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 |
|
|