Fix multiclass macro F1 to average per-class F-scores#382
Conversation
For multiclass inputs F1::get_score returned the F-measure of the macro-averaged precision and recall, instead of the mean of the per-class F-measures. The two agree only when every class has the same precision and recall, so macro F1 was wrong for most multiclass inputs (e.g. 0.860 vs sklearn's 0.822 on a 3-class example where precision and recall already match sklearn). Compute per-class F-beta over the label set and average; the binary path is unchanged.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## development #382 +/- ##
===============================================
- Coverage 45.59% 44.47% -1.13%
===============================================
Files 93 94 +1
Lines 8034 8086 +52
===============================================
- Hits 3663 3596 -67
- Misses 4371 4490 +119 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Thanks @gaoflow ! Here a brief review with some minor changes to apply. SummaryThe PR correctly identifies and fixes a real bug: the old code computed Correctness IssuesDead code / unreachable branch:
Design / ArchitectureDual iteration over Duplication with
Minor / Style
Suggested Fix for Dead Code// Replace:
if classes == 0 {
0.0
} else {
fbeta_sum / classes as f64
}
// With:
fbeta_sum / classes as f64
// (classes >= 3 is guaranteed by the outer `else` branch; classes == 0 is caught by n == 0 guard)Verdict✅ Correct fix, good tests — the core change is sound and the existing |
Mec-iS
left a comment
There was a problem hiding this comment.
Thanks, I will merge these changes and apply the fixes myself.
* fix(f1): review follow-ups from #382 Apply review feedback on the multiclass macro F1 fix from PR #382: - Add an early `n == 0` return so the `classes == 0` branch in the multiclass path is genuinely unreachable, then drop it. Without the guard, removing that branch would return NaN (0.0/0.0) on empty input. - Merge the two O(n) passes over y_true/y_pred (classes_set build and tp/support/predicted build) into a single pass. - Rename `sup` -> `support_count` for clarity. - Document the `to_f64_bits()` key convention (-0.0 vs +0.0), the positive-class bit-representation assumption baked into the binary path, and the `classes == 1` behaviour in the multiclass path. - Add `f1_single_class` test covering the single-class edge case. No behaviour change beyond #382; all existing tests pass unchanged. * fix(clippy): use HashMap::values() when key is unused Fixes `clippy::for_kv_map` lint in fastpair.rs:149 by iterating `distances.values()` instead of `distances.iter()` with an unused key binding.
…Recall/F1 (#384) * refactor(metrics): share per-class confusion counts across Precision/Recall/F1 Addresses the architectural review feedback from #382: the multiclass F1 path re-implemented per-class precision/recall bookkeeping using raw HashMaps, duplicating logic that already lived (in slightly different form) inside Precision and Recall. Introduce a pub(crate) `ConfusionCounts` helper (new src/metrics/confusion.rs) that computes the per-class tp / predicted / support / class set in a single pass. Precision and Recall each expose a crate-private `per_class_scores_from_counts` method deriving their per-class scores from those shared counts, and their `get_score` implementations are unified onto the same single-pass counts (the binary and multiclass paths now share one code path that either picks the positive class or macro-averages). F1's multiclass path builds the counts once and consumes the per-class precision/recall scores from Precision and Recall, eliminating the duplicated bookkeeping while keeping a single source of truth. Binary behaviour is preserved exactly (verified against all existing tests, including the {0,1} positive-class convention): for {0,1} labels the per-class positive-class precision/recall match the previous direct-computation formulas term-for-term. Also: add an `n == 0` early return to Precision/Recall (matching F1 post-#383) so the multiclass path can assume `classes >= 1`, and drop the now-dead `classes == 0` / `support.is_empty()` branches. README: bump the install snippet from ^0.4.3 to ^0.5.2 (current Cargo.toml version) and note the metrics refactoring in the roadmap. No behaviour change; all 432 lib tests + 67 doctests + 40 metrics tests pass under `cargo test --all-features`. `cargo fmt --check` and `cargo clippy --all-features -Dwarnings` are clean. * chore: bump to v0.5.3 - Cargo.toml: 0.5.2 -> 0.5.3 - README install snippet: ^0.5.2 -> ^0.5.3 - CHANGELOG: add [0.5.3] entry covering the metrics refactoring * refactor(metrics): address #384 review feedback - Rename ConfusionCounts::from -> new to avoid shadowing the std::convert::From trait convention (review blocker). - Add direct unit tests for ConfusionCounts (binary basic, multiclass, spurious predicted label, empty input, perfect predictions) covering tp/predicted/support/classes_set accessors — the helper was previously only tested indirectly through Precision/Recall/F1. - Add precision_binary_spurious_predicted_label test documenting the binary edge case where y_pred contains a label not in y_true: the new path uses predicted(positive) as the denominator (matching sklearn), so a spurious predicted label does not inflate it. The pre-refactor binary path would have counted it as a false positive (2/3 instead of 1.0). - Document that per_class_scores_from_counts iterates only over classes_set (y_true labels), so labels appearing only in y_pred are silently ignored — matching sklearn's behaviour. - Add order-independence comments for the HashMap::values().sum() calls in the Precision/Recall multiclass paths. - Clarify the binary Precision comment to note that the denominator is predicted(positive), not tp + fp_count, so spurious predicted labels do not affect the score. All 438 lib tests + 67 doctests + 46 metrics tests pass; fmt and clippy --all-features -Dwarnings clean.
Checklist
Current behaviour
For multiclass inputs
F1::get_scorereturns the F-measure of the macro-averagedprecision and recall,
Fβ(mean_c Pc, mean_c Rc). Macro F is defined as the mean of theper-class F-measures,
mean_c Fβ(Pc, Rc); the two are equal only when every class has thesame precision and recall, so the result is wrong for most multiclass inputs.
Example (3 classes, chosen so precision and recall already match sklearn, which isolates the
aggregation):
sklearn.metrics.f1_score(average='macro')returnsmean([2/3, 0.8, 1.0]) = 0.822222.New expected behaviour
For the multiclass case, compute per-class precision/recall/F-beta and average over the
classes, matching sklearn's
average='macro'(the same oracle used for the recent AUC fix#374). F1 on the example above is now
0.822222, and the correction applies forbeta != 1as well (e.g.
beta=2goes from0.843882to sklearn's0.821549).The binary path is unchanged (still the positive-class F-measure), so the existing
f1testpasses byte-for-byte. Added
f1_multiclass_macro,f1_multiclass_macro_betaandf1_multiclass_imbalanced, all of which fail against the old aggregation.Note: this changes only the F aggregation.
Precision/Recallderive their class set fromy_true, so a class that is predicted but never true (or a third predicted label wheny_truehas two) is handled exactly as before — that label-set question is separate and leftuntouched here.
Change logs
Changed
F1/F-beta is now the mean of the per-class F-measures instead of the F-measureof the macro-averaged precision and recall (
average='macro', per sklearn).