13 KiB
impl/loss-functions
- impl/loss-functions
- KernelizedROLLoss — Key Design Decisions
- KernelizedROLLoss Unification (2026-07-14)
- BCE Combination
- ISJ Bandwidth Failure
- Gamma Scheduling
- LibAUC Integration (2026-07-14, tuned 2026-07-14)
- Grace Period (BasicCriteriorator / CRBasedCriteriorator, 2026-07-14)
- Naming fix:
_kernelized_roll_fpr_internal'sfprparam renamedcdf_target(2026-09-10) - Verified correct (not a bug):
_kernelized_roll_tpr_internal's1-yhand un-complementedtpr(2026-09-10) - MAJOR: appendix_roll_code.tex was stale relative to impl/src/roll.py (2026-09-10)
- Fixed: Gaussian ROLL
_torch_mean_stdused Bessel-corrected std, not MLE (2026-09-10)
impl/loss-functions
KernelizedROLLoss — Key Design Decisions
_icdfuses bisection (not Newton-Raphson). NR was replaced becauseF_primegoes near-zero whenvis large andtaudrifts, causing divergence. Bisection bracket is[min(scores)−10/v, max(scores)+10/v], guaranteed to converge.- Score normalisation (
normalizeflag): dividesyhby its std before KDE. Can be disabled viakernelized_roll_fpr(fpr, normalize=False). The chain-rule correction (multiply grad byscore_scale) is applied in backward. _grad_tau_scores_negdenominator clamped at1e-10to prevent 0/0 when all sigmoid kernels saturate.- Variance clamped at
1e-8in normalisation to preventinfwhen all batch scores are identical.
KernelizedROLLoss Unification (2026-07-14)
KernelizedROLLoss and KernelizedROLLossAOC merged into a single class accepting alphas (list of CDF targets) and divisor:
- Point-FPR:
KernelizedROLLoss.apply([1−fpr], 1, yh, y, gamma, normalize) - AOC:
KernelizedROLLoss.apply(_AOC_ALPHAS, _AOC_DIVISOR, yh, y, gamma, normalize)
Backward always vectorised (K-dim); K=1 reduces to the scalar case. ctx.divisor stores the non-tensor divisor.
BCE Combination
Both kernelized_roll_fpr and kernelized_roll_tpr accept bce_weight (default 0.0) and bce_pos_weight (default 1.0).
Loss = ROLL + bce_weight × BCE. Standard value: bce_weight=0.5, bce_pos_weight=num_false/num_true.
ISJ Bandwidth Failure
ISJ fails when a class has too few samples in a split (~<20). Affects: cleveland, ecoli, glass4, glass5.
Gamma Scheduling
KernelScheduler widens KDE kernels early then anneals. Default: initial_gamma=100, decay=0.5, decay_every=500. Gamma is a divisor of v.
LibAUC Integration (2026-07-14, tuned 2026-07-14)
libauc_auc_loss in src/roll.py wraps AUCMLoss (Yuan et al., ICCV 2021) to the (yh, y) convention.
- Applies
torch.sigmoid(yh)before passing to AUCMLoss (squared-hinge surrogate, no built-in sigmoid). - AUCMLoss has learnable params
a, b, alpha— must use PESG optimizer (not Adam). opt_factorysignature:opt_factory(model)— does NOT take criteriorator (would be unbound at call site in_perform_episode).opt_factorypattern:loss_fn.pesg_opt_factory(...)returns picklable_PESGFactorysharing the same innerAUCMLossinstance as the criteriorator's loss func (preserved by pickle memo).ExperimentConfigurationgainedopt_factory: callable = None; overridesoptim_class/optim_argswhen set.tqdmis an undeclared dep of libauc — must be inpropagatedBuildInputsin flake.nix.
Recommended settings (v2 + adam, tuned 2026-07-14)
version'v2'= — removes class prior from formulation; LibAUC explicitly recommends over v1; default inlibauc_auc_loss.mode'adam'= in PESG — Adam-style primal updates, converges much faster than SGD on small datasets; default inpesg_opt_factory.imratiopassed explicitly asnum_true / (num_true + num_false)— avoids noisy mini-batch estimates on imbalanced data. Note: v2 ignores imratio (prior-free), so this only matters if reverting to v1.lr=0.1(PESG default, much larger than Adam's 1e-3). If diverging: tryepoch_decay=0.0or drop lr to0.01.
Grace Period (BasicCriteriorator / CRBasedCriteriorator, 2026-07-14)
grace_period: int = 0 parameter added to both criteriorators. During the first grace_period epochs:
best_flagis always False (no model snapshot taken)- Patience counter does not increment (early stopping cannot fire)
All keel configs use grace_period=50. Prevents noisy early-epoch models from being selected as best.
Naming fix: _kernelized_roll_fpr_internal's fpr param renamed cdf_target (2026-09-10)
Mentor-audit finding (screenshots, no direct cite): kernelized_roll_fpr(fpr, ...) calls
partial(_kernelized_roll_fpr_internal, fpr=1 - fpr, ...) — the INTERNAL function's fpr
parameter actually holds 1-fpr (the CDF target, thesis's 1-α), not the FPR itself, the
opposite of what its name implies. Logic was always correct (verified independently, see
next entry); only the name was misleading. Fixed by renaming the internal parameter (and
the keyword in the partial(...) call) to cdf_target — user's preferred name over
alpha (avoids clashing with the thesis's own use of α to mean the RAW target rate, not
its complement). The PUBLIC kernelized_roll_fpr(fpr, ...) parameter is untouched — it's
correctly named; the complement happens only when forwarding internally. No callers use
fpr= as a keyword arg anywhere in the codebase, so the rename is fully self-contained.
Mirrored the identical rename in the thesis's own code listing
(content/appendixs/appendix_roll_code.tex, which reproduces this function verbatim) so
the two don't drift apart.
Verified correct (not a bug): _kernelized_roll_tpr_internal's 1-yh and un-complemented tpr (2026-09-10)
While investigating the fpr=/=cdf_target naming issue above, also audited two
look-alike oddities in the neighboring _kernelized_roll_tpr_internal — both checked out
as correct, for non-obvious reasons worth remembering before anyone "fixes" them later:
1 - yhinstead of-yhfor the score transform (thesis's stated general rule says "negate scores", i.e.-f_theta(x)). Directly compared1-yhvs-yhas inputs: identical loss and gradients to ~3e-8 (float noise). Reason:1-yh = -(yh-1), i.e. negation plus a constant shift, andKernelizedROLLossis translation-invariant in its scores (same property underlying the thesis's gradient-balance proof) — so the shift is silently absorbed and does nothing. Added a one-line code comment at the call site (roll.py, right above theKernelizedROLLoss.applycall in_kernelized_roll_tpr_internal) explaining this, so it doesn't look like a stray bug to a future reader — this comment is the only thing that changed; the surrounding logic was intentionally left as-is.kernelized_roll_tpr(tpr)passestprstraight through with no complement, whilekernelized_roll_fpr(fpr)complements (1-fpr). Looks asymmetric, but is required by the math: reducing FPR@TPR(β) to the TPR@FPR machinery requires complementing the target rate to(1-β)first (per the thesis's own reduction rule), and separately, evaluating TPR@FPR at any rateγuses CDF-target1-γinternally — chain the two complements:1-(1-β)=β. They cancel exactly, so the rawtprvalue reaching the internal CDF-target ISβcorrectly, no complement needed. The apparent asymmetry between the two wrappers is expected, not a bug.
Closed the loop with an independent numerical check (not just internal
self-consistency): hand-computed 1 - F0_hat(F1_hat^{-1}(1-beta)) (thesis's
eq:roll-fpr-at-tpr) completely from scratch in a throwaway script — same normalization,
same KDEpy bandwidth selector, same sigmoid kernel, same bisection, but applied directly
to the ORIGINAL untransformed (yh, y) with no negation/swap trick at all. Result matched
_kernelized_roll_tpr_internal's actual output to ~3e-5 (bisection tolerance noise).
Confirms the function exactly reproduces the paper's formula. No code fix needed for
either point beyond the one comment above.
MAJOR: appendix_roll_code.tex was stale relative to impl/src/roll.py (2026-09-10)
User asked directly: "did we ever update the code [listing] after we changed [the real
code]… is the code in the thesis matching actual code?" Answer at the time: NO. Did a
full AST-based function-by-function diff (Python ast module, not just eyeballing) of
every function in appendix_roll_code.tex against impl/src/roll.py. Found:
Serious (pre-fix or actively-buggy code shown as current):
_icdf/_icdf_batch: listing showed the OLD signatures entirely (singlev=/=scoresinstead ofv0=/=scores_neg=/=v1=/=scores_pos, old fixed+-10/vbracket margin instead of the exacth0*log(alpha/(1-alpha))bracket, old "stop on CDF residual" criterion instead of the certified-loss-interval one) – none of the bisection-bracket / stopping-criterion audit fixes from earlier this session had been mirrored here.forward()'s calls to these were correspondingly stale (3-arg, not 5-arg).backward(): showed the OLD, numerically-vulnerablekernel = s_neg*(1-s_neg)gradient formula – the EXACT formula independently found to have a live bug (collapses to an all-zero gradient when scores saturate) and replaced with the log-space =_log_sigma_prime=/softmax version earlier this session. The listing never got that fix, so it directly contradicted appendix_kde_grad.tex (which documents the fix as done) – a self-contradiction within the thesis between two appendices._log_sigma_primeitself was MISSING from the listing entirely (not just stale) –backward()as shown couldn't even have called it.
Minor (comments from this session's smaller fixes not mirrored):
_torch_mean_std: listing still showedtorch.std(yh)(Bessel-corrected), not thecorrection=0MLE fix from earlier the same day (see next entry)._kernelized_roll_tpr_internal: missing the1-yhtranslation-invariance comment.
Also softened the appendix's opening claim from "reproduced verbatim" to "reproduced …
reformatted for print width … logic is unchanged" – it was never literally verbatim
(unicode sigma/tau stripped to ASCII, citation tags like [eq:sigmoid-kernel] dropped from
docstrings, lines rewrapped for column width), so "verbatim" was itself a small overclaim.
Fixed all of the above directly in appendix_roll_code.tex: ported the current _icdf=/
=_icdf_batch=/=forward=/=backward logic across (reformatted to the listing's existing
ASCII/wrapped style, not raw-pasted), added the missing _log_sigma_prime method right
after _sigma_prime (matching live file ordering), and added the two missing comments.
VERIFICATION METHOD (worth remembering for next time this comes up): manual reading isn't
reliable for this kind of check – used Python's own ast module to extract every
function's source from both files and diff them programmatically. First pass (raw source
diff) caught line-wrapping noise mixed in with real differences; had to write a stricter
second pass that parses both into ASTs, strips docstrings/comments via ast.unparse, and
diffs the RESULT – that gave a clean "0 functions differ in actual executable logic"
confirmation across all 21 functions in the listing. Recommend this exact
ast.parse=+=ast.unparse-diff technique any time "does the thesis's code listing match
the real implementation" needs checking again – eyeballing a big code appendix against a
live file is exactly how this drift went unnoticed for multiple fix-rounds in the first
place.
Rebuilt thesis clean (nix build exit 0) after the fix, confirmed no new undefined references introduced.
Fixed: Gaussian ROLL _torch_mean_std used Bessel-corrected std, not MLE (2026-09-10)
Mentor-audit finding: Appendix A (appendix_gauss_grad.tex) states explicitly "The MLE
parameters are: … σ_k² = (1/|B_k|)Σ(f_θ(x_j)-μ_k)²" — the biased, n-denominator MLE
variance. But _torch_mean_std (roll.py, Gaussian ROLL helpers) used torch.std(yh),
whose default (correction=1) is Bessel-corrected (n-1 denominator) — NOT the MLE. Real
documentation-vs-implementation mismatch, same family as the AOC-divisor issue, but lower
stakes: verified _torch_mean_std=/=_torch_normal_fit are ONLY used by
_roll_loss_from_fpr_internal (Gaussian ROLL), which is NOT among the loss configs used
in any reported experiment (only roll-aoc and roll-tpr90, both KDE-based, are) — so
fixing this required no re-running of any experiment.
Separately checked whether the ANALOGOUS torch.var(yh, correction=1) in KDE score
normalization (score_scale, roll.py ~line 271) has the same problem: it does NOT.
Thesis's own definition of the normalization constant (method.tex, sec:roll-numerical-
stability) is deliberately generic — \hat\sigma_\mathcal{B} = sqrt(Var(...)) with no
n vs n-1 commitment anywhere, unlike Appendix A's explicit "MLE" language. Nothing textual
is being contradicted there. Also, per the thesis's own prop:normalization-invariance,
for Silverman's rule (scale-equivariant) the exact value of σ̂_B doesn't affect the loss
or gradient AT ALL; for ISJ (already flagged elsewhere as "a heuristic"), the n vs n-1
choice is only a sqrt(n/(n-1)) deviation (~2.6% at batch 20, <1% by batch 64) – verified
numerically. Left this one alone entirely, per user's explicit instruction to only fix
the Gaussian ROLL side.
Fix applied: torch.std(yh) -> torch.std(yh, correction=0) in _torch_mean_std, with a
one-line comment noting this matches Appendix A's MLE formula rather than torch.std's
Bessel-corrected default. Verified: output now matches a hand-computed
sqrt(mean((x-mean(x))^2)) MLE formula exactly (torch.allclose true), and the full
roll_loss_from_fpr forward+backward still runs cleanly end-to-end after the change.