This document defines exactly what miniVERL optimizes, and states which
property is asserted by which test. It covers the five modules under
src/miniverl/losses/:
| module | responsibility |
|---|---|
numerics.py |
float32 reductions, log1mexp, KL and entropy from log-probabilities |
exact.py |
full-vocabulary forward KL, reverse KL, beta-JSD, temperature scaling |
bucketed.py |
the top-k + tail coarse-graining and its divergences |
reduction.py |
masked, weight-normalized reduction |
chunked.py |
the chunked evaluation and the two-stage backward |
Nothing here depends on a model, a config object or a device. That is what makes the brute-force reference tests possible.
Fix one selected prediction position. Let loss.temperature. Define
Throughout, exact.py and bucketed.py, because a swapped-argument
bug in a KL is invisible in the loss curve.
All quantities are in nats (natural log). All reductions are accumulated in
float32: to_float32 upcasts bf16 and fp16 inputs before any log_softmax or
logsumexp, because a 151936-entry softmax reduction in half precision loses
several digits of the tail mass this project depends on. float64 inputs are left
alone, which is what lets the property tests check the mathematics rather than
float32 accumulation.
src/miniverl/losses/exact.py materializes the full
exact_forward_kl(teacher_logits, student_logits, ...). The expectation is
taken under the teacher, so every token the teacher gives mass to costs the
student something if it does not cover it. The student is pushed to spread mass
over the whole teacher support.
exact_reverse_kl(teacher_logits, student_logits, ...). The expectation is
taken under the student, so the student is penalized only where it puts mass.
It is free to ignore teacher modes it cannot represent, and it is the default
loss.divergence because it is the usual on-policy distillation objective.
With
exact_jsd(teacher_logits, student_logits, beta=..., ...). The mixture is
formed in log space with torch.logaddexp(log_p + log(beta), log_q + log(1-beta)),
so no probability is ever exponentiated and re-logged.
At
and symmetrically
The KL limits are recovered from the ratio, not the value. Expanding around
This is checkable:
import torch
from miniverl.losses.exact import exact_forward_kl, exact_jsd
g = torch.Generator().manual_seed(13)
teacher = torch.randn(4, 11, generator=g)
student = torch.randn(4, 11, generator=g)
print("KL(P||Q) =", float(exact_forward_kl(teacher, student).mean()))
for beta in (0.5, 0.1, 0.01, 0.001):
js = float(exact_jsd(teacher, student, beta=beta).mean())
print(f"beta={beta:<6} JS={js:.6e} JS/beta={js / beta:.6f}")Output on the development machine:
KL(P||Q) = 0.8532112240791321
beta=0.5 JS=1.839693e-01 JS/beta=0.367939
beta=0.1 JS=7.205847e-02 JS/beta=0.720585
beta=0.01 JS=8.379206e-03 JS/beta=0.837921
beta=0.001 JS=8.516113e-04 JS/beta=0.851611
The endpoints are rejected in two places rather than one: exact_jsd and
bucketed_jsd both raise ValueError at RunConfig._validate_combination rejects the recipe at parse time when
loss.divergence: jsd. The config field itself allows ge=0.0, le=1.0 so that
the value is still storable when the divergence is not JSD.
Both distributions are softmaxed at the same loss.scale_by_temperature_squared is true and exact.temperature_scale). At
Softening by
That is one explicit factor of
This is the classic argument from Hinton, Vinyals and Dean (2015). Its scope is
forward KL (equivalently soft-target cross-entropy) in the near-uniform,
high-temperature regime. In that regime, multiplying by
miniVERL exposes the same factor for reverse KL and beta-JSD, but that is an
explicit heuristic rather than a theorem. Even forward KL can depart strongly
from the asymptotic argument when the logits are sharply peaked. Consequently,
the code and reports record the flag, and the project does not claim that
changing
Run python scripts/temperature_gradient_sweep.py to measure mean-absolute and
L2 student-logit gradients for forward KL, reverse KL and JSD at
tests/unit/test_temperature_gradient_sweep.py executes the full 48-cell grid.
It verifies the expected near-uniform forward-KL stabilization, while only
requiring finite measured values for reverse KL, JSD and peaked logits. In the
peaked scenario the measured scaled gradients vary substantially with
temperature, which is direct evidence against the broader invariance claim.
Note that the cross-entropy term described in section 7.3
is not temperature-scaled: _cross_entropy in chunked.py takes a plain
log_softmax of the raw student logits. Only the divergence uses
loss.mode: bucketed_topk_tail (the default) does not compute a
full-vocabulary KL. This section says precisely what it computes instead.
Let torch.topk, with
which partitions the vocabulary into
The bucketed divergence is the corresponding divergence between
In code:
-
teacher_topk_targets(teacher_logits, top_k, temperature)returns(topk_indices [N,k], topk_log_probs [N,k], tail_log_prob [N]). Thetopk_log_probsare log-probabilities over the full vocabulary restricted to$S$ , so they do not sum to one;tail_log_probis$\log(1 - \sum_{j \in S} p_j)$ , computed bylog1mexp. -
student_bucket_log_probs(student_logits, topk_indices, temperature)normalizes the student over the full vocabulary first, then gathers at the teacher's indices. Normalizing first is what makes$\tilde q_{\perp}$ meaningful. -
build_bucket_distributions(...)floors both non-empty tails, concatenates and renormalizes to two exact$[N, k+1]$ log-probability vectors. When$k=V$ , both tails are exactly empty and the function bypasses smoothing.
The functions are named bucketed_* so no call site can pretend otherwise, and
the manifest records loss_mode alongside top_k. The number reported by a
bucketed_topk_tail run is a divergence between two
import torch
from miniverl.losses.bucketed import bucketed_divergence, teacher_topk_targets
from miniverl.losses.exact import exact_divergence
g = torch.Generator().manual_seed(0)
teacher = torch.randn(4, 512, generator=g) * 3.0
student = torch.randn(4, 512, generator=g) * 3.0
exact = float(exact_divergence(teacher, student, divergence="reverse_kl").mean())
for k in (1, 4, 16, 64, 256, 512):
idx, lp, tail = teacher_topk_targets(teacher, top_k=k)
bucketed = bucketed_divergence(
teacher_topk_log_probs=lp,
teacher_tail_log_prob=tail,
topk_indices=idx,
student_logits=student,
divergence="reverse_kl",
)
print(f"k={k:4d} bucketed={float(bucketed.mean()):.4f} exact={exact:.4f}")Output on the development machine:
k= 1 bucketed=0.6266 exact=8.0730
k= 4 bucketed=1.7172 exact=8.0730
k= 16 bucketed=2.5005 exact=8.0730
k= 64 bucketed=3.8186 exact=8.0730
k= 256 bucketed=6.1714 exact=8.0730
k= 512 bucketed=8.0730 exact=8.0730
On this deliberately adversarial pair (independent random logits at scale 3,
The general statement is the data-processing inequality for
Log-sum inequality. For non-negative
with equality if and only if
Forward KL. Apply it with
Summing over the
Reverse KL. Identical, with
Beta-JSD. Mixing commutes with the pushforward:
The
Three cases where that holds:
-
$k = V$ . The tail cell is empty.teacher_topk_targetsandstudent_bucket_log_probsset it to exactly$-\infty$ rather than relying on floating-point cancellation.build_bucket_distributionsdetects this identity partition and bypasses the epsilon floor, so the same full-vocabulary divergence is recovered (asserted to1e-9in float64). -
$k = V - 1$ . The tail cell is a singleton, so the ratio is trivially constant. - Teacher and student are proportional on the tail — for instance both uniform there.
For beta-JSD, tightness needs
Going from tests/unit/test_losses_bucketed.py::test_bucketed_is_monotone_non_decreasing_in_k.
bucketed_teacher_entropy returns
lower-bounds the true full-vocabulary entropy: merging the tail into one bucket discards its internal spread. Reports label it as the coarse-grained entropy.
Teacher-side storage and transfer: swap memory strategy possible at all, because the targets survive the teacher
being evicted from VRAM.
It does not reduce the student forward/backward cost. The student still
needs a full-vocabulary log_softmax over each selected position to normalize
correctly — that is precisely what makes
Two independent floors keep the tail arithmetic finite. They interact, and the interaction is a common source of error, so both are spelled out.
log1mexp(x) computes
A single formula loses precision at one end or the other, so two are used, split
at
| regime | condition | formula | why |
|---|---|---|---|
| near zero |
|
expm1 avoids the catastrophic cancellation in |
|
| far from zero |
log1p avoids losing the small |
Both branches are evaluated on sanitized inputs. torch.where substitutes
the safe value torch.where — produces a correct forward value and a NaN gradient,
because the discarded branch still contributes to the backward pass.
log(1 - exp(x)) diverges to log1mexp clamps its input at the resolution of the working dtype:
# src/miniverl/losses/numerics.py
x = x.clamp(max=neg_clamp_for(x.dtype))
# neg_clamp_for(dtype) == -torch.finfo(dtype).eps for supported float dtypesFor float32 this bounds the output near
The consequence, which matters for reading a reported tail mass: because the
clamp acts on the input, any true tail mass below the dtype epsilon is
reported at roughly that epsilon. Float64 resolves much smaller tails than
float32; NEG_CLAMP = -1e-7 remains only the fallback for an unknown dtype.
import math
import torch
from miniverl.losses.numerics import log1mexp
for tail in (1e-3, 1e-6, 1e-7, 1e-8, 1e-30):
covered = torch.tensor([math.log1p(-tail)], dtype=torch.float32) # log(1 - tail)
recovered = math.exp(float(log1mexp(covered)))
print(f"true tail {tail:.0e} -> log1mexp recovers {recovered:.3e}")Output on the development machine:
true tail 1e-03 -> log1mexp recovers 1.000e-03
true tail 1e-06 -> log1mexp recovers 1.000e-06
true tail 1e-07 -> log1mexp recovers approximately 1.192e-07
true tail 1e-08 -> log1mexp recovers approximately 1.192e-07
true tail 1e-30 -> log1mexp recovers approximately 1.192e-07
This is a deliberate trade. The alternative — an exact
build_bucket_distributions applies a second, configurable floor before
concatenating. From the source:
# src/miniverl/losses/bucketed.py, inside build_bucket_distributions
log_eps = math.log(tail_epsilon)
teacher = torch.cat(
[
to_float32(teacher_topk_log_probs),
to_float32(teacher_tail_log_prob).clamp_min(log_eps).unsqueeze(-1),
],
dim=-1,
)
# ... the same two lines for the student ...
teacher = teacher - torch.logsumexp(teacher, dim=-1, keepdim=True)
student = student - torch.logsumexp(student, dim=-1, keepdim=True)Only non-empty tails are floored; the top-k log-probabilities are untouched.
The final subtraction renormalizes both
Write tail_epsilon. After flooring and renormalizing,
At the default
The two floors compose, and the tighter one wins. For log1mexp, so it is at least
torch.finfo(torch.float32).eps (about tail_epsilon clamp does nothing unless it is larger. The effective bound in
the default float32 configuration is therefore about
import math
import torch
from miniverl.losses.bucketed import bucketed_divergence, teacher_topk_targets
teacher = torch.full((1, 256), -80.0)
teacher[0, :2] = 40.0 # top-2 hold essentially all the teacher mass
student = torch.zeros(1, 256) # uniform: 254/256 of the student mass is tail
idx, lp, tail = teacher_topk_targets(teacher, top_k=2)
for eps in (1e-3, 1e-6, 1e-9):
value = bucketed_divergence(
teacher_topk_log_probs=lp,
teacher_tail_log_prob=tail,
topk_indices=idx,
student_logits=student,
divergence="reverse_kl",
tail_epsilon=eps,
)
print(f"eps={eps:.0e} reverse_kl={float(value):.4f} log(1/eps)={math.log(1 / eps):.4f}")Output on the development machine:
eps=1e-03 reverse_kl=6.8091 log(1/eps)=6.9078
eps=1e-06 reverse_kl=13.6619 log(1/eps)=13.8155
eps=1e-09 reverse_kl=15.9465 log(1/eps)=20.7233
At tail_epsilon clamp binds and the
value tracks
safe_log_prob clamps log-probabilities at LOG_PROB_FLOOR = -1e30 before any
subtraction in kl_from_log_probs. In float32 0.0, so clamping never changes a probability, but it keeps every difference
Every miniVERL objective is normalized by the sum of effective token weights — never by the padded sequence length and never by the raw selected position count.
Note the substitution is a torch.where on "exactly zero", not a clamp: a
weight sum that is positive but tiny still divides by its true value.
where alignment.token_weights[i], which the selector sets to
selection.critical_weight for tool-call and final-answer tokens and
selection.other_weight otherwise.
Three consequences:
- A zero weight masks a position exactly. It contributes nothing to the numerator and nothing to the denominator, so the loss and every gradient are identical to a run where the position was never selected. This is not "approximately zero because it was averaged over a larger denominator", and it is asserted for both the loss and the gradient.
- Loss magnitudes are comparable across selection budgets. An
all_model_tokensrun and auniform_ratio: 0.35run supervise very different numbers of positions. With a sequence-length denominator the second run's loss would read roughly a third of the first's for reasons having nothing to do with the policy. - Re-weighting does not rescale. Doubling
critical_weightchanges which tokens dominate the average without changing its scale.
reduction.weighted_mean is the reference implementation and takes an optional
externally supplied denominator. The training path does not call it: for the
reason given in section 7, chunked_selected_position_loss
computes
denom = torch.where(total > 0, total, torch.full_like(total, MIN_TOTAL_WEIGHT))
once, from the global weight vector, and divides every chunk by it.
When every weight is exactly zero the numerator is exactly zero too, so the loss
is exactly 0.0 and every gradient is zero. That is the only case in which
The substitution is deliberately a torch.where on total > 0 rather than a
clamp. A clamp would rescale the result for any tiny-but-positive weight sum:
with weights summing to
An earlier draft clamped, and
tests/property/test_property_losses.py::test_weighted_mean_is_a_weighted_mean
found exactly that counterexample (values=[0,0,0,0,1.0],
weights=[0,0,0,0,2.22e-16]). The test now asserts both branches — an exactly
zero weight sum gives exactly 0.0, and any positive weight sum gives the true
weighted mean, within the value range — and it passes.
A naive distillation step computes [batch, seq_len, vocab] logits. For a
151936-entry vocabulary at sequence length 768 that is 116 M floats per sequence
before the backward pass. miniVERL never builds that tensor:
- the backbone runs once and produces hidden states;
- only the selected prediction positions are gathered, giving
[N, H]; - those are projected through the LM head in slices of
loss.chunk_size, so the largest vocabulary-sized tensor alive at any moment is[chunk_size, V].
chunk_size is a memory and throughput knob. It does not change the objective,
and that is the property an OOM retry relies on when it halves the value.
Backpropagating each chunk straight through the backbone would re-run the
backbone once per chunk. Instead, excerpted from
chunked_selected_position_loss (... marks elided lines):
# src/miniverl/losses/chunked.py, inside chunked_selected_position_loss
total = w.sum()
# Only an exactly-zero weight sum uses the floor; see losses/reduction.py.
denom = torch.where(total > 0, total, torch.full_like(total, MIN_TOTAL_WEIGHT))
use_two_stage = backward and hidden_states.requires_grad
work = hidden_states.detach().requires_grad_(True) if use_two_stage else hidden_states
for start in range(0, n, chunk_size):
end = min(start + chunk_size, n)
chunk_hidden = work[start:end]
student_logits = lm_head(chunk_hidden)
...
chunk_loss = (combined * w[start:end]).sum() / denom
if backward:
(chunk_loss * loss_scale).backward() # stage one
...
del student_logits, divergence, ce, combined, chunk_loss # [chunk, V] freed
if use_two_stage:
grad_hidden = work.grad
...
hidden_states.backward(gradient=grad_hidden) # stage twoClaim. The parameter gradients are identical to those of the unchunked
computation, in exact arithmetic, for any chunk_size.
Proof. Let
where
Because every chunk divides by the same global denom is computed once from the full weight vector before the loop.
Gradients are linear in that decomposition:
and the rows of backward() on each work as a leaf, which
accumulates exactly work.grad. Stage two calls hidden_states.backward(gradient=work.grad), which
by the chain rule contributes
and runs the backbone backward exactly once.
In floating point the only difference is summation order. Measured:
import torch
from miniverl.losses.chunked import ExactTargetProvider, chunked_selected_position_loss
torch.manual_seed(2026) # torch.nn.Linear draws from the global generator
g = torch.Generator().manual_seed(2026)
hidden0 = torch.randn(37, 16, generator=g)
lm_head = torch.nn.Linear(16, 48, bias=False)
teacher_logits = torch.randn(37, 48, generator=g) * 2.0
weights = torch.rand(37, generator=g) + 0.1
provider = ExactTargetProvider(teacher_logits_fn=lambda a, b: teacher_logits[a:b])
grads = {}
for chunk in (1, 5, 37, 1000):
hidden = (hidden0 * 1.0).requires_grad_(True)
out = chunked_selected_position_loss(
hidden_states=hidden,
lm_head=lm_head,
weights=weights,
provider=provider,
chunk_size=chunk,
backward=True,
)
grads[chunk] = out.grad_hidden.clone()
print(f"chunk={chunk:5d} chunks={out.num_chunks:3d} loss={out.loss:.10f}")
reference = grads[1000]
for chunk, grad in grads.items():
print(chunk, "max grad diff vs unchunked:", float((grad - reference).abs().max()))Output on the development machine:
chunk= 1 chunks= 37 loss=1.9415816478
chunk= 5 chunks= 8 loss=1.9415816665
chunk= 37 chunks= 1 loss=1.9415816069
chunk= 1000 chunks= 1 loss=1.9415816069
1 max grad diff vs unchunked: 2.2118911147117615e-09
5 max grad diff vs unchunked: 9.313225746154785e-10
37 max grad diff vs unchunked: 0.0
1000 max grad diff vs unchunked: 0.0
The residual disagreement is float32 summation-order noise at the atol=1e-5 the equivalence
tests use.
Two details worth noting:
- The two-stage path is taken only when
backward=Trueandhidden_states.requires_grad. An evaluation-mode call falls through to the single-stage path. loss_scalemultiplies the value passed tobackward()and nothing else. The returnedLossOutput.lossis the unscaled value, which is what makes gradient accumulation overtrain.gradient_accumulation_stepstrajectories (loss_scale = 1 / len(group)) report a comparable loss.
Per selected position, with target token
at temperature 1, and the combined per-position value is a convex combination
When no teacher provider is supplied — the SFT path — the loss is pure
cross-entropy and the trainer forces sampled_token_nll_weight is 0, the NLL branch is skipped
entirely rather than multiplied by zero. The legacy YAML name ce_weight is
accepted only for SFT compatibility and rejected when nonzero in distillation
modes because its target semantics are ambiguous.
Every claim above is backed by at least one executed test. The mapping:
| Claim | Test |
|---|---|
| Forward KL matches the textbook definition | test_forward_kl_matches_brute_force |
| Reverse KL matches the textbook definition | test_reverse_kl_matches_brute_force |
| Beta-JSD matches the textbook definition at |
test_jsd_matches_brute_force |
| Forward and reverse are distinguishable, and swapping the arguments swaps them | test_orientation_forward_and_reverse_differ |
|
|
test_identical_distributions_are_zero |
| All three are non-negative | test_divergences_are_non_negative |
| Symmetric JSD is bounded by |
test_jsd_is_bounded_by_log_two |
|
|
test_jsd_rejects_degenerate_beta |
| Finite at logit scale |
test_extreme_logits_stay_finite |
| Gradients are finite, non-zero and reach the student | test_gradients_are_finite_and_flow_to_student |
| fp16 and bf16 inputs reduce in float32 | test_half_precision_inputs_reduce_in_float32 |
| The |
test_temperature_squared_scaling_is_applied |
| The flag is a no-op at |
test_temperature_one_is_unaffected_by_the_scaling_flag |
| An unknown divergence name raises | test_unknown_divergence_name_is_rejected |
| Teacher entropy matches the definition | test_teacher_entropy_matches_brute_force |
The brute-force references (_softmax_rows, _brute_kl, _brute_jsd) are
plain Python loops written from the definitions, so a bug in the vectorized
implementation cannot hide behind the same expression on both sides.
| Claim | Test |
|---|---|
log1mexp matches log1p(-exp(x)) across both regimes |
test_log1mexp_matches_reference |
log1mexp is finite and differentiable near zero |
test_log1mexp_is_finite_and_differentiable_near_zero |
| Top-k mass plus tail mass equals one | test_topk_targets_have_valid_tail_mass |
|
|
test_topk_equal_to_vocab_gives_exactly_empty_tail |
|
|
test_full_k_converges_to_the_exact_loss |
|
Bucketed |
test_bucketed_lower_bounds_exact |
| Bucketed is non-decreasing in |
test_bucketed_is_monotone_non_decreasing_in_k |
| Identical distributions give zero | test_identical_distributions_give_zero |
| A near-deterministic teacher produces no inf or NaN | test_tail_edge_case_when_topk_mass_is_almost_one |
| The reverse-KL tail penalty is bounded | test_reverse_kl_tail_penalty_is_bounded_by_log_one_over_epsilon |
| Gradients flow to the student only | test_gradients_flow_to_the_student_only |
| Student buckets sum to one | test_student_bucket_log_probs_sum_to_one |
| Coarse-grained entropy lower-bounds exact entropy | test_bucketed_entropy_lower_bounds_the_exact_entropy |
| bf16 student logits are upcast | test_half_precision_student_logits_are_upcast |
top_k < 1, unknown divergence and tail_epsilon = 0 are rejected |
test_invalid_arguments_are_rejected |
| Claim | Test |
|---|---|
The loss value is chunk-invariant for chunk_size |
test_chunked_value_matches_unchunked |
The gradient matches an unchunked reference for chunk_size atol=1e-5) |
test_chunked_gradients_match_unchunked |
| A zero weight changes neither the loss nor any gradient | test_zero_weight_positions_contribute_nothing |
| All-zero weights give loss exactly 0 and zero gradients | test_all_zero_weights_give_a_safe_zero_loss |
| An empty selection is a documented no-op | test_empty_selection_is_a_documented_no_op |
The bucketed provider matches a direct bucketed_divergence call |
test_bucketed_provider_matches_direct_call |
CE-only mode matches torch.nn.functional.cross_entropy
|
test_cross_entropy_only_mode_matches_torch_reference |
| CE mixing is a convex combination | test_ce_mixing_is_a_convex_combination |
loss_scale affects gradients only |
test_loss_scale_only_affects_gradients |
Missing provider, missing targets and chunk_size < 1 are rejected |
test_invalid_arguments_are_rejected |
test_chunked_gradients_match_unchunked is the load-bearing one. It builds a
second backbone and LM head with identical initialization, runs one unchunked
backward through them, and compares the backbone weight gradient, the backbone
bias gradient and the LM head weight gradient against the chunked path.
Hypothesis searches over vocabulary sizes 2–24, 1–5 rows and logits in
| Claim | Test |
|---|---|
| All three divergences are finite and non-negative | test_exact_divergences_are_finite_and_non_negative |
| Symmetric JSD is symmetric and bounded by |
test_symmetric_jsd_is_symmetric_and_bounded |
| Self-divergence is zero | test_self_divergence_is_zero |
| Invariant to a constant logit shift (softmax shift-invariance) | test_divergence_is_invariant_to_a_constant_logit_shift |
| Entropy lies in |
test_entropy_is_between_zero_and_log_vocab |
| The shipped smoothed bucketed objective is empirically |
test_bucketed_never_exceeds_exact |
atol=1e-9 in float64 |
test_full_k_reproduces_exact |
| Top-k mass plus tail equals one | test_teacher_topk_mass_and_tail_sum_to_one |
log1mexp matches the reference outside the clamp |
test_log1mexp_matches_the_reference_outside_the_clamp |
log1mexp clamps rather than diverging at the working dtype's resolution |
test_log1mexp_clamps_instead_of_diverging_near_zero |
weighted_mean is a weighted mean and lies within the value range |
test_weighted_mean_is_a_weighted_mean (see 6.1) |
| Zero weights mask positions exactly | test_zero_weights_mask_positions_exactly |
| A shape mismatch raises | test_weighted_mean_rejects_a_shape_mismatch |
| Selection is reproducible for any ratio, seed and selector | test_selection_is_reproducible_for_any_ratio_and_seed |
test_bucketed_never_exceeds_exact runs in float64 on purpose. The bound is
tight (an equality) whenever the coarse-graining is the identity — for example
test_bucketed_lower_bounds_exact.
The data-processing inequality proved in section 4.3 applies to the
unfloored coarse-graining. The implementation additionally floors non-empty
tails (section 5) and renormalizes, which perturbs both distributions and is not
covered by that proof. The property tests exercise the smoothed implementation
over a broad generated grid; they are regression evidence, not a universal
theorem. The k=V test follows the separate no-smoothing identity path.
- Hinton, Vinyals, Dean. Distilling the Knowledge in a Neural Network. 2015.
The source of the
$\tau^{2}$ argument in section 3. - Mächler. Accurately Computing $\log(1-\exp(-\lvert a\rvert))$. 2012. The
two-regime
log1mexpsplit at$-\log 2$ in section 5.1. - Cover and Thomas, Elements of Information Theory, for the log-sum inequality and the grouping property of entropy used in sections 4.3 and 4.6.
- arXiv:2602.12275, On-Policy Context Distillation for Language Models (Ye,
Dong, Wu, Huang, Wei). The reverse-KL-against-a-context-conditioned-teacher
objective that
models.teacher.mode: privileged_contextimplements. - arXiv:2603.07079, Entropy-Aware On-Policy Distillation of Language Models (Jin, Min, Yang, Wei, Zhou, Kadhe, Baracaldo, Lee). Motivates recording teacher entropy per selected token. The entropy-aware mixing itself is not implemented in miniVERL — see the Roadmap in design.md.