Skip to content

Commit 669b878

Browse files
authored
Add negative log-acceptance (LK) loss for speculative decoding (#657)
## Summary Adds a negative log-acceptance loss to the speculators loss interface — the hyperparameter-free LK loss variant. ## Motivation Acceptance rate equals the target/draft overlap, `alpha = sum_v min(p_v, q_v) = 1 - TV`. This loss is `-log(alpha)`, which optimizes acceptance directly. Its gradient is `(1/alpha)*grad(TV)`: the `1/alpha` factor amplifies TV's otherwise-vanishing gradient when overlap is low, so it trains well from a cold start where pure TV stalls. At a point-mass target it reduces to cross-entropy. Source: Samarin et al., "LK Losses: Direct Acceptance Rate Optimization for Speculative Decoding" (arXiv 2602.23881). ## Changes - `neg_log_acceptance_loss(logits, targets) -> [1, seq_len]` in `metrics.py`, reusing the same overlap term as `tv_loss`, with an `_EPS` floor before the log. - Registered as `"nla"` in `resolve_loss_fn` (short name open to maintainer preference). - Unit tests: identical-dist = 0, equals `-log(overlap)`, reduces to cross-entropy at a point-mass target, finite at zero overlap, and resolution. ## Scope / notes - Negative-log-acceptance variant only. The hybrid LK loss (`lambda*KL + (1-lambda)*TV` with an adaptive, eta-controlled weight) is deferred to a follow-up, since it needs a loss-hyperparameter interface decision. The e2e variant and kernel optimizations are out of scope. - Target distribution is treated as the (detached) reference, consistent with the existing `kl_div`/`ce`/`tv` losses which assume frozen-target logits. Signed-off-by: GIREESH7963 <abburigireesh@gmail.com>
1 parent 9e322bd commit 669b878

2 files changed

Lines changed: 79 additions & 2 deletions

File tree

src/speculators/models/metrics.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,35 @@ def tv_loss(
146146
return elementwise_loss # noqa: RET504
147147

148148

149+
def neg_log_acceptance_loss(
150+
logits: torch.Tensor, # shape: [1, seq_len, draft_vocab_size]
151+
targets: torch.Tensor, # shape: [1, seq_len, draft_vocab_size]
152+
):
153+
"""Compute per-position negative log-acceptance (LK) loss.
154+
155+
The speculative-decoding acceptance rate equals the draft/target distribution
156+
overlap, ``alpha = sum_v min(p_v, q_v)`` (the same quantity computed in
157+
``tv_loss``). This loss is ``-log(alpha)``. Its gradient is
158+
``(1 / alpha) * grad(TV)``: the ``1 / alpha`` factor amplifies the otherwise
159+
vanishing TV gradient when overlap is low (early training), giving TV's
160+
acceptance-optimal target a usable gradient from a cold start. When the target
161+
is a point mass, this loss reduces to cross-entropy.
162+
163+
Args:
164+
logits: Draft model logits (softmax applied internally to form q).
165+
targets: Target model logits (softmax applied internally to form p).
166+
167+
Returns:
168+
Per-position negative log-acceptance with shape [1, seq_len].
169+
"""
170+
draft_p = torch.nn.functional.softmax(logits, dim=-1)
171+
target_p = torch.nn.functional.softmax(targets, dim=-1)
172+
overlap = torch.minimum(draft_p, target_p).sum(dim=-1) # alpha, shape: [1, seq_len]
173+
elementwise_loss = -torch.log(overlap.clamp_min(_EPS))
174+
175+
return elementwise_loss # noqa: RET504
176+
177+
149178
def dflash_loss_decay(pos_idx: torch.Tensor, gamma: float):
150179
"""Compute DFlash-style exponential decay weights per position.
151180
@@ -186,8 +215,9 @@ def resolve_loss_fn(
186215
"""Resolves a loss function given its abbreviated name.
187216
188217
Args:
189-
name: ``"kl_div"`` for KL-divergence, ``"ce"`` for cross-entropy, or
190-
``"tv"`` for total variation.
218+
name: ``"kl_div"`` for KL-divergence, ``"ce"`` for cross-entropy,
219+
``"tv"`` for total variation, or ``"nla"`` for negative
220+
log-acceptance.
191221
192222
Returns:
193223
The corresponding loss function.
@@ -199,6 +229,7 @@ def resolve_loss_fn(
199229
"kl_div": kl_div_loss,
200230
"ce": ce_loss,
201231
"tv": tv_loss,
232+
"nla": neg_log_acceptance_loss,
202233
}
203234
if name not in loss_fn_map:
204235
raise ValueError(

tests/unit/models/test_metrics.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
import torch
77

88
from speculators.models.metrics import (
9+
ce_loss,
910
compute_accuracy_single_step,
1011
dflash_loss_decay,
1112
exp_loss_decay,
1213
kl_div_loss,
1314
loss_function,
15+
neg_log_acceptance_loss,
1416
resolve_loss_fn,
1517
tv_loss,
1618
)
@@ -118,6 +120,50 @@ def test_resolve_tv(self):
118120
assert resolve_loss_fn("tv") is tv_loss
119121

120122

123+
class TestNegLogAcceptanceLoss:
124+
def test_identical_is_zero(self):
125+
"""Negative log-acceptance of identical distributions is ~0."""
126+
logits = torch.randn(1, 4, 50)
127+
loss = neg_log_acceptance_loss(logits, logits)
128+
assert torch.allclose(loss, torch.zeros(1, 4), atol=1e-5)
129+
130+
def test_equals_neg_log_overlap_and_shape(self):
131+
"""Loss equals -log(overlap); output is [1, seq_len] and non-negative."""
132+
torch.manual_seed(0)
133+
logits = torch.randn(1, 4, 50)
134+
targets = torch.randn(1, 4, 50)
135+
out = neg_log_acceptance_loss(logits, targets)
136+
137+
overlap = 1.0 - tv_loss(logits, targets) # tv = 1 - alpha
138+
assert out.shape == (1, 4)
139+
assert torch.allclose(out, -torch.log(overlap.clamp_min(1e-5)), atol=1e-6)
140+
assert (out >= 0).all()
141+
142+
def test_reduces_to_cross_entropy_at_point_mass_target(self):
143+
"""At a point-mass target the loss reduces to cross-entropy."""
144+
torch.manual_seed(0)
145+
logits = torch.randn(1, 1, 50)
146+
targets = torch.full((1, 1, 50), -30.0)
147+
targets[0, 0, 7] = 30.0 # ~one-hot target
148+
assert torch.allclose(
149+
neg_log_acceptance_loss(logits, targets),
150+
ce_loss(logits, targets),
151+
atol=1e-3,
152+
)
153+
154+
def test_finite_at_zero_overlap(self):
155+
"""The _EPS floor keeps the loss finite when overlap collapses to ~0."""
156+
logits = torch.full((1, 1, 50), -30.0)
157+
logits[0, 0, 0] = 30.0
158+
targets = torch.full((1, 1, 50), -30.0)
159+
targets[0, 0, 1] = 30.0
160+
assert torch.isfinite(neg_log_acceptance_loss(logits, targets)).all()
161+
162+
def test_resolve_nla(self):
163+
"""resolve_loss_fn maps 'nla' to neg_log_acceptance_loss."""
164+
assert resolve_loss_fn("nla") is neg_log_acceptance_loss
165+
166+
121167
class TestComputeAccuracySingleStep:
122168
def test_prev_correct_chain(self):
123169
"""Conditional accuracy across ttt steps tracks cumulative correctness.

0 commit comments

Comments
 (0)