The miniVERL objective¶
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.
1. Notation¶
Fix one selected prediction position. Let $V$ be the vocabulary size,
$z^{T} \in \mathbb{R}^{V}$ the teacher logits at that position and
$z^{S} \in \mathbb{R}^{V}$ the student logits. Let $\tau > 0$ be
loss.temperature. Define
$$ p_v \;=\; \frac{\exp(z^{T}v/\tau)}{\sum}\exp(z^{Tu/\tau)}, \qquad q_v \;=\; \frac{\exp(z^{S}_v/\tau)}{\sum . $$}\exp(z^{S}_u/\tau)
Throughout, $P$ is the teacher and $Q$ is the student. Orientation is part of
every function name in 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.
2. The three divergences¶
src/miniverl/losses/exact.py materializes the full $[N, V]$ distributions for
$N$ selected positions and returns a per-position vector of shape $[N]$.
2.1 Forward KL — mass-covering¶
$$ D_{\mathrm{KL}}(P \,|\, Q) \;=\; \sum_{v=1}^{V} p_v \log \frac{p_v}{q_v} $$
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.
2.2 Reverse KL — mode-seeking¶
$$ D_{\mathrm{KL}}(Q \,|\, P) \;=\; \sum_{v=1}^{V} q_v \log \frac{q_v}{p_v} $$
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.
2.3 Beta-weighted Jensen-Shannon¶
With $M = \beta P + (1-\beta) Q$,
$$ \mathrm{JS}{\beta}(P, Q) \;=\; \beta\, D(P \,|\, M) \;+\; (1-\beta)\, D_{\mathrm{KL}}(Q \,|\, M) $$}
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.
$\beta = 0.5$ is the symmetric Jensen-Shannon divergence, which in nats is bounded above by $\log 2 \approx 0.6931$.
2.4 Why $\beta$ must be strictly inside $(0, 1)$¶
At $\beta = 1$ the mixture collapses to $M = P$, so
$$ \mathrm{JS}{1} = 1 \cdot D(Q|P) = 0 , $$}}(P|P) + 0 \cdot D_{\mathrm{KL}
and symmetrically $\mathrm{JS}_{0} = 0$. Both endpoints give a divergence that is identically zero for every input pair — a silently non-training objective, not a KL limit.
The KL limits are recovered from the ratio, not the value. Expanding around $\beta = 0$, the second term is $O(\beta^{2})$ and
$$ \frac{\mathrm{JS}{\beta}(P,Q)}{\beta} \;\xrightarrow[\beta \to 0]{}\; D(P \,|\, Q) . $$}
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 $\beta \notin (0,1)$, and
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.
3. Temperature and the $\tau^{2}$ factor¶
Both distributions are softmaxed at the same $\tau$. When
loss.scale_by_temperature_squared is true and $\tau \neq 1$, the per-position
divergence is multiplied by $\tau^{2}$ (exact.temperature_scale). At
$\tau = 1$ the factor is exactly 1 either way, so the flag is a no-op there.
3.1 Why the factor exists¶
Softening by $\tau$ shrinks the gradient the objective delivers to the student logits. For forward KL, with $P$ held fixed,
$$ \frac{\partial}{\partial z^{S}{j}} D(P|Q) \;=\; -\sum_{v} p_v \frac{\partial \log q_v}{\partial z^{S}}{j}} \;=\; -\sum - q_j\right) \;=\; \frac{1}{\tau}\left(q_j - p_j\right) . $$} p_v \cdot \frac{1}{\tau}\left(\delta_{vj
That is one explicit factor of $1/\tau$ from the chain rule through $z/\tau$. A second appears in the softened regime: for zero-meaned logits and $\tau$ large enough that $\exp(z/\tau) \approx 1 + z/\tau$,
$$ q_j - p_j \;\approx\; \frac{z^{S}{j} - z^{T}, \qquad\text{so}\qquad \frac{\partial L}{\partial z^{S}}}{V\tau{j}} \;\approx\; \frac{z^{S} . $$} - z^{T}_{j}}{V\tau^{2}
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 $\tau^{2}$ largely separates target softening from gradient scale.
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 $\tau$ leaves update magnitudes invariant in general.
3.2 Measured¶
Run python scripts/temperature_gradient_sweep.py to measure mean-absolute and
L2 student-logit gradients for forward KL, reverse KL and JSD at
$\tau \in {1,2,4,8}$ in both a near-uniform and a sharply-peaked mismatched
regime. The deterministic test
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 $\tau$.
4. The top-k + tail coarse-graining¶
loss.mode: bucketed_topk_tail (the default) does not compute a
full-vocabulary KL. This section says precisely what it computes instead.
4.1 Definition¶
Let $S \subseteq {1,\dots,V}$ be the indices of the $k$ largest teacher
probabilities, $|S| = k$ (torch.topk, with $k$ clipped to $V$). Define the
map
$$ T(v) \;=\; \begin{cases} v & v \in S \ \perp & v \notin S \end{cases} $$
which partitions the vocabulary into $k$ singleton cells plus one aggregate tail cell $S^{c}$. The same map is applied to both distributions, giving the $k+1$-category pushforwards
$$ \tilde p_j = p_j \;(j \in S), \qquad \tilde p_{\perp} = \sum_{v \notin S} p_v , $$ $$ \tilde q_j = q_j \;(j \in S), \qquad \tilde q_{\perp} = \sum_{v \notin S} q_v . $$
The bucketed divergence is the corresponding divergence between $\tilde P$ and $\tilde Q$.
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.
4.2 This is not full-vocabulary KL¶
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 $(k+1)$-category
distributions. Before numerical tail flooring, it is a lower bound on the
full-vocabulary divergence. The shipped epsilon-smoothed objective is a nearby,
finite objective; section 5 states the perturbation explicitly.
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, $V = 512$) the default $k = 64$ recovers under half the exact reverse KL. On a real teacher and a partly-trained student the top-64 mass may be higher and the gap smaller. The under-reporting direction is guaranteed for the unfloored coarse-graining; it is measured, not universally claimed, after epsilon smoothing.
4.3 Proof sketch: why it is a lower bound¶
The general statement is the data-processing inequality for $f$-divergences: for any Markov kernel $T$, $D_f(P \circ T^{-1} | Q \circ T^{-1}) \le D_f(P|Q)$. A deterministic partition map is a Markov kernel, so coarse-graining can only destroy information. For KL specifically the argument is one line of the log-sum inequality.
Log-sum inequality. For non-negative $a_i, b_i$,
$$ \sum_i a_i \log \frac{a_i}{b_i} \;\ge\; \Big(\sum_i a_i\Big) \log \frac{\sum_i a_i}{\sum_i b_i} , $$
with equality if and only if $a_i / b_i$ is constant over $i$.
Forward KL. Apply it with $a_v = p_v$, $b_v = q_v$ on each cell $A$ of the partition $\mathcal{A} = {{j} : j \in S} \cup {S^{c}}$:
$$ \sum_{v \in A} p_v \log \frac{p_v}{q_v} \;\ge\; \tilde p_A \log \frac{\tilde p_A}{\tilde q_A} . $$
Summing over the $k+1$ cells, the left-hand side is exactly $D_{\mathrm{KL}}(P|Q)$ and the right-hand side is exactly $D_{\mathrm{KL}}(\tilde P | \tilde Q)$. Hence
$$ D_{\mathrm{KL}}(\tilde P \,|\, \tilde Q) \;\le\; D_{\mathrm{KL}}(P \,|\, Q) . $$
Reverse KL. Identical, with $a_v = q_v$ and $b_v = p_v$. The partition is unchanged — it is still defined by the teacher's top-k — so the same argument gives $D_{\mathrm{KL}}(\tilde Q | \tilde P) \le D_{\mathrm{KL}}(Q|P)$.
Beta-JSD. Mixing commutes with the pushforward: $\tilde M = \beta \tilde P + (1-\beta)\tilde Q$ is the coarse-graining of $M = \beta P + (1-\beta) Q$, because summing over a cell is linear. Applying the KL bound to each of the two terms gives $\mathrm{JS}{\beta}(\tilde P, \tilde Q) \le \mathrm{JS}(P, Q)$.
4.4 Equality condition¶
The $k$ singleton cells contribute equality trivially. The bound is therefore tight if and only if the log-sum inequality is tight on the tail cell, that is, if and only if
$$ \frac{p_v}{q_v} \;\text{is constant for all } v \notin S . $$
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 $p_v/m_v$ and $q_v/m_v$ both constant on the tail, which since $m = \beta p + (1-\beta) q$ is equivalent to the same condition.
4.5 Monotone in $k$¶
Going from $k$ to $k+1$ splits the tail cell into a new singleton and a smaller
tail — a strict refinement of the partition. Applying the log-sum inequality to
the split cell alone shows the coarse-grained divergence cannot decrease.
Empirically this is visible in the table in section 4.2 and asserted in
tests/unit/test_losses_bucketed.py::test_bucketed_is_monotone_non_decreasing_in_k.
4.6 Entropy¶
bucketed_teacher_entropy returns $H(\tilde P)$, which by the grouping property
$$ H(P) \;=\; H(\tilde P) \;+\; \sum_{A \in \mathcal{A}} \tilde p_A \, H(P \mid A) \;\ge\; H(\tilde P) $$
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.
4.7 What the compression actually saves¶
Teacher-side storage and transfer: $k$ indices, $k$ log-probabilities and one
tail scalar per position, instead of $V$ logits. It is also what makes the
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 $\tilde q_{\perp}$ meaningful — so the
$[chunk, V]$ student tensor is built either way.
5. Floors¶
Two independent floors keep the tail arithmetic finite. They interact, and the interaction is a common source of error, so both are spelled out.
5.1 log1mexp and its two regimes¶
log1mexp(x) computes $\log(1 - e^{x})$ for $x \le 0$, which is needed to turn
the covered mass $\log \sum_{j \in S} p_j$ into the tail
$\log\left(1 - \sum_{j \in S} p_j\right)$.
A single formula loses precision at one end or the other, so two are used, split at $x^{*} = -\log 2$ (Mächler, 2012):
| regime | condition | formula | why |
|---|---|---|---|
| near zero | $x > -\log 2$, i.e. $e^{x} > \tfrac12$ | $\log(-\mathrm{expm1}(x))$ | expm1 avoids the catastrophic cancellation in $e^{x} - 1$ for small $\lvert x\rvert$ |
| far from zero | $x \le -\log 2$ | $\mathrm{log1p}(-e^{x})$ | log1p avoids losing the small $e^{x}$ against 1 |
Both branches are evaluated on sanitized inputs. torch.where substitutes
the safe value $-\log 2 - 1$ into whichever branch is inactive, then selects.
The naive version — evaluating both branches on the raw tensor and selecting
with torch.where — produces a correct forward value and a NaN gradient,
because the discarded branch still contributes to the backward pass.
5.2 dtype-aware neg_clamp_for¶
log(1 - exp(x)) diverges to $-\infty$ as $x \to 0^{-}$. Before anything else,
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 dtypes
For float32 this bounds the output near
$$ \log!\left(1 - e^{-\epsilon_{32}}\right) \;\approx\; \log(\epsilon_{32}) \;\approx\; -15.94 . $$
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 $-\infty$ for a near-deterministic teacher — makes the reverse-KL tail term $+\infty$ whenever the student leaks any probability outside the top-k, which is always true early in training.
5.3 tail_epsilon¶
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 $[N, k+1]$ vectors, so they are exact probability distributions and the divergence is guaranteed non-negative. The normalizer is $Z \in [1, 1+\varepsilon]$. When $k=V$, the exactly empty tails bypass this block and the identity partition stays exact.
5.4 The bound the floor buys¶
Write $\varepsilon$ for tail_epsilon. After flooring and renormalizing,
$\tilde p_{\perp} \ge \varepsilon / (1+\varepsilon)$, so the reverse-KL tail
term is bounded:
$$ \tilde q_{\perp} \log \frac{\tilde q_{\perp}}{\tilde p_{\perp}} \;\le\; 1 \cdot \log \frac{1+\varepsilon}{\varepsilon} \;\le\; \log\frac{1}{\varepsilon} + \varepsilon . $$
At the default $\varepsilon = 10^{-9}$ that is $20.72$ nats. Without the floor the same term is $+\infty$.
The two floors compose, and the tighter one wins. For $k < V$ a float32
teacher tail has already passed through log1mexp, so it is at least
torch.finfo(torch.float32).eps (about $1.19\times10^{-7}$). The
tail_epsilon clamp does nothing unless it is larger. The effective bound in
the default float32 configuration is therefore about $15.94$ nats, not $20.72$.
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 $\varepsilon = 10^{-3}$ and $10^{-6}$ the tail_epsilon clamp binds and the
value tracks $\log(1/\varepsilon)$. At $\varepsilon = 10^{-9}$ it does not: the
value saturates at 15.95, matching the float32 dtype-aware clamp. The run stays
finite either way, which is the property being bought.
5.5 LOG_PROB_FLOOR¶
safe_log_prob clamps log-probabilities at LOG_PROB_FLOOR = -1e30 before any
subtraction in kl_from_log_probs. In float32 $\exp(-10^{30})$ is exactly
0.0, so clamping never changes a probability, but it keeps every difference
$\log p - \log q$ finite and therefore keeps the gradient free of the
$\infty - \infty = \mathrm{NaN}$ that an unclamped $-\infty$ would produce.
6. Weight normalization¶
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.
$$ \mathcal{L} \;=\; \frac{\sum_{i=1}^{N} w_i \, \ell_i}{W'}, \qquad W' = \begin{cases} \sum_{i=1}^{N} w_i & \sum_i w_i > 0 \[2pt] \varepsilon_W & \sum_i w_i = 0 \end{cases}, \qquad \varepsilon_W = \texttt{MIN_TOTAL_WEIGHT} = 10^{-12} $$
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 $\ell_i$ is the per-position value at selected position $i$ and $w_i$ is
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.
6.1 An exact statement about the floor¶
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
$\varepsilon_W$ is ever used.
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 $2.22 \times 10^{-16}$ and one unit value, clamping the
denominator to $10^{-12}$ returns $2.22 \times 10^{-4}$ instead of the true mean
of $1.0$. Dividing by the real total is both correct and safe, because the true
weighted mean is always bounded by the range of the values however small the
weights are.
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.
7. Chunking¶
7.1 Why¶
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.
7.2 The two-stage backward, and why the gradient is identical¶
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 two
Claim. The parameter gradients are identical to those of the unchunked
computation, in exact arithmetic, for any chunk_size.
Proof. Let $h \in \mathbb{R}^{N \times H}$ be the selected hidden states, a differentiable function of the backbone parameters $\theta$, and let the LM head have parameters $\phi$. The loss is
$$ \mathcal{L} \;=\; \frac{1}{W}\sum_{i=1}^{N} w_i \, \ell_i(h_i; \phi), \qquad W = \sum_{i=1}^{N} w_i , $$
where $\ell_i$ depends on row $i$ of $h$ and on nothing else. Partition ${1..N}$ into contiguous chunks $C_1,\dots,C_m$ and set
$$ \mathcal{L}c \;=\; \frac{1}{W}\sum w_i \, \ell_i(h_i; \phi) . $$
Because every chunk divides by the same global $W$, $\sum_c \mathcal{L}_c = \mathcal{L}$
identically — this is the single detail that makes the whole scheme correct, and
it is why denom is computed once from the full weight vector before the loop.
Gradients are linear in that decomposition:
$$ \frac{\partial \mathcal{L}}{\partial \phi} = \sum_{c} \frac{\partial \mathcal{L}c}{\partial \phi}, \qquad \frac{\partial \mathcal{L}}{\partial h} = \sum, $$} \frac{\partial \mathcal{L}_c}{\partial h
and the rows of $\partial \mathcal{L}_c/\partial h$ are zero outside $C_c$.
Stage one runs backward() on each $\mathcal{L}_c$ with work as a leaf, which
accumulates exactly $\sum_c \partial \mathcal{L}_c / \partial \phi$ into the LM
head parameters and exactly $\sum_c \partial \mathcal{L}_c / \partial h$ into
work.grad. Stage two calls hidden_states.backward(gradient=work.grad), which
by the chain rule contributes
$$ \frac{\partial \mathcal{L}}{\partial \theta} = \left(\frac{\partial h}{\partial \theta}\right)^{!\top} \frac{\partial \mathcal{L}}{\partial h} $$
and runs the backbone backward exactly once. $\square$
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 $10^{-9}$
level, three to four orders of magnitude below 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.
7.3 The cross-entropy term¶
Per selected position, with target token $y_i$,
$$ \ell^{\mathrm{CE}}{i} \;=\; -\log \mathrm{softmax}(z^{S}) $$
at temperature 1, and the combined per-position value is a convex combination
$$ \ell_i \;=\; (1 - c)\,\ell^{\mathrm{div}}{i} \;+\; c\,\ell^{\mathrm{CE}}, \qquad c = \texttt{loss.sampled_token_nll_weight} \in [0, 1] . $$
When no teacher provider is supplied — the SFT path — the loss is pure
cross-entropy and the trainer forces $c = 1$. In a distillation run, $y_i$ is
the token actually sampled from the student policy at that selected position;
this term is therefore sampled-token NLL, not ground-truth CE and not teacher
hard-label CE. When 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.
8. Test index¶
Every claim above is backed by at least one executed test. The mapping:
tests/unit/test_losses_exact.py¶
| 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 $\beta \in {0.1, 0.5, 0.9}$ | test_jsd_matches_brute_force |
| Forward and reverse are distinguishable, and swapping the arguments swaps them | test_orientation_forward_and_reverse_differ |
| $D(P|P) = 0$ for all three | test_identical_distributions_are_zero |
| All three are non-negative | test_divergences_are_non_negative |
| Symmetric JSD is bounded by $\log 2$ | test_jsd_is_bounded_by_log_two |
| $\beta \in {0, 1}$ is rejected | test_jsd_rejects_degenerate_beta |
| Finite at logit scale $10^3$ and $10^4$ | 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 $\tau^{2}$ factor is exactly $\tau^{2}$ | test_temperature_squared_scaling_is_applied |
| The flag is a no-op at $\tau = 1$ | 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.
tests/unit/test_losses_bucketed.py¶
| 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 |
| $k = V$ gives an exactly empty tail | test_topk_equal_to_vocab_gives_exactly_empty_tail |
| $k = V$ reproduces the exact loss (all three divergences) | test_full_k_converges_to_the_exact_loss |
| Bucketed $\le$ exact for $k \in {1,2,8,32}$, all three divergences | test_bucketed_lower_bounds_exact |
| Bucketed is non-decreasing in $k$ | 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 |
tests/unit/test_chunked_equivalence.py¶
| Claim | Test |
|---|---|
The loss value is chunk-invariant for chunk_size $\in {1,3,8,37,1000}$ |
test_chunked_value_matches_unchunked |
The gradient matches an unchunked reference for chunk_size $\in {1,5,37}$ across all three divergences (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.
tests/property/test_property_losses.py¶
Hypothesis searches over vocabulary sizes 2–24, 1–5 rows and logits in $[-30, 30]$, 60 examples per property.
| 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 $\log 2$ | 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 $[0, \log V]$ | test_entropy_is_between_zero_and_log_vocab |
| The shipped smoothed bucketed objective is empirically $\le$ exact on the float64 property grid | test_bucketed_never_exceeds_exact |
$k = V$ reproduces exact to 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
$V = k + 1$ — so in float32 the two sides can differ by a few units in the last
place in either direction. The float32 behaviour is covered separately by
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.
9. References¶
- 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.