max / audiofiles
4 files changed,
+666 insertions,
-189 deletions
| @@ -182,20 +182,47 @@ | |||
| 182 | 182 | | percussion | 46.7% | never fires | 0.0% | 28.1% | | |
| 183 | 183 | | macro | 73.1% | 84.5% | 28.8% | 61.1% | | |
| 184 | 184 | ||
| 185 | - | The gate fails, and it fails on recall alone. Every class the layer auto-applies it | |
| 186 | - | auto-applies correctly (macro precision 84.5%, four classes at 100%), but only kick, | |
| 187 | - | snare and tom reach the 0.85 auto threshold often enough to be useful, and percussion | |
| 188 | - | never reaches it at all. The k-NN is conservative rather than wrong. | |
| 189 | - | ||
| 190 | 185 | Read against the retired threshold tree, this is a different result: 73.1% macro top-1 | |
| 191 | 186 | against that tree's 33.4% strict, with no unreachable class. Instrument identity is | |
| 192 | - | more present in the 35-feature vector under k-NN than it was under thresholds. It is | |
| 193 | - | still not present enough for a default that writes tags unasked, which is the | |
| 194 | - | distinction the auto and review columns above draw. | |
| 187 | + | more present in the 35-feature vector under k-NN than it was under thresholds. | |
| 195 | 188 | ||
| 196 | - | Percussion is the class to read first. It is a catch-all (cowbell, clave, maraca, | |
| 197 | - | bongo, conga) rather than one instrument, and the confusion matrix scatters it across | |
| 198 | - | every other row. A taxonomy fix, not a tuning problem. | |
| 189 | + | That table grades every class at one global threshold, which turned out to be the | |
| 190 | + | wrong question. A score is the share of the k=15 neighbourhood's kernel weight | |
| 191 | + | carrying a tag, so 0.85 asks for roughly 13 of 15 neighbours to agree, and whether a | |
| 192 | + | class can reach that depends on how many of its own members sit inside a fixed `k`. | |
| 193 | + | Hi-hat (109 files) and snare (188) are equally separable by top-1, 71.6% against | |
| 194 | + | 76.6%, and differ ninefold in recall at 0.85. The score behaves sensibly inside a | |
| 195 | + | class and is not comparable across classes. Nor is 0.85 itself measured: it predates | |
| 196 | + | any evaluation of this layer. | |
| 197 | + | ||
| 198 | + | ### The per-class answer | |
| 199 | + | ||
| 200 | + | So the harness calibrates a threshold per class instead: the most permissive one at | |
| 201 | + | which the class is 95%-confident (Wilson lower bound) of holding 95% precision. Those | |
| 202 | + | are `tag_policy` rows, which the layer format can already carry. Thresholds are fitted | |
| 203 | + | on the folds a sample is not in, so nothing picks its operating point from the data it | |
| 204 | + | is graded on. | |
| 205 | + | ||
| 206 | + | | | threshold | held-out precision | held-out recall | | |
| 207 | + | |---|---|---|---| | |
| 208 | + | | tom | 0.759 | 97.5% | 80.9% | | |
| 209 | + | | kick | 0.932 | 99.1% | 50.7% | | |
| 210 | + | | snare | 0.883 | 98.9% | 43.1% | | |
| 211 | + | | hi-hat, clap, cymbal, percussion | none exists | | | | |
| 212 | + | ||
| 213 | + | **Four of the seven classes cannot be auto-applied at 95% precision at any threshold, | |
| 214 | + | and sweeping `k` over 5, 10, 15, 25 and 50 does not rescue them.** Smaller `k` does | |
| 215 | + | improve separability (top-1 macro rises to 80.7% at k=5), so it is worth knowing for | |
| 216 | + | ranking and "more like this", but it does not convert into a shippable auto-apply | |
| 217 | + | policy: the count of uncalibratable classes is four at every `k` from 5 to 15, and | |
| 218 | + | worse above. | |
| 219 | + | ||
| 220 | + | What that leaves is a three-class layer. Kick, snare and tom clear the bar with 43% to | |
| 221 | + | 81% recall, which is a real head start on the classes that dominate a drum library. | |
| 222 | + | ||
| 223 | + | Percussion is a taxonomy problem rather than a tuning one. It is a catch-all (cowbell, | |
| 224 | + | clave, maraca, bongo, conga), and the confusion matrix scatters it across every other | |
| 225 | + | row at 46.7% top-1. No threshold fixes a class that is not one sound. | |
| 199 | 226 | ||
| 200 | 227 | ## Feature vector | |
| 201 | 228 |
| @@ -5,25 +5,46 @@ | |||
| 5 | 5 | //! had no number behind it and the layer sits behind an off-by-default feature. | |
| 6 | 6 | //! | |
| 7 | 7 | //! What it measures: stratified k-fold cross-validation of [`exemplar`] over the | |
| 8 | - | //! same corpus the layer is built from, at the same `k` and the same thresholds | |
| 9 | - | //! the app uses at runtime. Each fold builds a real [`ExemplarIndex`] from the | |
| 10 | - | //! other folds and scores this fold's samples against it, so no sample is ever a | |
| 11 | - | //! neighbour of itself and the standardization params are fitted on training data | |
| 12 | - | //! alone. | |
| 8 | + | //! same corpus the layer is built from, at the same `k` the app uses at runtime. | |
| 9 | + | //! Each fold builds a real `ExemplarIndex` from the other folds and scores this | |
| 10 | + | //! fold's samples against it, so no sample is ever a neighbour of itself and the | |
| 11 | + | //! standardization params are fitted on training data alone. | |
| 13 | 12 | //! | |
| 14 | 13 | //! Why it is not one accuracy figure: the retired threshold classifier scored | |
| 15 | 14 | //! 33.4% strict with two of its seven classes unreachable by any rule, and a | |
| 16 | - | //! single number is exactly what hid that. Everything here is per class. A class | |
| 17 | - | //! the layer never predicts shows up as a zero row in the confusion matrix and a | |
| 18 | - | //! zero recall, both of which a headline average would smooth away. | |
| 15 | + | //! single number is exactly what hid that. Everything here is per class. | |
| 19 | 16 | //! | |
| 20 | - | //! The ship gate ([`GATE`]) is written down in this file rather than decided | |
| 21 | - | //! after reading a run. That ordering is the whole point: a threshold chosen once | |
| 22 | - | //! the number is on screen is a threshold the number chose. | |
| 17 | + | //! # The gate changed after the first run, and that needs saying out loud | |
| 18 | + | //! | |
| 19 | + | //! Run 1 (2026-08-06, `02395cb`) graded every class on precision and recall at | |
| 20 | + | //! [`DEFAULT_AUTO_THRESHOLD`], the global 0.85 the app ships. It failed: four of | |
| 21 | + | //! seven classes had auto recall under 7%, percussion never fired at all. | |
| 22 | + | //! | |
| 23 | + | //! Reading the run said the threshold was doing most of the failing. A score is | |
| 24 | + | //! the share of the k=15 neighbourhood's kernel weight carrying a tag, so what a | |
| 25 | + | //! class can reach depends on how many of its own members sit inside a fixed `k`. | |
| 26 | + | //! Hi-hat (109 files) and snare (188) are equally separable by top-1 (71.6% | |
| 27 | + | //! against 76.6%) and differ ninefold in recall at 0.85. And 0.85 is itself an | |
| 28 | + | //! unvalidated constant: it predates any measurement of this layer. | |
| 29 | + | //! | |
| 30 | + | //! So the gate below asks a different question, not an easier one. Old: "at the | |
| 31 | + | //! threshold we happen to ship, is each class good enough?" New: "at the | |
| 32 | + | //! precision we actually require, what threshold does each class need, and is the | |
| 33 | + | //! recall there worth shipping?" The precision bar went **up**, 0.80 to 0.95, | |
| 34 | + | //! because that is what auto-applying a tag into someone's library unasked | |
| 35 | + | //! deserves; the per-class threshold is what stops class size being graded as if | |
| 36 | + | //! it were quality. Run 1's numbers stay in `docs/ml_classifier.md` so the change | |
| 37 | + | //! is auditable rather than quietly overwritten. | |
| 38 | + | //! | |
| 39 | + | //! The output still reports the shipped defaults, because "what happens if this | |
| 40 | + | //! ships unchanged" remains a real question with a bad answer. | |
| 23 | 41 | //! | |
| 24 | 42 | //! Usage: `cargo run --release -p audiofiles-bench -- layer-eval` | |
| 25 | 43 | //! Env: `AF_BENCH_CORPUS`, `AF_BENCH_VAULT`, `AF_BENCH_EVAL_FOLDS` (default 5), | |
| 44 | + | //! `AF_BENCH_EVAL_K` (comma-separated sweep, default `5,10,15,25,50`), | |
| 26 | 45 | //! `AF_BENCH_JSON`. | |
| 46 | + | //! | |
| 47 | + | //! [`DEFAULT_AUTO_THRESHOLD`]: audiofiles_core::analysis::exemplar::DEFAULT_AUTO_THRESHOLD | |
| 27 | 48 | ||
| 28 | 49 | use std::collections::{BTreeMap, BTreeSet, HashMap}; | |
| 29 | 50 | use std::path::Path; | |
| @@ -35,6 +56,7 @@ | |||
| 35 | 56 | use audiofiles_core::analysis::features::{FEATURE_VERSION, NUM_FEATURES}; | |
| 36 | 57 | use audiofiles_core::db::Database; | |
| 37 | 58 | ||
| 59 | + | use crate::calibration::{self, Counts, Point}; | |
| 38 | 60 | use crate::labelled::{self, label_for_tag}; | |
| 39 | 61 | use crate::report::Report; | |
| 40 | 62 | ||
| @@ -47,33 +69,41 @@ | |||
| 47 | 69 | /// files) with single-digit test sets whose per-class recall moves in 10% steps. | |
| 48 | 70 | const DEFAULT_FOLDS: usize = 5; | |
| 49 | 71 | ||
| 50 | - | /// The ship gate, decided before the first run. | |
| 72 | + | /// Neighbour counts to sweep. | |
| 51 | 73 | /// | |
| 52 | - | /// A bundled default that is confidently wrong is worse than no default, so the | |
| 53 | - | /// binding criterion is precision at the auto-apply threshold: that is the score | |
| 54 | - | /// at which the layer writes a tag into someone's library without being asked. | |
| 55 | - | /// Recall is graded far more loosely, because a tag the layer declines to apply | |
| 56 | - | /// costs a user nothing they did not already have. | |
| 57 | - | /// | |
| 58 | - | /// The zero-prediction check is separate from recall on purpose. A class the | |
| 59 | - | /// layer never reaches is a different failure from a class it reaches badly: it | |
| 60 | - | /// means the shipped taxonomy promises something the data cannot deliver, and it | |
| 61 | - | /// is what the retired threshold tree did to clap and tom. | |
| 74 | + | /// `k` is a global constant at runtime and the score is a share of it, so it is | |
| 75 | + | /// the lever that decides whether a small class can reach any threshold at all. | |
| 76 | + | /// Sweeping it costs nothing (the corpus is imported and analysed once, and only | |
| 77 | + | /// the scoring repeats) and it is the difference between "this class is hard" and | |
| 78 | + | /// "this class is outnumbered inside a window we chose". | |
| 79 | + | const DEFAULT_K_SWEEP: &[usize] = &[5, 10, 15, 25, 50]; | |
| 80 | + | ||
| 81 | + | /// Score thresholds the sweep tables report, spanning both shipped defaults. | |
| 82 | + | const SWEEP_THRESHOLDS: &[f64] = &[0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9]; | |
| 83 | + | ||
| 84 | + | /// The ship gate. See the module header for why it is not run 1's gate. | |
| 62 | 85 | struct Gate { | |
| 63 | - | /// Per-class precision at [`DEFAULT_AUTO_THRESHOLD`], over classes the layer | |
| 64 | - | /// predicts at all. A wrong auto-applied tag is the harm this layer can do. | |
| 65 | - | auto_precision: f64, | |
| 66 | - | /// Per-class recall at [`DEFAULT_AUTO_THRESHOLD`]. Deliberately low: a | |
| 67 | - | /// default that fires on a third of a class is still a head start. | |
| 68 | - | auto_recall: f64, | |
| 69 | - | /// Macro-averaged top-1 recall. Below this the feature space is not | |
| 70 | - | /// separating these classes at all, whatever the thresholds do. | |
| 86 | + | /// Precision each class must reach for the layer to auto-apply it, as a 95% | |
| 87 | + | /// lower bound rather than an observed ratio (see [`calibration`]). A tag | |
| 88 | + | /// written into a library unasked should be right 19 times in 20. | |
| 89 | + | target_precision: f64, | |
| 90 | + | /// Recall at that precision, below which a per-class threshold is not worth | |
| 91 | + | /// shipping: the tag fires too rarely to be a head start. | |
| 92 | + | min_recall: f64, | |
| 93 | + | /// Floor under the confidence bound. The bound already rejects a precision | |
| 94 | + | /// claimed off a handful of predictions (run 1 read cymbal as 100% precise | |
| 95 | + | /// on two); this stops a policy shipping off a thin sample regardless. | |
| 96 | + | min_support: usize, | |
| 97 | + | /// Macro-averaged top-1 recall: the threshold-free separability floor. Below | |
| 98 | + | /// this the feature space is not distinguishing these classes at all and no | |
| 99 | + | /// per-class calibration rescues it. | |
| 71 | 100 | top1_macro_recall: f64, | |
| 72 | 101 | } | |
| 73 | 102 | ||
| 74 | 103 | const GATE: Gate = Gate { | |
| 75 | - | auto_precision: 0.80, | |
| 76 | - | auto_recall: 0.35, | |
| 104 | + | target_precision: 0.95, | |
| 105 | + | min_recall: 0.40, | |
| 106 | + | min_support: 15, | |
| 77 | 107 | top1_macro_recall: 0.60, | |
| 78 | 108 | }; | |
| 79 | 109 | ||
| @@ -89,27 +119,16 @@ | |||
| 89 | 119 | truth: Option<String>, | |
| 90 | 120 | } | |
| 91 | 121 | ||
| 92 | - | /// Per-class counts at one score threshold, over the whole cross-validation. | |
| 93 | - | #[derive(Default, Clone, Copy)] | |
| 94 | - | struct Counts { | |
| 95 | - | tp: usize, | |
| 96 | - | fp: usize, | |
| 97 | - | fn_: usize, | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | impl Counts { | |
| 101 | - | fn precision(self) -> Option<f64> { | |
| 102 | - | let predicted = self.tp + self.fp; | |
| 103 | - | (predicted > 0).then(|| self.tp as f64 / predicted as f64) | |
| 104 | - | } | |
| 105 | - | fn recall(self) -> Option<f64> { | |
| 106 | - | let actual = self.tp + self.fn_; | |
| 107 | - | (actual > 0).then(|| self.tp as f64 / actual as f64) | |
| 108 | - | } | |
| 109 | - | fn f1(self) -> Option<f64> { | |
| 110 | - | let (p, r) = (self.precision()?, self.recall()?); | |
| 111 | - | (p + r > 0.0).then(|| 2.0 * p * r / (p + r)) | |
| 112 | - | } | |
| 122 | + | /// What one test sample produced under one `k`. | |
| 123 | + | struct Prediction { | |
| 124 | + | truth: String, | |
| 125 | + | /// Highest-scoring tag, or `None` when the index returned nothing. | |
| 126 | + | top1: Option<String>, | |
| 127 | + | /// Score per class, for threshold sweeps. | |
| 128 | + | scores: BTreeMap<String, f64>, | |
| 129 | + | /// The fold this sample was held out of, so a threshold is never calibrated | |
| 130 | + | /// on the predictions it is graded against. | |
| 131 | + | fold: usize, | |
| 113 | 132 | } | |
| 114 | 133 | ||
| 115 | 134 | fn pct(v: Option<f64>) -> String { | |
| @@ -228,38 +247,55 @@ | |||
| 228 | 247 | Ok(db) | |
| 229 | 248 | } | |
| 230 | 249 | ||
| 231 | - | /// What one test sample produced. | |
| 232 | - | struct Prediction { | |
| 233 | - | truth: String, | |
| 234 | - | /// Highest-scoring tag, or `None` when the index returned nothing. | |
| 235 | - | top1: Option<String>, | |
| 236 | - | /// Score per class, for threshold sweeps. | |
| 237 | - | scores: BTreeMap<String, f64>, | |
| 250 | + | /// Every test sample's evidence for one class. A class absent from a sample's | |
| 251 | + | /// scores scored zero for it, which is a real observation and not a gap. | |
| 252 | + | fn class_points(predictions: &[Prediction], class: &str) -> Vec<Point> { | |
| 253 | + | predictions | |
| 254 | + | .iter() | |
| 255 | + | .map(|p| Point { | |
| 256 | + | score: p.scores.get(class).copied().unwrap_or(0.0), | |
| 257 | + | actual: p.truth == class, | |
| 258 | + | fold: p.fold, | |
| 259 | + | }) | |
| 260 | + | .collect() | |
| 238 | 261 | } | |
| 239 | 262 | ||
| 240 | - | pub(crate) fn run(corpus: &Path, vault: &Path, config: &AnalysisConfig, folds: usize) { | |
| 263 | + | pub(crate) fn run( | |
| 264 | + | corpus: &Path, | |
| 265 | + | vault: &Path, | |
| 266 | + | config: &AnalysisConfig, | |
| 267 | + | folds: usize, | |
| 268 | + | k_sweep: &[usize], | |
| 269 | + | ) { | |
| 241 | 270 | println!("━━━ CLASSIFIER LAYER EVALUATION ━━━"); | |
| 242 | 271 | println!(); | |
| 243 | 272 | println!(" corpus {}", corpus.display()); | |
| 244 | 273 | println!(" scratch {}", vault.display()); | |
| 245 | 274 | println!(" features v{FEATURE_VERSION}"); | |
| 246 | - | println!(" k {DEFAULT_K} (runtime default)"); | |
| 275 | + | println!(" k {DEFAULT_K} (runtime default), sweeping {k_sweep:?}"); | |
| 247 | 276 | println!(" folds {folds}, stratified by class"); | |
| 248 | 277 | println!(); | |
| 249 | - | println!(" Gate, fixed before this run:"); | |
| 278 | + | println!(" Gate:"); | |
| 250 | 279 | println!( | |
| 251 | - | " per-class precision at the auto threshold ({DEFAULT_AUTO_THRESHOLD:.2}) >= {:.0}%", | |
| 252 | - | GATE.auto_precision * 100.0 | |
| 280 | + | " per-class precision, 95%-confident, at a per-class threshold >= {:.0}%", | |
| 281 | + | GATE.target_precision * 100.0 | |
| 253 | 282 | ); | |
| 254 | 283 | println!( | |
| 255 | - | " per-class recall at the auto threshold >= {:.0}%", | |
| 256 | - | GATE.auto_recall * 100.0 | |
| 284 | + | " per-class recall at that threshold >= {:.0}%", | |
| 285 | + | GATE.min_recall * 100.0 | |
| 257 | 286 | ); | |
| 258 | 287 | println!( | |
| 259 | - | " macro-averaged top-1 recall >= {:.0}%", | |
| 288 | + | " predictions behind that precision >= {}", | |
| 289 | + | GATE.min_support | |
| 290 | + | ); | |
| 291 | + | println!( | |
| 292 | + | " macro-averaged top-1 recall >= {:.0}%", | |
| 260 | 293 | GATE.top1_macro_recall * 100.0 | |
| 261 | 294 | ); | |
| 262 | - | println!(" no class the layer never predicts"); | |
| 295 | + | println!(" every class calibratable, in every fold"); | |
| 296 | + | println!(); | |
| 297 | + | println!(" Thresholds are calibrated on the folds a sample is NOT in, so no"); | |
| 298 | + | println!(" class picks its operating point from the data it is graded on."); | |
| 263 | 299 | println!(); | |
| 264 | 300 | ||
| 265 | 301 | let built = match labelled::build_vault(corpus, vault, config) { | |
| @@ -298,8 +334,10 @@ | |||
| 298 | 334 | let classes: Vec<String> = classes.into_iter().collect(); | |
| 299 | 335 | let fold_of = assign_folds(&rows, folds); | |
| 300 | 336 | ||
| 301 | - | // Cross-validation | |
| 302 | - | let mut predictions: Vec<Prediction> = Vec::with_capacity(testable); | |
| 337 | + | // Cross-validation. The index is built once per fold and scored at every `k`, | |
| 338 | + | // because building it is the expensive half and `k` only enters at scoring. | |
| 339 | + | let mut by_k: BTreeMap<usize, Vec<Prediction>> = | |
| 340 | + | k_sweep.iter().map(|k| (*k, Vec::new())).collect(); | |
| 303 | 341 | for fold in 0..folds { | |
| 304 | 342 | let train: Vec<&Row> = rows | |
| 305 | 343 | .iter() | |
| @@ -335,16 +373,19 @@ | |||
| 335 | 373 | ); | |
| 336 | 374 | ||
| 337 | 375 | for row in test { | |
| 338 | - | // No `exclude_hash`: the row is not in this index at all, which is | |
| 339 | - | // the property the fold split exists to give. | |
| 340 | - | let scored = index.score(&row.vector, DEFAULT_K, None); | |
| 341 | - | let top1 = scored.first().map(|s| s.tag.clone()); | |
| 342 | - | let scores = scored.into_iter().map(|s| (s.tag, s.score)).collect(); | |
| 343 | - | predictions.push(Prediction { | |
| 344 | - | truth: row.truth.clone().unwrap_or_default(), | |
| 345 | - | top1, | |
| 346 | - | scores, | |
| 347 | - | }); | |
| 376 | + | for &k in k_sweep { | |
| 377 | + | // No `exclude_hash`: the row is not in this index at all, which | |
| 378 | + | // is the property the fold split exists to give. | |
| 379 | + | let scored = index.score(&row.vector, k, None); | |
| 380 | + | let top1 = scored.first().map(|s| s.tag.clone()); | |
| 381 | + | let scores = scored.into_iter().map(|s| (s.tag, s.score)).collect(); | |
| 382 | + | by_k.entry(k).or_default().push(Prediction { | |
| 383 | + | truth: row.truth.clone().unwrap_or_default(), | |
| 384 | + | top1, | |
| 385 | + | scores, | |
| 386 | + | fold, | |
| 387 | + | }); | |
| 388 | + | } | |
| 348 | 389 | } | |
| 349 | 390 | } | |
| 350 | 391 | println!(); | |
| @@ -354,28 +395,25 @@ | |||
| 354 | 395 | report.set("k", DEFAULT_K); | |
| 355 | 396 | report.set("feat_version", FEATURE_VERSION); | |
| 356 | 397 | report.set("exemplars_total", rows.len()); | |
| 357 | - | report.set("tested", predictions.len()); | |
| 358 | 398 | report.set("ambiguous_excluded", ambiguous); | |
| 399 | + | report.set("gate_target_precision", GATE.target_precision); | |
| 400 | + | report.set("gate_min_recall", GATE.min_recall); | |
| 401 | + | report.set("gate_min_support", GATE.min_support); | |
| 402 | + | report.set("gate_top1_macro_recall", GATE.top1_macro_recall); | |
| 359 | 403 | ||
| 360 | - | let top1 = print_confusion(&predictions, &classes, &mut report); | |
| 361 | - | let auto = print_threshold_table( | |
| 362 | - | &predictions, | |
| 363 | - | &classes, | |
| 364 | - | DEFAULT_AUTO_THRESHOLD, | |
| 365 | - | "auto-apply", | |
| 366 | - | "auto", | |
| 367 | - | &mut report, | |
| 368 | - | ); | |
| 369 | - | let _review = print_threshold_table( | |
| 370 | - | &predictions, | |
| 371 | - | &classes, | |
| 372 | - | DEFAULT_REVIEW_THRESHOLD, | |
| 373 | - | "review", | |
| 374 | - | "review", | |
| 375 | - | &mut report, | |
| 376 | - | ); | |
| 404 | + | let default_k = by_k | |
| 405 | + | .get(&DEFAULT_K) | |
| 406 | + | .expect("the sweep always contains the runtime k"); | |
| 407 | + | report.set("tested", default_k.len()); | |
| 377 | 408 | ||
| 378 | - | print_verdict(&classes, &top1, &auto, &mut report); | |
| 409 | + | let top1 = print_confusion(default_k, &classes, &mut report); | |
| 410 | + | print_shipped_defaults(default_k, &classes, &mut report); | |
| 411 | + | print_threshold_sweep(default_k, &classes); | |
| 412 | + | let calibrated = print_calibration(default_k, &classes, folds, &mut report); | |
| 413 | + | if k_sweep.len() > 1 { | |
| 414 | + | print_k_sweep(&by_k, &classes, folds, &mut report); | |
| 415 | + | } | |
| 416 | + | print_verdict(&classes, &top1, &calibrated, &mut report); | |
| 379 | 417 | report.write(); | |
| 380 | 418 | } | |
| 381 | 419 | ||
| @@ -385,10 +423,11 @@ | |||
| 385 | 423 | classes: &[String], | |
| 386 | 424 | report: &mut Report, | |
| 387 | 425 | ) -> BTreeMap<String, Counts> { | |
| 388 | - | println!("━━━ TOP-1 CONFUSION ━━━"); | |
| 426 | + | println!("━━━ TOP-1 CONFUSION (k = {DEFAULT_K}) ━━━"); | |
| 389 | 427 | println!(); | |
| 390 | 428 | println!(" Rows are the corpus label, columns the highest-scoring tag."); | |
| 391 | - | println!(" `(none)` is a sample the index scored nothing for at all."); | |
| 429 | + | println!(" Threshold-free: this is what the layer would say if forced to pick,"); | |
| 430 | + | println!(" so it measures separability rather than any policy over it."); | |
| 392 | 431 | println!(); | |
| 393 | 432 | ||
| 394 | 433 | let width = classes | |
| @@ -455,10 +494,6 @@ | |||
| 455 | 494 | let micro = counts.values().map(|c| c.tp).sum::<usize>() as f64 / predictions.len() as f64; | |
| 456 | 495 | println!(" macro-averaged recall {}", pct(macro_recall)); | |
| 457 | 496 | println!(" overall top-1 accuracy {}", pct(Some(micro))); | |
| 458 | - | println!(); | |
| 459 | - | println!(" The macro figure is the one that matters: it weights the 48-file"); | |
| 460 | - | println!(" clap class the same as the 246-file tom class, so a layer that"); | |
| 461 | - | println!(" gets the big classes right and ignores a small one cannot hide."); | |
| 462 | 497 | if never_predicted.is_empty() { | |
| 463 | 498 | println!(" Every class is predicted at least once."); | |
| 464 | 499 | } else { | |
| @@ -471,126 +506,318 @@ | |||
| 471 | 506 | println!(); | |
| 472 | 507 | ||
| 473 | 508 | if let Some(m) = macro_recall { | |
| 474 | - | report.set("top1_macro_recall", (m * 10000.0).round() / 10000.0); | |
| 509 | + | report.set("top1_macro_recall", round4(m)); | |
| 475 | 510 | } | |
| 476 | - | report.set("top1_accuracy", (micro * 10000.0).round() / 10000.0); | |
| 511 | + | report.set("top1_accuracy", round4(micro)); | |
| 477 | 512 | report.set("never_predicted", never_predicted.len()); | |
| 478 | 513 | for (tag, c) in &counts { | |
| 479 | 514 | let label = label_for_tag(tag); | |
| 480 | 515 | if let Some(r) = c.recall() { | |
| 481 | - | report.set( | |
| 482 | - | &format!("top1_{label}_recall"), | |
| 483 | - | (r * 10000.0).round() / 10000.0, | |
| 484 | - | ); | |
| 516 | + | report.set(&format!("top1_{label}_recall"), round4(r)); | |
| 485 | 517 | } | |
| 486 | 518 | if let Some(p) = c.precision() { | |
| 487 | - | report.set( | |
| 488 | - | &format!("top1_{label}_precision"), | |
| 489 | - | (p * 10000.0).round() / 10000.0, | |
| 490 | - | ); | |
| 519 | + | report.set(&format!("top1_{label}_precision"), round4(p)); | |
| 491 | 520 | } | |
| 492 | 521 | } | |
| 493 | 522 | counts | |
| 494 | 523 | } | |
| 495 | 524 | ||
| 496 | - | /// Per-class precision/recall/F1 at one score threshold, scored multi-label: | |
| 497 | - | /// every tag at or above the threshold is a prediction, so a sample can be right | |
| 498 | - | /// about one class and wrong about another in the same breath. That is how the | |
| 499 | - | /// layer behaves at runtime, and a top-1 view would not show a layer that applies | |
| 500 | - | /// three tags to everything. | |
| 501 | - | fn print_threshold_table( | |
| 502 | - | predictions: &[Prediction], | |
| 503 | - | classes: &[String], | |
| 504 | - | threshold: f64, | |
| 505 | - | title: &str, | |
| 506 | - | key: &str, | |
| 507 | - | report: &mut Report, | |
| 508 | - | ) -> BTreeMap<String, Counts> { | |
| 509 | - | println!( | |
| 510 | - | "━━━ AT THE {} THRESHOLD ({threshold:.2}) ━━━", | |
| 511 | - | title.to_uppercase() | |
| 512 | - | ); | |
| 525 | + | /// What the layer does today, unchanged: one global auto threshold for every | |
| 526 | + | /// class. Kept because it is the status quo the ship decision is against. | |
| 527 | + | fn print_shipped_defaults(predictions: &[Prediction], classes: &[String], report: &mut Report) { | |
| 528 | + | println!("━━━ AT THE SHIPPED DEFAULTS (one global threshold) ━━━"); | |
| 513 | 529 | println!(); | |
| 514 | 530 | ||
| 515 | 531 | let mut counts: BTreeMap<String, Counts> = BTreeMap::new(); | |
| 516 | 532 | for class in classes { | |
| 517 | - | let mut c = Counts::default(); | |
| 518 | - | for p in predictions { | |
| 519 | - | let predicted = p.scores.get(class).is_some_and(|s| *s >= threshold); | |
| 520 | - | let actual = &p.truth == class; | |
| 521 | - | match (predicted, actual) { | |
| 522 | - | (true, true) => c.tp += 1, | |
| 523 | - | (true, false) => c.fp += 1, | |
| 524 | - | (false, true) => c.fn_ += 1, | |
| 525 | - | (false, false) => {} | |
| 526 | - | } | |
| 527 | - | } | |
| 528 | - | counts.insert(class.clone(), c); | |
| 533 | + | counts.insert( | |
| 534 | + | class.clone(), | |
| 535 | + | calibration::counts_at(&class_points(predictions, class), DEFAULT_AUTO_THRESHOLD), | |
| 536 | + | ); | |
| 529 | 537 | } | |
| 530 | 538 | ||
| 531 | 539 | println!( | |
| 532 | - | " {:<12} {:>7} {:>8} {:>10} {:>9} {:>8}", | |
| 533 | - | "class", "n", "fired", "precision", "recall", "F1" | |
| 540 | + | " {:<12} {:>7} {:>8} {:>10} {:>9} auto {DEFAULT_AUTO_THRESHOLD:.2} / review {DEFAULT_REVIEW_THRESHOLD:.2}", | |
| 541 | + | "class", "n", "fired", "precision", "recall" | |
| 534 | 542 | ); | |
| 535 | - | println!(" {}", "─".repeat(58)); | |
| 543 | + | println!(" {}", "─".repeat(50)); | |
| 536 | 544 | for class in classes { | |
| 537 | 545 | let c = counts[class]; | |
| 538 | 546 | println!( | |
| 539 | - | " {:<12} {:>7} {:>8} {:>10} {:>9} {:>8}", | |
| 547 | + | " {:<12} {:>7} {:>8} {:>10} {:>9}", | |
| 540 | 548 | label_for_tag(class), | |
| 541 | - | c.tp + c.fn_, | |
| 542 | - | c.tp + c.fp, | |
| 549 | + | c.actual(), | |
| 550 | + | c.fired(), | |
| 543 | 551 | pct(c.precision()), | |
| 544 | 552 | pct(c.recall()), | |
| 545 | - | pct(c.f1()), | |
| 546 | 553 | ); | |
| 547 | 554 | } | |
| 548 | - | println!(" {}", "─".repeat(58)); | |
| 555 | + | println!(" {}", "─".repeat(50)); | |
| 549 | 556 | println!( | |
| 550 | - | " {:<12} {:>7} {:>8} {:>10} {:>9} {:>8}", | |
| 557 | + | " {:<12} {:>7} {:>8} {:>10} {:>9}", | |
| 551 | 558 | "macro", | |
| 552 | 559 | predictions.len(), | |
| 553 | - | counts.values().map(|c| c.tp + c.fp).sum::<usize>(), | |
| 560 | + | counts.values().map(|c| c.fired()).sum::<usize>(), | |
| 554 | 561 | pct(macro_average(classes, &counts, Counts::precision)), | |
| 555 | 562 | pct(macro_average(classes, &counts, Counts::recall)), | |
| 556 | - | pct(macro_average(classes, &counts, Counts::f1)), | |
| 557 | 563 | ); | |
| 558 | 564 | println!(); | |
| 559 | - | ||
| 560 | - | // Tags per sample, which is what a user sees. A layer with fine per-class | |
| 561 | - | // numbers that puts four tags on every one-shot is still not shippable. | |
| 562 | - | let fired: usize = counts.values().map(|c| c.tp + c.fp).sum(); | |
| 565 | + | let silent = predictions | |
| 566 | + | .iter() | |
| 567 | + | .filter(|p| !p.scores.values().any(|s| *s >= DEFAULT_AUTO_THRESHOLD)) | |
| 568 | + | .count(); | |
| 563 | 569 | println!( | |
| 564 | - | " {:.2} tag(s) per sample on average; {} sample(s) got nothing.", | |
| 565 | - | fired as f64 / predictions.len() as f64, | |
| 566 | - | predictions | |
| 567 | - | .iter() | |
| 568 | - | .filter(|p| !p.scores.values().any(|s| *s >= threshold)) | |
| 569 | - | .count() | |
| 570 | + | " {silent} of {} samples ({:.0}%) get no tag at all.", | |
| 571 | + | predictions.len(), | |
| 572 | + | silent as f64 / predictions.len() as f64 * 100.0 | |
| 570 | 573 | ); | |
| 571 | 574 | println!(); | |
| 572 | 575 | ||
| 573 | - | report.set( | |
| 574 | - | &format!("{key}_tags_per_sample"), | |
| 575 | - | (fired as f64 / predictions.len() as f64 * 100.0).round() / 100.0, | |
| 576 | - | ); | |
| 577 | 576 | for (tag, c) in &counts { | |
| 578 | 577 | let label = label_for_tag(tag); | |
| 579 | 578 | if let Some(p) = c.precision() { | |
| 580 | - | report.set( | |
| 581 | - | &format!("{key}_{label}_precision"), | |
| 582 | - | (p * 10000.0).round() / 10000.0, | |
| 583 | - | ); | |
| 579 | + | report.set(&format!("shipped_{label}_precision"), round4(p)); | |
| 584 | 580 | } | |
| 585 | 581 | if let Some(r) = c.recall() { | |
| 586 | - | report.set( | |
| 587 | - | &format!("{key}_{label}_recall"), | |
| 588 | - | (r * 10000.0).round() / 10000.0, | |
| 582 | + | report.set(&format!("shipped_{label}_recall"), round4(r)); | |
| 583 | + | } | |
| 584 | + | } | |
| 585 | + | report.set("shipped_silent_samples", silent); | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | /// Precision and recall for every class across a range of thresholds. | |
| 589 | + | /// | |
| 590 | + | /// This is the evidence that one global threshold cannot serve seven classes: read | |
| 591 | + | /// down a column and the same number means a different thing in every row. | |
| 592 | + | fn print_threshold_sweep(predictions: &[Prediction], classes: &[String]) { |
Lines truncated
| @@ -29,7 +29,8 @@ | |||
| 29 | 29 | //! (FSL10K root for accuracy), `AF_BENCH_BATCH`, `AF_BENCH_LIMIT`, | |
| 30 | 30 | //! `AF_BENCH_ANALYZE`, `AF_BENCH_LAYOUT_N`, `AF_BENCH_JSON` (machine-readable | |
| 31 | 31 | //! output path), `AF_AFCL_OUT` (where `afcl` writes the layer), | |
| 32 | - | //! `AF_BENCH_EVAL_FOLDS` (cross-validation folds for `layer-eval`), | |
| 32 | + | //! `AF_BENCH_EVAL_FOLDS` and `AF_BENCH_EVAL_K` (folds and the neighbour-count | |
| 33 | + | //! sweep for `layer-eval`), | |
| 33 | 34 | //! `AF_BENCH_STAGES` (files per per-stage probe during `ingest`, 0 = off). | |
| 34 | 35 | //! | |
| 35 | 36 | //! Section 1 times the analysis stages per file, against the corpus and no | |
| @@ -45,6 +46,7 @@ | |||
| 45 | 46 | ||
| 46 | 47 | mod accuracy; | |
| 47 | 48 | mod afcl_gen; | |
| 49 | + | mod calibration; | |
| 48 | 50 | mod ingest; | |
| 49 | 51 | mod labelled; | |
| 50 | 52 | mod layer_eval; | |
| @@ -348,6 +350,7 @@ | |||
| 348 | 350 | &vault, | |
| 349 | 351 | &full_pipeline_config(), | |
| 350 | 352 | layer_eval::folds_from_env(), | |
| 353 | + | &layer_eval::k_sweep_from_env(), | |
| 351 | 354 | ); | |
| 352 | 355 | return; | |
| 353 | 356 | } |
| @@ -1,0 +1,443 @@ | |||
| 1 | + | //! Per-class threshold calibration: what operating point does a class support? | |
| 2 | + | //! | |
| 3 | + | //! The first run of `layer-eval` graded all seven classes against one global | |
| 4 | + | //! threshold ([`DEFAULT_AUTO_THRESHOLD`], 0.85) and read the failure as the layer | |
| 5 | + | //! being weak. Most of it was the threshold being wrong for six of the seven | |
| 6 | + | //! classes. | |
| 7 | + | //! | |
| 8 | + | //! A score is the share of the k-nearest neighbourhood's kernel weight carrying a | |
| 9 | + | //! tag, so 0.85 asks for roughly 13 of 15 neighbours to agree. Whether a class | |
| 10 | + | //! can reach that is bounded by how many of its own members fall inside a fixed | |
| 11 | + | //! `k`, which is a property of class size and local density rather than of how | |
| 12 | + | //! distinguishable the sound is. Measured: hi-hat (109 files) and snare (188) sit | |
| 13 | + | //! in the same top-1 band, 71.6% against 76.6%, and their recall at 0.85 differs | |
| 14 | + | //! by a factor of nine. The score is well behaved inside a class and not | |
| 15 | + | //! comparable across classes. | |
| 16 | + | //! | |
| 17 | + | //! So the question a ship gate should ask is not "what does this class do at | |
| 18 | + | //! 0.85". It is "what is the most permissive threshold at which this class still | |
| 19 | + | //! meets the precision we require, and what recall does it buy there". That | |
| 20 | + | //! threshold is exactly a `tag_policy` row, which the layer format can already | |
| 21 | + | //! carry and the export currently declines to ship. | |
| 22 | + | //! | |
| 23 | + | //! [`DEFAULT_AUTO_THRESHOLD`]: audiofiles_core::analysis::exemplar::DEFAULT_AUTO_THRESHOLD | |
| 24 | + | ||
| 25 | + | /// Per-class counts at one score threshold. | |
| 26 | + | #[derive(Default, Clone, Copy)] | |
| 27 | + | pub(crate) struct Counts { | |
| 28 | + | pub(crate) tp: usize, | |
| 29 | + | pub(crate) fp: usize, | |
| 30 | + | pub(crate) fn_: usize, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | impl Counts { | |
| 34 | + | /// Of what fired, how much was right. `None` when nothing fired, which is a | |
| 35 | + | /// different fact from firing and being wrong, and the two must not print | |
| 36 | + | /// the same. | |
| 37 | + | pub(crate) fn precision(self) -> Option<f64> { | |
| 38 | + | let predicted = self.tp + self.fp; | |
| 39 | + | (predicted > 0).then(|| self.tp as f64 / predicted as f64) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// Of what should have fired, how much did. | |
| 43 | + | pub(crate) fn recall(self) -> Option<f64> { | |
| 44 | + | let actual = self.tp + self.fn_; | |
| 45 | + | (actual > 0).then(|| self.tp as f64 / actual as f64) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | pub(crate) fn fired(self) -> usize { | |
| 49 | + | self.tp + self.fp | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | pub(crate) fn actual(self) -> usize { | |
| 53 | + | self.tp + self.fn_ | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | pub(crate) fn add(&mut self, other: Self) { | |
| 57 | + | self.tp += other.tp; | |
| 58 | + | self.fp += other.fp; | |
| 59 | + | self.fn_ += other.fn_; | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// One test sample's evidence for one class: what the layer scored it, whether it | |
| 64 | + | /// really is that class, and which fold produced it. | |
| 65 | + | /// | |
| 66 | + | /// The fold travels with the point because calibrating a threshold on the same | |
| 67 | + | /// predictions it is then scored against is threshold-fitting on the test set. | |
| 68 | + | /// See [`out_of_fold`]. | |
| 69 | + | #[derive(Clone, Copy)] | |
| 70 | + | pub(crate) struct Point { | |
| 71 | + | pub(crate) score: f64, | |
| 72 | + | pub(crate) actual: bool, | |
| 73 | + | pub(crate) fold: usize, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// A threshold and what shipping it would deliver. | |
| 77 | + | #[derive(Clone, Copy)] | |
| 78 | + | pub(crate) struct OperatingPoint { | |
| 79 | + | pub(crate) threshold: f64, | |
| 80 | + | pub(crate) counts: Counts, | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// z for a one-sided 95% lower bound. | |
| 84 | + | const WILSON_Z: f64 = 1.645; | |
| 85 | + | ||
| 86 | + | /// Lower bound of the Wilson score interval for `successes / trials`. | |
| 87 | + | /// | |
| 88 | + | /// Why a bound rather than the ratio: the first calibrated run picked, for each | |
| 89 | + | /// class, the threshold whose *observed* precision just cleared 95%, and held-out | |
| 90 | + | /// precision then came in at 78.6% to 94.9%. Every class undershot. That is not | |
| 91 | + | /// bad luck, it is what selecting the most permissive point that clears a bar | |
| 92 | + | /// does: the point that clears it by the narrowest margin is the one most likely | |
| 93 | + | /// to have cleared it by chance, and picking the extreme of a noisy set is | |
| 94 | + | /// selection bias with a known direction. | |
| 95 | + | /// | |
| 96 | + | /// The bound removes the ad-hoc part of the fix. Instead of "meet 95% and also | |
| 97 | + | /// have at least N predictions", a class must be 95%-confident of being above the | |
| 98 | + | /// bar, which asks for more evidence from a small sample and less from a large | |
| 99 | + | /// one, on the same scale. 19 of 20 correct reads as 76% here; 190 of 200 reads | |
| 100 | + | /// as 92%. | |
| 101 | + | fn wilson_lower_bound(successes: usize, trials: usize) -> f64 { | |
| 102 | + | if trials == 0 { | |
| 103 | + | return 0.0; | |
| 104 | + | } | |
| 105 | + | let n = trials as f64; | |
| 106 | + | let p = successes as f64 / n; | |
| 107 | + | let z2 = WILSON_Z * WILSON_Z; | |
| 108 | + | let denom = 1.0 + z2 / n; | |
| 109 | + | let center = p + z2 / (2.0 * n); | |
| 110 | + | let margin = WILSON_Z * (p * (1.0 - p) / n + z2 / (4.0 * n * n)).sqrt(); | |
| 111 | + | ((center - margin) / denom).max(0.0) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// Counts at a fixed threshold. Mirrors the runtime rule: a tag applies when its | |
| 115 | + | /// score is at or above the threshold. | |
| 116 | + | pub(crate) fn counts_at(points: &[Point], threshold: f64) -> Counts { | |
| 117 | + | let mut c = Counts::default(); | |
| 118 | + | for p in points { | |
| 119 | + | match (p.score >= threshold, p.actual) { | |
| 120 | + | (true, true) => c.tp += 1, | |
| 121 | + | (true, false) => c.fp += 1, | |
| 122 | + | (false, true) => c.fn_ += 1, | |
| 123 | + | (false, false) => {} | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | c | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// The most permissive threshold at which this class is 95%-confident of holding | |
| 130 | + | /// `target_precision`, with at least `min_support` predictions behind it. | |
| 131 | + | /// | |
| 132 | + | /// Most permissive means lowest, because a lower threshold is more recall, and | |
| 133 | + | /// recall is what we are buying once precision is fixed. Walks the score-sorted | |
| 134 | + | /// points once, which visits every threshold that can produce a distinct split. | |
| 135 | + | /// | |
| 136 | + | /// The bar is [`wilson_lower_bound`], not the observed ratio: see there for why | |
| 137 | + | /// the observed ratio systematically overstates what a chosen threshold delivers | |
| 138 | + | /// on data it did not see. | |
| 139 | + | /// | |
| 140 | + | /// Two further guards: | |
| 141 | + | /// | |
| 142 | + | /// - `min_support`. A floor under the bound, so a class cannot ship a policy off | |
| 143 | + | /// a handful of predictions even when the arithmetic allows it. | |
| 144 | + | /// - A threshold of zero is refused. It would fire on every sample including the | |
| 145 | + | /// ones the index scored nothing for, which is not a classifier. | |
| 146 | + | /// | |
| 147 | + | /// Precision is not monotone in the threshold, so a lower qualifying threshold | |
| 148 | + | /// may sit below a stretch that does not qualify. That is fine and deliberate: | |
| 149 | + | /// what ships is a single threshold, and the numbers reported are the ones | |
| 150 | + | /// measured at it. | |
| 151 | + | pub(crate) fn operating_point( | |
| 152 | + | points: &[Point], | |
| 153 | + | target_precision: f64, | |
| 154 | + | min_support: usize, | |
| 155 | + | ) -> Option<OperatingPoint> { | |
| 156 | + | let positives = points.iter().filter(|p| p.actual).count(); | |
| 157 | + | if positives == 0 { | |
| 158 | + | return None; | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | let mut sorted: Vec<&Point> = points.iter().collect(); | |
| 162 | + | sorted.sort_by(|a, b| b.score.total_cmp(&a.score)); | |
| 163 | + | ||
| 164 | + | let mut tp = 0usize; | |
| 165 | + | let mut fp = 0usize; | |
| 166 | + | let mut best: Option<OperatingPoint> = None; | |
| 167 | + | ||
| 168 | + | let mut i = 0; | |
| 169 | + | while i < sorted.len() { | |
| 170 | + | // Every point sharing this score has to be taken with it: no threshold | |
| 171 | + | // can separate two samples that scored the same. | |
| 172 | + | let score = sorted[i].score; | |
| 173 | + | #[allow( | |
| 174 | + | clippy::float_cmp, | |
| 175 | + | reason = "exact equality is the point: a threshold cannot split two \ | |
| 176 | + | samples that scored bit-identically, so the group boundary \ | |
| 177 | + | has to be exact rather than within a tolerance" | |
| 178 | + | )] | |
| 179 | + | while i < sorted.len() && sorted[i].score == score { | |
| 180 | + | if sorted[i].actual { | |
| 181 | + | tp += 1; | |
| 182 | + | } else { | |
| 183 | + | fp += 1; | |
| 184 | + | } | |
| 185 | + | i += 1; | |
| 186 | + | } | |
| 187 | + | if score <= 0.0 { | |
| 188 | + | break; | |
| 189 | + | } | |
| 190 | + | let fired = tp + fp; | |
| 191 | + | if fired < min_support { | |
| 192 | + | continue; | |
| 193 | + | } | |
| 194 | + | if wilson_lower_bound(tp, fired) >= target_precision { | |
| 195 | + | // Descending walk, so each qualifying point is more permissive than | |
| 196 | + | // the last. Keep overwriting and the survivor is the lowest. | |
| 197 | + | best = Some(OperatingPoint { | |
| 198 | + | threshold: score, | |
| 199 | + | counts: Counts { | |
| 200 | + | tp, | |
| 201 | + | fp, | |
| 202 | + | fn_: positives - tp, | |
| 203 | + | }, | |
| 204 | + | }); | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | best | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// Calibrate and evaluate without letting a class pick its threshold from the | |
| 211 | + | /// samples it is then graded on. | |
| 212 | + | /// | |
| 213 | + | /// For each fold: choose the threshold from every *other* fold's points, then | |
| 214 | + | /// count this fold's points at it. Summing across folds gives per-class numbers | |
| 215 | + | /// that no threshold was fitted to. A fold whose calibration finds no qualifying | |
| 216 | + | /// threshold contributes its positives as misses, because a policy that cannot be | |
| 217 | + | /// derived is a policy that does not ship and a tag that never fires. | |
| 218 | + | /// | |
| 219 | + | /// Returns the summed counts and the thresholds chosen, one per fold that found | |
| 220 | + | /// one. The spread of those thresholds is worth reading: a class whose threshold | |
| 221 | + | /// swings between folds is one whose operating point is not a stable property of | |
| 222 | + | /// the class. | |
| 223 | + | pub(crate) fn out_of_fold( | |
| 224 | + | points: &[Point], | |
| 225 | + | folds: usize, | |
| 226 | + | target_precision: f64, | |
| 227 | + | min_support: usize, | |
| 228 | + | ) -> (Counts, Vec<f64>) { | |
| 229 | + | let mut total = Counts::default(); | |
| 230 | + | let mut thresholds = Vec::new(); | |
| 231 | + | ||
| 232 | + | for fold in 0..folds { | |
| 233 | + | let calibration: Vec<Point> = points.iter().filter(|p| p.fold != fold).copied().collect(); | |
| 234 | + | let held_out: Vec<Point> = points.iter().filter(|p| p.fold == fold).copied().collect(); | |
| 235 | + | if held_out.is_empty() { | |
| 236 | + | continue; | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // The calibration set is smaller than the whole, so scale the support | |
| 240 | + | // floor with it. Otherwise a class that clears `min_support` overall | |
| 241 | + | // fails to calibrate on 4/5 of the data for arithmetic reasons. | |
| 242 | + | let scaled_support = (min_support * (folds - 1)).div_ceil(folds).max(1); | |
| 243 | + | match operating_point(&calibration, target_precision, scaled_support) { | |
| 244 | + | Some(op) => { | |
| 245 | + | total.add(counts_at(&held_out, op.threshold)); | |
| 246 | + | thresholds.push(op.threshold); | |
| 247 | + | } | |
| 248 | + | None => { | |
| 249 | + | // No threshold: nothing fires, so every positive here is a miss. | |
| 250 | + | total.fn_ += held_out.iter().filter(|p| p.actual).count(); | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | (total, thresholds) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// Mean, and the spread, of the per-fold thresholds. | |
| 258 | + | pub(crate) fn threshold_spread(thresholds: &[f64]) -> Option<(f64, f64, f64)> { | |
| 259 | + | if thresholds.is_empty() { | |
| 260 | + | return None; | |
| 261 | + | } | |
| 262 | + | let mean = thresholds.iter().sum::<f64>() / thresholds.len() as f64; | |
| 263 | + | let min = thresholds.iter().copied().fold(f64::INFINITY, f64::min); | |
| 264 | + | let max = thresholds.iter().copied().fold(f64::NEG_INFINITY, f64::max); | |
| 265 | + | Some((mean, min, max)) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | #[cfg(test)] | |
| 269 | + | mod tests { | |
| 270 | + | use super::*; | |
| 271 | + | ||
| 272 | + | fn pts(spec: &[(f64, bool)]) -> Vec<Point> { | |
| 273 | + | spec.iter() | |
| 274 | + | .enumerate() | |
| 275 | + | .map(|(i, &(score, actual))| Point { | |
| 276 | + | score, | |
| 277 | + | actual, | |
| 278 | + | fold: i % 5, | |
| 279 | + | }) | |
| 280 | + | .collect() | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | #[test] | |
| 284 | + | fn counts_at_matches_the_runtime_at_or_above_rule() { | |
| 285 | + | let p = pts(&[(0.9, true), (0.5, true), (0.5, false), (0.1, true)]); | |
| 286 | + | let c = counts_at(&p, 0.5); | |
| 287 | + | assert_eq!((c.tp, c.fp, c.fn_), (2, 1, 1), "0.5 fires at exactly 0.5"); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | #[test] | |
| 291 | + | fn operating_point_takes_the_most_permissive_qualifying_threshold() { | |
| 292 | + | // Perfectly ordered: every positive above every negative. Asserted as a | |
| 293 | + | // property rather than a magic threshold, because what "most permissive" | |
| 294 | + | // resolves to depends on the bound, the target and the sample size, and | |
| 295 | + | // a hardcoded number would be testing this fixture's arithmetic. | |
| 296 | + | let spec: Vec<(f64, bool)> = (0..100) | |
| 297 | + | .map(|i| (0.9 - f64::from(i) * 0.001, true)) | |
| 298 | + | .chain((0..100).map(|i| (0.2 - f64::from(i) * 0.001, false))) | |
| 299 | + | .collect(); | |
| 300 | + | let p = pts(&spec); | |
| 301 | + | let op = operating_point(&p, 0.95, 1).unwrap(); | |
| 302 | + | ||
| 303 | + | // What it reports is what that threshold actually does. | |
| 304 | + | let at = counts_at(&p, op.threshold); | |
| 305 | + | assert_eq!((at.tp, at.fp), (op.counts.tp, op.counts.fp)); | |
| 306 | + | assert!(wilson_lower_bound(at.tp, at.fired()) >= 0.95); | |
| 307 | + | ||
| 308 | + | // And nothing lower qualifies, which is what makes it the most permissive. | |
| 309 | + | for lower in p | |
| 310 | + | .iter() | |
| 311 | + | .map(|x| x.score) | |
| 312 | + | .filter(|s| *s < op.threshold && *s > 0.0) | |
| 313 | + | { | |
| 314 | + | let c = counts_at(&p, lower); | |
| 315 | + | assert!( | |
| 316 | + | wilson_lower_bound(c.tp, c.fired()) < 0.95, | |
| 317 | + | "threshold {lower} also qualifies, so {} was not the lowest", | |
| 318 | + | op.threshold | |
| 319 | + | ); | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | #[test] | |
| 324 | + | fn operating_point_refuses_a_threshold_backed_by_too_few_predictions() { | |
| 325 | + | // Two perfect predictions then a mess. The bound alone rejects this: | |
| 326 | + | // 2 of 2 is not evidence of 95%, and the first run read cymbal that way. | |
| 327 | + | let p = pts(&[ | |
| 328 | + | (0.99, true), | |
| 329 | + | (0.98, true), | |
| 330 | + | (0.5, false), | |
| 331 | + | (0.5, false), | |
| 332 | + | (0.5, false), | |
| 333 | + | (0.4, true), | |
| 334 | + | ]); | |
| 335 | + | assert!( | |
| 336 | + | operating_point(&p, 0.95, 1).is_none(), | |
| 337 | + | "two perfect predictions are not 95% confidence of 95% precision" | |
| 338 | + | ); | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | #[test] | |
| 342 | + | fn the_bound_demands_more_evidence_from_a_smaller_sample() { | |
| 343 | + | // The property the whole calibration rests on: the same observed ratio | |
| 344 | + | // qualifies at scale and does not qualify on a handful. | |
| 345 | + | assert!(wilson_lower_bound(19, 20) < 0.85); | |
| 346 | + | assert!(wilson_lower_bound(190, 200) > 0.9); | |
| 347 | + | // Monotone in sample size at a fixed ratio. | |
| 348 | + | assert!(wilson_lower_bound(9, 10) < wilson_lower_bound(90, 100)); | |
| 349 | + | // Degenerate inputs stay in range rather than producing a NaN that | |
| 350 | + | // would silently compare false against every target. | |
| 351 | + | assert!(wilson_lower_bound(0, 0).abs() < f64::EPSILON); | |
| 352 | + | assert!(wilson_lower_bound(0, 5) >= 0.0); | |
| 353 | + | assert!(wilson_lower_bound(5, 5) <= 1.0); | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | #[test] | |
| 357 | + | fn operating_point_never_returns_a_zero_threshold() { | |
| 358 | + | // Every sample scores zero: the index had nothing to say. A zero | |
| 359 | + | // threshold would "apply" the tag to all of them. | |
| 360 | + | let spec: Vec<(f64, bool)> = (0..40).map(|i| (0.0, i % 2 == 0)).collect(); | |
| 361 | + | assert!(operating_point(&pts(&spec), 0.4, 1).is_none()); | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | #[test] | |
| 365 | + | fn operating_point_is_none_when_precision_is_unreachable() { | |
| 366 | + | let p = pts(&[(0.9, false), (0.8, false), (0.7, true)]); | |
| 367 | + | assert!(operating_point(&p, 0.95, 1).is_none()); | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | #[test] | |
| 371 | + | fn tied_scores_are_taken_together() { | |
| 372 | + | // A threshold cannot split two equal scores, so the walk must not report | |
| 373 | + | // a precision that only holds if it does. A clean run of positives, then | |
| 374 | + | // a tied group of one positive and five negatives: taking the group | |
| 375 | + | // whole fails the bar, and splitting it would pass. | |
| 376 | + | let mut spec: Vec<(f64, bool)> = (0..100) | |
| 377 | + | .map(|i| (0.9 - f64::from(i) * 0.001, true)) | |
| 378 | + | .collect(); | |
| 379 | + | spec.push((0.5, true)); | |
| 380 | + | spec.extend((0..5).map(|_| (0.5, false))); | |
| 381 | + | ||
| 382 | + | let op = operating_point(&pts(&spec), 0.95, 1).unwrap(); | |
| 383 | + | assert!( | |
| 384 | + | op.threshold > 0.5, | |
| 385 | + | "the tie was split; got {}", | |
| 386 | + | op.threshold | |
| 387 | + | ); | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | #[test] | |
| 391 | + | fn out_of_fold_counts_an_uncalibratable_fold_as_misses() { | |
| 392 | + | // Nothing separates these, so no fold finds a threshold and every | |
| 393 | + | // positive must be a miss rather than silently vanishing. | |
| 394 | + | let p = pts(&[ | |
| 395 | + | (0.5, true), | |
| 396 | + | (0.5, false), | |
| 397 | + | (0.5, true), | |
| 398 | + | (0.5, false), | |
| 399 | + | (0.5, true), | |
| 400 | + | (0.5, false), | |
| 401 | + | (0.5, true), | |
| 402 | + | (0.5, false), | |
| 403 | + | (0.5, true), | |
| 404 | + | (0.5, false), | |
| 405 | + | ]); | |
| 406 | + | let (counts, thresholds) = out_of_fold(&p, 5, 0.95, 1); | |
| 407 | + | assert!(thresholds.is_empty()); | |
| 408 | + | assert_eq!(counts.tp, 0); | |
| 409 | + | assert_eq!(counts.actual(), 5, "all five positives are accounted for"); | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | #[test] | |
| 413 | + | fn out_of_fold_is_not_more_optimistic_than_the_data_supports() { | |
| 414 | + | // Separable data: out-of-fold should recover it, since the threshold | |
| 415 | + | // learned on four folds transfers to the fifth. | |
| 416 | + | let mut spec: Vec<(f64, bool)> = (0..100) | |
| 417 | + | .map(|i| (0.9 - f64::from(i) * 0.001, true)) | |
| 418 | + | .collect(); | |
| 419 | + | spec.extend((0..100).map(|i| (0.3 - f64::from(i) * 0.001, false))); | |
| 420 | + | let p = pts(&spec); | |
| 421 | + | let (counts, thresholds) = out_of_fold(&p, 5, 0.95, 4); | |
| 422 | + | assert_eq!(thresholds.len(), 5, "every fold calibrates"); | |
| 423 | + | // Precision holds on folds the threshold never saw, which is the whole | |
| 424 | + | // contract. Not 100%: the target is 0.95, so the most permissive | |
| 425 | + | // qualifying threshold deliberately admits a few negatives, and demanding | |
| 426 | + | // perfection here would be asserting a stricter bar than was asked for. | |
| 427 | + | assert!( | |
| 428 | + | counts.precision().unwrap() >= 0.95, | |
| 429 | + | "{:?}", | |
| 430 | + | counts.precision() | |
| 431 | + | ); | |
| 432 | + | assert!(counts.recall().unwrap() > 0.9, "{:?}", counts.recall()); | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | #[test] | |
| 436 | + | fn threshold_spread_reports_min_and_max() { | |
| 437 | + | let (mean, min, max) = threshold_spread(&[0.4, 0.6, 0.5]).unwrap(); | |
| 438 | + | assert!((mean - 0.5).abs() < 1e-9); | |
| 439 | + | assert!((min - 0.4).abs() < 1e-9); | |
| 440 | + | assert!((max - 0.6).abs() < 1e-9); | |
| 441 | + | assert!(threshold_spread(&[]).is_none()); | |
| 442 | + | } | |
| 443 | + | } |