Skip to content

Commit 2857b7c

Browse files
committed
Add hybrid LK loss (adaptive KL/TV blend) for speculative decoding
Signed-off-by: GIREESH7963 <abburigireesh@gmail.com>
1 parent 5aa4da9 commit 2857b7c

6 files changed

Lines changed: 133 additions & 7 deletions

File tree

scripts/train.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -963,7 +963,7 @@ def parse_args():
963963
"--loss-fn",
964964
type=str,
965965
default="kl_div",
966-
choices=["kl_div", "ce"],
966+
choices=["kl_div", "ce", "lk_hybrid"],
967967
help=(
968968
"Loss function used during draft model training. "
969969
"'kl_div' = KL divergence (default). "
@@ -1047,6 +1047,13 @@ def parse_args():
10471047
default=4.0,
10481048
help="Decay gamma for DFlash loss weighting (default: 4.0)",
10491049
)
1050+
parser.add_argument(
1051+
"--lk-loss-eta",
1052+
type=float,
1053+
default=3.0,
1054+
help="Blend temperature for the hybrid LK loss (--loss-fn lk_hybrid). "
1055+
"Higher values shift from KL toward TV sooner (default: 3.0).",
1056+
)
10501057
parser.add_argument(
10511058
"--draft-attn-impl",
10521059
type=str,
@@ -1172,7 +1179,7 @@ def parse_args():
11721179
args = parser.parse_args()
11731180
provided = explicitly_provided_dests(parser, DECODER_SHAPING_FLAGS)
11741181
validate_draft_init_args(parser, args, provided)
1175-
resolve_loss_fn(args.loss_fn)
1182+
resolve_loss_fn(args.loss_fn, lk_loss_eta=args.lk_loss_eta)
11761183
return args
11771184

11781185

src/speculators/models/dflash/core.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,9 @@ def get_trainer_kwargs(**kwargs) -> tuple[dict, dict]:
193193
Returns:
194194
Tuple of (train_call_kwargs, val_call_kwargs)
195195
"""
196-
loss_fn = resolve_loss_fn(kwargs["loss_fn"])
196+
loss_fn = resolve_loss_fn(
197+
kwargs["loss_fn"], lk_loss_eta=kwargs.get("lk_loss_eta", 3.0)
198+
)
197199
gamma = kwargs.get("dflash_decay_gamma", 4.0)
198200
return {"loss_fn": loss_fn, "gamma": gamma}, {
199201
"loss_fn": loss_fn,

src/speculators/models/eagle3/core.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,9 @@ def get_trainer_kwargs(**kwargs) -> tuple[dict, dict]:
361361
Returns:
362362
Tuple of (train_call_kwargs, val_call_kwargs)
363363
"""
364-
loss_fn = resolve_loss_fn(kwargs["loss_fn"])
364+
loss_fn = resolve_loss_fn(
365+
kwargs["loss_fn"], lk_loss_eta=kwargs.get("lk_loss_eta", 3.0)
366+
)
365367
train_kwargs = {
366368
"use_off_policy_tokens": kwargs["use_off_policy_tokens"],
367369
"ttt_steps": kwargs["ttt_steps"],

src/speculators/models/metrics.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections.abc import Callable
2+
from functools import partial
23

34
import torch
45

@@ -175,6 +176,48 @@ def neg_log_acceptance_loss(
175176
return elementwise_loss # noqa: RET504
176177

177178

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

212255
def resolve_loss_fn(
213256
name: str,
257+
lk_loss_eta: float = 3.0,
214258
) -> "Callable[[torch.Tensor, torch.Tensor], torch.Tensor]":
215259
"""Resolves a loss function given its abbreviated name.
216260
217261
Args:
218262
name: ``"kl_div"`` for KL-divergence, ``"ce"`` for cross-entropy,
219-
``"tv"`` for total variation, or ``"nla"`` for negative
220-
log-acceptance.
263+
``"tv"`` for total variation, ``"nla"`` for negative
264+
log-acceptance, or ``"lk_hybrid"`` for the adaptive KL/TV blend.
265+
lk_loss_eta: Blend temperature for ``"lk_hybrid"``; bound into the loss
266+
via ``functools.partial`` so callers keep handing a bare
267+
``(logits, targets)`` callable. Ignored by the other losses.
221268
222269
Returns:
223270
The corresponding loss function.
@@ -230,6 +277,7 @@ def resolve_loss_fn(
230277
"ce": ce_loss,
231278
"tv": tv_loss,
232279
"nla": neg_log_acceptance_loss,
280+
"lk_hybrid": partial(lk_hybrid_loss, eta=lk_loss_eta),
233281
}
234282
if name not in loss_fn_map:
235283
raise ValueError(

src/speculators/models/peagle/core.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,5 +252,7 @@ def get_trainer_kwargs(**kwargs) -> tuple[dict, dict]:
252252
Returns:
253253
Tuple of (train_call_kwargs, val_call_kwargs)
254254
"""
255-
loss_fn = resolve_loss_fn(kwargs["loss_fn"])
255+
loss_fn = resolve_loss_fn(
256+
kwargs["loss_fn"], lk_loss_eta=kwargs.get("lk_loss_eta", 3.0)
257+
)
256258
return {"loss_fn": loss_fn}, {"loss_fn": loss_fn}

tests/unit/models/test_metrics.py

Lines changed: 65 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,70 @@ 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_binds_eta(self):
223+
"""resolve_loss_fn binds eta into the 'lk_hybrid' callable via partial."""
224+
torch.manual_seed(0)
225+
logits, targets = torch.randn(1, 4, 50), torch.randn(1, 4, 50)
226+
fn = resolve_loss_fn("lk_hybrid", lk_loss_eta=2.0)
227+
assert torch.allclose(
228+
fn(logits, targets), lk_hybrid_loss(logits, targets, eta=2.0), atol=1e-6
229+
)
230+
231+
167232
class TestComputeAccuracySingleStep:
168233
def test_prev_correct_chain(self):
169234
"""Conditional accuracy across ttt steps tracks cumulative correctness.

0 commit comments

Comments
 (0)