Give classes with no holdout support a neutral decision threshold - #1152
Open
LeoGrin wants to merge 4 commits into
Open
Give classes with no holdout support a neutral decision threshold#1152LeoGrin wants to merge 4 commits into
LeoGrin wants to merge 4 commits into
Conversation
find_optimal_classification_thresholds tuned a threshold for every class in
range(n_classes), including classes with no positive rows in the tuning
holdout. Rare classes can be absent entirely: StratifiedKFold allocates folds
by class count, so a class with a handful of samples deterministically receives
zero holdout rows.
With no positive rows every threshold scores identically, so the search returned
an arbitrary value that then fed the divisive reweight in _maybe_reweight_probas
(probas / threshold). Per metric on the resulting threshold for the absent class:
roc_auc nan at every threshold; argmin on an all-nan array returns
index 0, so the smallest threshold in the grid wins. That
is a ~24x boost relative to the tuned classes, applied to
the class with the least evidence.
log_loss raised ValueError (y_true contains only one label).
f1, balanced_acc ~2.6x suppression relative to the tuned classes.
accuracy roughly neutral by coincidence.
Classes with fewer than two distinct labels in the holdout are now skipped and
assigned the mean of the successfully tuned thresholds. A fixed constant would
not work: tuned thresholds sit near 0.6 for accuracy but near 0.2 for f1, so any
constant boosts the class under one metric and suppresses it under another. When
no class is tunable the thresholds are uniform, which cancels in the caller's
renormalization and leaves predictions untouched.
Suppression was not a safe default. The threshold layer exists to counteract the
model's under-prediction of rare classes, and under balanced_accuracy the tuned
threshold correlates with class frequency at +0.99 -- rarer classes get a larger
boost. An absent class is the rarest of all, so suppressing it inverts the
treatment every other rare class receives in the same fit. Over 8 seeds on
synthetic 6-class data with two classes withheld from the holdout, neutral beat
suppression 8/8 for both metrics (balanced_accuracy +0.0138 vs +0.0112, macro-F1
+0.0151 vs +0.0080 against the untuned baseline).
select_robust_optimal_threshold additionally now ignores non-finite losses
rather than letting them reach np.argmin, where they silently selected the
first, most aggressive threshold.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Fill untunable classes with the geometric mean of the tuned thresholds: thresholds act as divisive reweights, so only their ratios matter and the geometric mean carries the average multiplier on the log scale. - Correct the guard comment and test docstring: for accuracy, balanced_accuracy, and log_loss the losses vary with the threshold (degenerate negatives-driven plateau); they are identical only for f1 and roc_auc. - Drop the stale log_loss crash claim from the changelog fragment; that crash was fixed by #1140 and shipped in v8.3.0. - Pin tuned classes to a direct single-class search in the absent-class test, and add a positive-loss non-finite case so a nan-to-zero fill regression cannot pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
find_optimal_classification_thresholdstuned a threshold for every class inrange(n_classes), including classes with no positive rows in the tuning holdout. Rare classes can be absent entirely:StratifiedKFoldallocates folds by class count, so a class with a handful of samples deterministically receives zero holdout rows.With no positive rows every threshold scores identically, so the search returned an arbitrary value — which then fed the divisive reweight in
_maybe_reweight_probas(probas / threshold, then renormalize). Per metric, the threshold produced for the absent class:roc_aucnanat every threshold;argminon an all-nanarray returns index 0log_lossValueErrorbefore v8.3.0)f1,balanced_accuracyaccuracyThe
roc_auccase is the sharpest: the class with the least evidence receives the largest boost, silently. On synthetic 6-class data that moved an absent class from 0.1% to 49.8% of predictions.Note the
log_lossrow is why this PR is still needed after #1140 (shipped in v8.3.0). That PR fixes the crash by passinglabels=[0, 1], which is correct — but it turns a loud failure into a quiet one: the search then runs, finds no signal, and suppresses the class. The guard here is what actually resolves the underlying problem.Fix
Classes with no positive (or no negative) holdout rows are skipped and assigned the geometric mean of the successfully tuned thresholds — thresholds act as divisive reweights, so only their ratios matter and the geometric mean carries the average multiplier on the log scale.
A fixed constant would not work: tuned thresholds sit near 0.6 for
accuracybut near 0.2 forf1, so any constant boosts the class under one metric and suppresses it under another. When no class is tunable the thresholds come out uniform, which cancels in the caller's renormalization and leaves predictions untouched.select_robust_optimal_thresholdadditionally ignores non-finite losses rather than letting them reachnp.argmin, where they silently selected the first — most aggressive — threshold.Why neutral rather than suppression
Suppressing a class absent from the holdout looks conservative but is not. The model already under-predicts rare classes heavily (in the test setup, a class with 3.2% true frequency is predicted 0.12% of the time). The threshold layer exists to counteract that: under
balanced_accuracythe tuned threshold correlates with class frequency at +0.99 — rarer classes get a larger boost. An absent class is the rarest of all, so suppressing it inverts the treatment every other rare class receives in the same fit.Validated on real datasets with real TabPFN probabilities (digits K=10, wine K=3, covtype K=7 subsampled to 6k; classes geometrically subsampled so genuine rare classes exist; 8 seeds; holdout deliberately small and unstratified so rare classes can miss it). Paired per-seed,
fixvs pre-fixmain:No meaningful regression anywhere: the only negative cells are -0.0001 and -0.0000 on
roc_auc. And on real imbalanced multiclass data thelog_losscrash is not an edge case -- it fired on 8/8 seeds for both digits and covtype.Behaviour impact
Only affects fits with
tune_decision_thresholds=Trueand a class with no holdout support. Thresholds for tunable classes are unchanged, so fits where every class appears in the holdout are bit-for-bit identical.Tests
Nine regression tests, all failing on
main:find_optimal_classification_thresholdsparametrized over all five metrics — the absent class must get the geometric mean of the tuned thresholds, and every tunable class must match a direct single-class search (covers thelog_lossmis-thresholding and theroc_auc0.01 collapse).select_robust_optimal_thresholdmust fall back to the midpoint when all losses are non-finite, and must never let ananbeat a finite loss — including when all finite losses are positive (thelog_lossregime), where an internal zero-fill would win the argmin.Not addressed here
metric_name="roc_auc"remains tunable in this PR. It is handled separately in #1153, which rejects the combination outright: the threshold search scores thresholded labels, so it was silently optimizing balanced accuracy rather than ROC AUC (identical threshold vectors), and the reweighting measurably lowers macro OvR AUC.Best merged in that order -- this PR first, #1153 on top -- since landing #1153 first makes the
roc_aucportion of the motivation here unreachable through the classifier path. The guard in this PR still stands on its own for the other metrics, notably thelog_lossmis-thresholding (whose pre-v8.3.0 crash fired on 8/8 seeds on digits and covtype) and thef1/balanced_accuracygains.Also not addressed (pre-existing on
main, surfaced during review): the sibling rare-class arrangement still crashesfit()upstream of this code. When the rare class's rows land in a used tuning fold's holdout, that fold's sub-classifier trains without the class and emitsK-1logit columns — under auto tuning settings this fails at thenp.concatenatein_compute_holdout_validation_data(classifier.py:1353), and withtuning_n_folds=1it is anIndexErroraty_pred_probas[:, i]. Since auto settings use every fold for n ≤ 5000, the holdout union covers the whole training set there, so the absent-from-holdout path this PR fixes is reachable only for n > 5000 (auto uses 3 of 5 folds) or an explicittuning_n_foldsbelow the fold count; below that, the singleton-class scenario hits the upstream crash instead. Deserves its own issue and an end-to-end rare-class tuning test once fixed.Prior art, and the choice of fallback value
Neither sklearn nor AutoGluon offers multiclass decision-threshold tuning: sklearn's
TunedThresholdClassifierCVraisesValueError("Only binary classification is supported")(multiclass is open issue scikit-learn#30970), and AutoGluon'scalibrate_decision_thresholdassertsproblem_type == 'binary'.That is not evidence against what we do here, because we are not doing the thing they decline. They refuse multiclass thresholding because per-class cutoffs do not compose into a decision rule — rows appear where two classes both clear their threshold, or none do, with no principled tiebreak. This code never thresholds at predict time:
_maybe_reweight_probasturns the tuned values into per-class reweighting factors (probas / t, renormalize, argmax), which is always well defined.So the mechanism is closer to learned class-prior reweighting (
class_weight='balanced') than to threshold tuning, with weights coming from a validation search rather than training frequencies. Two consequences worth noting:probas[:, i] >= t) but applied as divisors. That mismatch costs almost nothing in practice: a direct coordinate search over the divisors actually used scores 0.3491 vs 0.3480 for the current approach on held-out balanced accuracy (untuned baseline 0.2416), because the two are monotonically related and argmax after renormalization only depends on relative magnitudes.minbelow.For the fallback value itself there is no established practice to copy. The nearest reference point is sklearn's
compute_class_weight, which derives weights from training frequencies and refuses to produce one for a class absent from the data rather than inventing a value.meanis what this PR originally shipped, and the evidence in the table below favoursmin; review resolved this on the geometric mean — see the end of this section. On real data, restricted to seeds where a class was genuinely absent (8/8 seeds on both datasets):The rationale for
min: underbalanced_accuracythe tuned threshold correlates with class frequency, so the absent class -- the rarest of all -- belongs at the bottom of the tuned range rather than its middle.The caution: the win/loss records are near even (3/3, 3/3, 4/3).
minwins bigger than it loses rather than winning more often, which is the high-variance signature expected of an order statistic. At n=8 the per-seed evidence is weak even though the mean effect is consistent across two unrelated datasets.Resolution (after review): the PR now ships the geometric mean of the tuned thresholds. The thresholds are applied as divisors and only their ratios survive renormalization, so neutrality lives on the log scale: the arithmetic mean of thresholds is the harmonic mean of the multipliers
1/t— the most suppressive classical mean, with AM/GM reaching ~1.45 on realisticbalanced_accuracythreshold spreads — while the geometric mean carries exactly the average multiplier. It is derivable from the mechanism rather than fitted to n=8 seeds, and it sits betweenmeanandmin, capturing part ofmin's expected gain without betting on an order statistic.