Skip to content

Commit 4b25261

Browse files
GIREESH7963claude
andauthored
Add hybrid LK loss (adaptive KL/TV blend) for speculative decoding (#673)
## Summary Adds the hybrid LK loss: an adaptive per-position blend of KL and TV, L = lambda*KL + (1-lambda)*TV with lambda = exp(-eta * sg[alpha]), alpha = overlap. ## Motivation TV optimizes acceptance directly but has a vanishing gradient from a cold start; KL has a strong early gradient but optimizes a proxy. The hybrid leans on KL when overlap is low and shifts to TV as the draft aligns, making TV's acceptance-optimal target trainable from scratch. Source: Samarin et al., arXiv 2602.23881. Paper's best setting: eta=3. ## Changes - `lk_hybrid_loss(logits, targets, eta=3.0)` in metrics.py, reusing `kl_div_loss` and the TV overlap term; alpha in the blend weight is detached. - `resolve_loss_fn` gains `lk_loss_eta` (default 3.0), binding eta into the "lk_hybrid" entry via functools.partial (single binding point; call sites unchanged). - `--lk-loss-eta` CLI flag (mirrors --dflash-decay-gamma), threaded through each get_trainer_kwargs into resolve_loss_fn; "lk_hybrid" added to --loss-fn choices. - Tests: eta->0 == KL, eta->inf == TV, shape/finiteness, alpha-detach gradient check, resolve binds eta. ## Notes - The adaptive KL/TV blend and the DFlash positional decay are orthogonal weightings. - tv/nla are not in the --loss-fn choices list either; happy to add them here if desired. - Loss key name "lk_hybrid" open to preference. Signed-off-by: GIREESH7963 <abburigireesh@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d4fd1d6 commit 4b25261

3 files changed

Lines changed: 107 additions & 4 deletions

File tree

scripts/train.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -863,8 +863,8 @@ def parse_args():
863863
default="kl_div",
864864
help=(
865865
"Loss function specification. Pass a name for a single loss "
866-
"(kl_div, ce, tv, nla) or a JSON dict for a weighted combination, "
867-
'e.g. \'{"ce": 0.1, "tv": 0.9}\'.'
866+
"(kl_div, ce, tv, nla, lk_hybrid) or a JSON dict for a weighted "
867+
'combination, e.g. \'{"ce": 0.1, "tv": 0.9}\'.'
868868
),
869869
)
870870
parser.add_argument(

src/speculators/models/metrics.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,48 @@ def neg_log_acceptance_loss(
180180
return elementwise_loss # noqa: RET504
181181

182182

183+
def lk_hybrid_loss(
184+
logits: torch.Tensor, # shape: [1, seq_len, draft_vocab_size]
185+
targets: torch.Tensor, # shape: [1, seq_len, draft_vocab_size]
186+
eta: float = 3.0,
187+
):
188+
"""Compute per-position hybrid LK loss (adaptive KL/TV blend).
189+
190+
Blends KL divergence and total variation per position:
191+
``L = lambda * KL(p||q) + (1 - lambda) * TV(p, q)`` with adaptive weight
192+
``lambda = exp(-eta * sg[alpha])``, where ``alpha = sum_v min(p_v, q_v)`` is the
193+
acceptance rate (overlap) and ``sg`` is stop-gradient. When overlap is low
194+
(early training, misaligned draft) ``lambda -> 1`` and the loss leans on KL's
195+
strong gradient; as overlap grows ``lambda -> 0`` and it shifts to TV, which
196+
optimizes acceptance directly. This gives TV's acceptance-optimal target a
197+
usable gradient from a cold start.
198+
199+
``alpha`` in the weight is detached: it controls the blend but is not
200+
differentiated through; gradients flow only through the KL and TV terms.
201+
202+
Source: Samarin et al., "LK Losses: Direct Acceptance Rate Optimization for
203+
Speculative Decoding" (arXiv 2602.23881), hybrid objective.
204+
205+
Args:
206+
logits: Draft model logits (softmax applied internally to form q).
207+
targets: Target model logits (softmax applied internally to form p).
208+
eta: Blend temperature; larger shifts toward TV sooner. Default 3.0
209+
(the paper's best hybrid setting).
210+
211+
Returns:
212+
Per-position hybrid loss with shape [1, seq_len].
213+
"""
214+
draft_p = torch.nn.functional.softmax(logits, dim=-1)
215+
target_p = torch.nn.functional.softmax(targets, dim=-1)
216+
overlap = torch.minimum(draft_p, target_p).sum(dim=-1) # alpha, shape: [1, seq_len]
217+
tv = 1.0 - overlap
218+
kl = kl_div_loss(logits, targets) # reuse existing KL, shape: [1, seq_len]
219+
weight = torch.exp(-eta * overlap.detach()) # lambda = exp(-eta * sg[alpha])
220+
elementwise_loss = weight * kl + (1.0 - weight) * tv
221+
222+
return elementwise_loss # noqa: RET504
223+
224+
183225
def dflash_loss_decay(pos_idx: torch.Tensor, gamma: float):
184226
"""Compute DFlash-style exponential decay weights per position.
185227
@@ -219,6 +261,7 @@ def exp_loss_decay(pos_idx: torch.Tensor, gamma: float):
219261
"ce": ce_loss,
220262
"tv": tv_loss,
221263
"nla": neg_log_acceptance_loss,
264+
"lk_hybrid": lk_hybrid_loss,
222265
}
223266

224267

@@ -229,8 +272,8 @@ def resolve_loss_fn(
229272
230273
Args:
231274
name: ``"kl_div"`` for KL-divergence, ``"ce"`` for cross-entropy,
232-
``"tv"`` for total variation, or ``"nla"`` for negative
233-
log-acceptance.
275+
``"tv"`` for total variation, ``"nla"`` for negative
276+
log-acceptance, or ``"lk_hybrid"`` for the adaptive KL/TV blend.
234277
235278
Returns:
236279
The corresponding loss function.

tests/unit/models/test_metrics.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
dflash_loss_decay,
1212
exp_loss_decay,
1313
kl_div_loss,
14+
lk_hybrid_loss,
1415
loss_function,
1516
neg_log_acceptance_loss,
1617
resolve_loss_fn,
@@ -164,6 +165,65 @@ def test_resolve_nla(self):
164165
assert resolve_loss_fn("nla") is neg_log_acceptance_loss
165166

166167

168+
class TestLKHybridLoss:
169+
def test_eta_zero_reduces_to_kl(self):
170+
"""eta=0 gives lambda=1 everywhere, so the loss is pure KL."""
171+
torch.manual_seed(0)
172+
logits, targets = torch.randn(1, 4, 50), torch.randn(1, 4, 50)
173+
assert torch.allclose(
174+
lk_hybrid_loss(logits, targets, eta=0.0),
175+
kl_div_loss(logits, targets),
176+
atol=1e-6,
177+
)
178+
179+
def test_large_eta_reduces_to_tv(self):
180+
"""Large eta drives lambda->0, so the loss approaches pure TV."""
181+
torch.manual_seed(0)
182+
logits, targets = torch.randn(1, 4, 50), torch.randn(1, 4, 50)
183+
assert torch.allclose(
184+
lk_hybrid_loss(logits, targets, eta=1e6),
185+
tv_loss(logits, targets),
186+
atol=1e-5,
187+
)
188+
189+
def test_shape_and_finite(self):
190+
"""Output is [1, seq_len] and finite at the default blend setting."""
191+
torch.manual_seed(0)
192+
logits, targets = torch.randn(1, 4, 50), torch.randn(1, 4, 50)
193+
out = lk_hybrid_loss(logits, targets, eta=3.0)
194+
assert out.shape == (1, 4)
195+
assert torch.isfinite(out).all()
196+
197+
def test_alpha_is_detached_in_weight(self):
198+
"""The alpha inside lambda must be stop-gradient.
199+
200+
Verify the impl's gradient matches the detached form, and that NOT
201+
detaching would differ.
202+
"""
203+
torch.manual_seed(0)
204+
logits = torch.randn(1, 3, 40, requires_grad=True)
205+
targets = torch.randn(1, 3, 40)
206+
g_impl = torch.autograd.grad(
207+
lk_hybrid_loss(logits, targets, eta=3.0).sum(), logits
208+
)[0]
209+
210+
def manual(detach):
211+
dp, tp = torch.softmax(logits, -1), torch.softmax(targets, -1)
212+
ov = torch.minimum(dp, tp).sum(-1)
213+
alpha = ov.detach() if detach else ov
214+
lam = torch.exp(-3.0 * alpha)
215+
return (lam * kl_div_loss(logits, targets) + (1 - lam) * (1 - ov)).sum()
216+
217+
g_detached = torch.autograd.grad(manual(True), logits, retain_graph=True)[0]
218+
g_nodetach = torch.autograd.grad(manual(False), logits)[0]
219+
assert torch.allclose(g_impl, g_detached, atol=1e-5)
220+
assert not torch.allclose(g_detached, g_nodetach, atol=1e-4)
221+
222+
def test_resolve_lk_hybrid(self):
223+
"""resolve_loss_fn maps 'lk_hybrid' to lk_hybrid_loss."""
224+
assert resolve_loss_fn("lk_hybrid") is lk_hybrid_loss
225+
226+
167227
class TestComputeAccuracySingleStep:
168228
def test_prev_correct_chain(self):
169229
"""Conditional accuracy across ttt steps tracks cumulative correctness.

0 commit comments

Comments
 (0)