| """Constrained token pruner with an interpretable dual variable mu (formalization §4). |
| |
| Solves, per image, the constrained problem |
| min_m sum_i m_i s.t. C*(x) - C(S;x) <= epsilon |
| via the Lagrangian |
| J(m, mu) = sum_i m_i + mu * (C*(x) - C(S;x) - epsilon), mu >= 0 |
| with primal gradient descent on the gate logits (Gumbel straight-through mask) and dual |
| ascent on mu: |
| mu <- [ mu + eta_mu * (C*(x) - C(S;x) - epsilon) ]_+ . |
| |
| mu reads as the marginal token cost of one unit of preserved lesion coverage. When the |
| coverage floor is violated mu rises (retain more tokens); when satisfied it decays (prune |
| more). This is the controller — no RL (anti-goal §5). Operates on FROZEN features Z and a |
| label-free lesion subspace projector P_L; coverage is the RankMe functional (or coding-rate |
| surrogate). The contribution is this constraint, not the backbone. |
| """ |
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
|
|
| import torch |
|
|
| from coverage.rankme import coverage as rankme_coverage |
| from .mask_gumbel import gumbel_sigmoid, threshold_mask |
|
|
|
|
| @dataclass |
| class PrunerResult: |
| mask: torch.Tensor |
| mu: float |
| delta_C: float |
| k: int |
| C_star: float |
| C_S: float |
| mu_trajectory: list |
| satisfied: bool |
|
|
|
|
| class ConstrainedPruner: |
| def __init__(self, epsilon: float, steps: int = 200, lr: float = 0.5, |
| eta_mu: float = 0.2, tau: float = 0.5, mu_init: float = 1.0, |
| keep_init: float = 2.0, coverage_fn=None, momentum: float = 0.9, |
| mu_max: float = 1e4, cost_scale: float = 1.0, seed: int = 0): |
| self.epsilon = epsilon |
| self.steps = steps |
| self.lr = lr |
| self.eta_mu = eta_mu |
| self.tau = tau |
| self.mu_init = mu_init |
| self.keep_init = keep_init |
| self.coverage_fn = coverage_fn or rankme_coverage |
| self.momentum = momentum |
| self.mu_max = mu_max |
| self.cost_scale = cost_scale |
| self.seed = seed |
|
|
| def fit_image(self, Z: torch.Tensor, P_L: torch.Tensor) -> PrunerResult: |
| """Optimize the per-image mask. Z: (n,d) frozen tokens; P_L: (d,d) lesion projector.""" |
| device = Z.device |
| Z = Z.float() |
| P_L = P_L.to(device).float() |
| gen = torch.Generator(device=device).manual_seed(self.seed) |
| n = Z.shape[0] |
| theta = torch.full((n,), float(self.keep_init), device=device, requires_grad=True) |
| |
| |
| opt = torch.optim.SGD([theta], lr=self.lr, momentum=self.momentum) |
| C_star = self.coverage_fn(Z, P_L).detach() |
| cost_scale = self.cost_scale |
| mu = torch.tensor(float(self.mu_init), device=device) |
| mu_traj = [] |
|
|
| for _ in range(self.steps): |
| opt.zero_grad() |
| m = gumbel_sigmoid(theta, tau=self.tau, hard=True, generator=gen) |
| Z_S = Z * m[:, None] |
| C_S = self.coverage_fn(Z_S, P_L) |
| violation = C_star - C_S - self.epsilon |
| J = cost_scale * m.sum() + mu.detach() * violation |
| J.backward() |
| opt.step() |
| with torch.no_grad(): |
| mu = (mu + self.eta_mu * violation.detach()).clamp_(0.0, self.mu_max) |
| mu_traj.append(float(mu)) |
|
|
| with torch.no_grad(): |
| m_hard = threshold_mask(theta) |
| Z_S = Z * m_hard[:, None] |
| C_S_final = float(self.coverage_fn(Z_S, P_L)) |
| delta_C = float(C_star) - C_S_final |
| return PrunerResult( |
| mask=m_hard.detach(), mu=float(mu), delta_C=delta_C, k=int(m_hard.sum()), |
| C_star=float(C_star), C_S=C_S_final, mu_trajectory=mu_traj, |
| satisfied=bool(delta_C <= self.epsilon), |
| ) |
|
|