| 39 |
39 |
|
//! The output still reports the shipped defaults, because "what happens if this
|
| 40 |
40 |
|
//! ships unchanged" remains a real question with a bad answer.
|
| 41 |
41 |
|
//!
|
|
42 |
+ |
//! # And then the whole question changed, which needs saying louder
|
|
43 |
+ |
//!
|
|
44 |
+ |
//! Runs 1 and 2 (2026-08-06, `02395cb` and `50fe541`) graded seven **specific drum
|
|
45 |
+ |
//! instruments**. Both halves of that were settled against on 2026-07-29 and this
|
|
46 |
+ |
//! module did it anyway: audiofiles classifies at coarse family resolution because
|
|
47 |
+ |
//! instrument identity is not in these features (33.4% with ~40 tuned thresholds
|
|
48 |
+ |
//! against 92.4% for families on one unfitted cut, and instrument labels are not
|
|
49 |
+ |
//! perceptually coherent), and drums-only is the defect the whole scope effort
|
|
50 |
+ |
//! exists to fix rather than the scope to measure within. The machinery below was
|
|
51 |
+ |
//! never the problem: fold split, index construction, calibration, the Wilson
|
|
52 |
+ |
//! bound and the confusion matrix are all label-agnostic. The label mapping was.
|
|
53 |
+ |
//!
|
|
54 |
+ |
//! So the corpus is now projected onto families as it is read back
|
|
55 |
+ |
//! ([`crate::families`]), and everything is graded at that resolution. Two things
|
|
56 |
+ |
//! follow that a reader should not have to infer:
|
|
57 |
+ |
//!
|
|
58 |
+ |
//! - The drum corpus reaches **two of seven** families, `low` and `drum-bright`.
|
|
59 |
+ |
//! Nothing here is a verdict on the layer. Five families are unmeasured.
|
|
60 |
+ |
//! - Runs 1 and 2 answer a retired question. They are not superseded results, they
|
|
61 |
+ |
//! are results for something else, and `docs/ml_classifier.md` says so.
|
|
62 |
+ |
//!
|
| 42 |
63 |
|
//! Usage: `cargo run --release -p audiofiles-bench -- layer-eval`
|
| 43 |
64 |
|
//! Env: `AF_BENCH_CORPUS`, `AF_BENCH_VAULT`, `AF_BENCH_EVAL_FOLDS` (default 5),
|
| 44 |
65 |
|
//! `AF_BENCH_EVAL_K` (comma-separated sweep, default `5,10,15,25,50`),
|
|
66 |
+ |
//! `AF_BENCH_EVAL_LABELS` (`family` default, `family-tom-split`, `instrument`),
|
| 45 |
67 |
|
//! `AF_BENCH_JSON`.
|
| 46 |
68 |
|
//!
|
| 47 |
69 |
|
//! [`DEFAULT_AUTO_THRESHOLD`]: audiofiles_core::analysis::exemplar::DEFAULT_AUTO_THRESHOLD
|
| 57 |
79 |
|
use audiofiles_core::db::Database;
|
| 58 |
80 |
|
|
| 59 |
81 |
|
use crate::calibration::{self, Counts, Point};
|
| 60 |
|
- |
use crate::labelled::{self, label_for_tag};
|
|
82 |
+ |
use crate::families::{self, LabelSpace};
|
|
83 |
+ |
use crate::labelled;
|
| 61 |
84 |
|
use crate::report::Report;
|
| 62 |
85 |
|
|
| 63 |
86 |
|
/// Default fold count.
|
| 111 |
134 |
|
struct Row {
|
| 112 |
135 |
|
hash: String,
|
| 113 |
136 |
|
vector: Vec<f64>,
|
|
137 |
+ |
/// Labels in the evaluation's space, so the index under test carries the tags
|
|
138 |
+ |
/// being graded.
|
| 114 |
139 |
|
tags: Vec<String>,
|
| 115 |
140 |
|
/// The single class this row is ground truth for, or `None` when the corpus
|
| 116 |
141 |
|
/// gave it more than one. Content-addressed import collapses a file that
|
| 117 |
142 |
|
/// appears in two class folders into one row carrying both tags; its true
|
| 118 |
143 |
|
/// class is undecidable, so it trains but is never tested.
|
| 119 |
144 |
|
truth: Option<String>,
|
|
145 |
+ |
/// The corpus folder(s) this row came from, before projection. Kept so a
|
|
146 |
+ |
/// family's members can be broken out by where they came from: `low` is kick
|
|
147 |
+ |
/// plus tom, and whether the layer recovers a tom as readily as a kick is the
|
|
148 |
+ |
/// open split `af-coarse-families` asks about.
|
|
149 |
+ |
origin: String,
|
| 120 |
150 |
|
}
|
| 121 |
151 |
|
|
| 122 |
152 |
|
/// What one test sample produced under one `k`.
|
| 129 |
159 |
|
/// The fold this sample was held out of, so a threshold is never calibrated
|
| 130 |
160 |
|
/// on the predictions it is graded against.
|
| 131 |
161 |
|
fold: usize,
|
|
162 |
+ |
/// Corpus folder(s) behind the truth label. See [`Row::origin`].
|
|
163 |
+ |
origin: String,
|
| 132 |
164 |
|
}
|
| 133 |
165 |
|
|
| 134 |
166 |
|
fn pct(v: Option<f64>) -> String {
|
| 135 |
167 |
|
v.map_or_else(|| "-".to_string(), |x| format!("{:.1}%", x * 100.0))
|
| 136 |
168 |
|
}
|
| 137 |
169 |
|
|
| 138 |
|
- |
/// Read the analysed corpus back out of the scratch vault as scoreable rows.
|
| 139 |
|
- |
fn load_rows(db: &Database) -> Result<Vec<Row>, String> {
|
|
170 |
+ |
/// What `load_rows` set aside, so no exclusion is silent.
|
|
171 |
+ |
struct Dropped {
|
|
172 |
+ |
/// Rows whose every corpus tag projects nowhere in this label space.
|
|
173 |
+ |
unprojectable: usize,
|
|
174 |
+ |
/// Corpus labels those rows came from.
|
|
175 |
+ |
origins: BTreeSet<String>,
|
|
176 |
+ |
}
|
|
177 |
+ |
|
|
178 |
+ |
/// Read the analysed corpus back out of the scratch vault as scoreable rows,
|
|
179 |
+ |
/// projected onto `space`.
|
|
180 |
+ |
///
|
|
181 |
+ |
/// The projection happens here rather than in [`labelled`] on purpose. The vault
|
|
182 |
+ |
/// is ground truth at the finest resolution the corpus carries, which is what
|
|
183 |
+ |
/// `afcl_gen` exports from; grading at a coarser resolution is a property of the
|
|
184 |
+ |
/// evaluation, not of the corpus, so it belongs on the read side. That also keeps
|
|
185 |
+ |
/// the exported layer byte-identical and leaves the retired instrument question
|
|
186 |
+ |
/// runnable instead of deleted.
|
|
187 |
+ |
fn load_rows(db: &Database, space: LabelSpace) -> Result<(Vec<Row>, Dropped), String> {
|
| 140 |
188 |
|
let conn = db.conn();
|
| 141 |
189 |
|
|
| 142 |
190 |
|
let mut tags_by_hash: HashMap<String, Vec<String>> = HashMap::new();
|
| 156 |
204 |
|
}
|
| 157 |
205 |
|
|
| 158 |
206 |
|
let mut out = Vec::new();
|
|
207 |
+ |
let mut dropped = Dropped {
|
|
208 |
+ |
unprojectable: 0,
|
|
209 |
+ |
origins: BTreeSet::new(),
|
|
210 |
+ |
};
|
| 159 |
211 |
|
let mut stmt = conn
|
| 160 |
212 |
|
.prepare("SELECT hash, vector FROM sample_features WHERE feat_version = ?1 ORDER BY hash")
|
| 161 |
213 |
|
.map_err(|e| format!("features query: {e}"))?;
|
| 166 |
218 |
|
.map_err(|e| format!("features query: {e}"))?;
|
| 167 |
219 |
|
for r in rows {
|
| 168 |
220 |
|
let (hash, json) = r.map_err(|e| format!("features row: {e}"))?;
|
| 169 |
|
- |
let Some(mut tags) = tags_by_hash.remove(&hash) else {
|
|
221 |
+ |
let Some(mut corpus_tags) = tags_by_hash.remove(&hash) else {
|
| 170 |
222 |
|
continue;
|
| 171 |
223 |
|
};
|
| 172 |
|
- |
tags.sort();
|
| 173 |
|
- |
tags.dedup();
|
|
224 |
+ |
corpus_tags.sort();
|
|
225 |
+ |
corpus_tags.dedup();
|
| 174 |
226 |
|
let vector: Vec<f64> =
|
| 175 |
227 |
|
serde_json::from_str(&json).map_err(|e| format!("vector for {hash}: {e}"))?;
|
| 176 |
228 |
|
// The index drops these too (`is_usable_vector`), so counting them as
|
| 178 |
230 |
|
if vector.len() != NUM_FEATURES || !vector.iter().all(|x| x.is_finite()) {
|
| 179 |
231 |
|
continue;
|
| 180 |
232 |
|
}
|
|
233 |
+ |
|
|
234 |
+ |
// Project, then dedup again: two instrument tags landing in one family is
|
|
235 |
+ |
// not ambiguity, it is the coarser question being easier. A file in both
|
|
236 |
+ |
// the kick and tom folders has no instrument truth and a perfectly good
|
|
237 |
+ |
// family one.
|
|
238 |
+ |
let mut tags: Vec<String> = corpus_tags
|
|
239 |
+ |
.iter()
|
|
240 |
+ |
.filter_map(|t| families::project(space, t))
|
|
241 |
+ |
.map(str::to_string)
|
|
242 |
+ |
.collect();
|
|
243 |
+ |
tags.sort();
|
|
244 |
+ |
tags.dedup();
|
|
245 |
+ |
if tags.is_empty() {
|
|
246 |
+ |
dropped.unprojectable += 1;
|
|
247 |
+ |
dropped.origins.extend(
|
|
248 |
+ |
corpus_tags
|
|
249 |
+ |
.iter()
|
|
250 |
+ |
.map(|t| labelled::label_for_tag(t).to_string()),
|
|
251 |
+ |
);
|
|
252 |
+ |
continue;
|
|
253 |
+ |
}
|
|
254 |
+ |
|
| 181 |
255 |
|
let truth = (tags.len() == 1).then(|| tags[0].clone());
|
|
256 |
+ |
let origin = corpus_tags
|
|
257 |
+ |
.iter()
|
|
258 |
+ |
.map(|t| labelled::label_for_tag(t))
|
|
259 |
+ |
.collect::<Vec<_>>()
|
|
260 |
+ |
.join("+");
|
| 182 |
261 |
|
out.push(Row {
|
| 183 |
262 |
|
hash,
|
| 184 |
263 |
|
vector,
|
| 185 |
264 |
|
tags,
|
| 186 |
265 |
|
truth,
|
|
266 |
+ |
origin,
|
| 187 |
267 |
|
});
|
| 188 |
268 |
|
}
|
| 189 |
|
- |
Ok(out)
|
|
269 |
+ |
Ok((out, dropped))
|
| 190 |
270 |
|
}
|
| 191 |
271 |
|
|
| 192 |
272 |
|
/// Assign each row a fold, stratified by class.
|
| 266 |
346 |
|
config: &AnalysisConfig,
|
| 267 |
347 |
|
folds: usize,
|
| 268 |
348 |
|
k_sweep: &[usize],
|
|
349 |
+ |
space: LabelSpace,
|
| 269 |
350 |
|
) {
|
| 270 |
351 |
|
println!("━━━ CLASSIFIER LAYER EVALUATION ━━━");
|
| 271 |
352 |
|
println!();
|
| 274 |
355 |
|
println!(" features v{FEATURE_VERSION}");
|
| 275 |
356 |
|
println!(" k {DEFAULT_K} (runtime default), sweeping {k_sweep:?}");
|
| 276 |
357 |
|
println!(" folds {folds}, stratified by class");
|
|
358 |
+ |
println!(" labels {}", space.describe());
|
| 277 |
359 |
|
println!();
|
| 278 |
360 |
|
println!(" Gate:");
|
| 279 |
361 |
|
println!(
|
| 306 |
388 |
|
}
|
| 307 |
389 |
|
};
|
| 308 |
390 |
|
|
| 309 |
|
- |
let rows = match load_rows(&built.db) {
|
|
391 |
+ |
let (rows, dropped) = match load_rows(&built.db, space) {
|
| 310 |
392 |
|
Ok(r) => r,
|
| 311 |
393 |
|
Err(e) => {
|
| 312 |
394 |
|
eprintln!("reading the vault back: {e}");
|
| 313 |
395 |
|
std::process::exit(1);
|
| 314 |
396 |
|
}
|
| 315 |
397 |
|
};
|
|
398 |
+ |
if dropped.unprojectable > 0 {
|
|
399 |
+ |
println!();
|
|
400 |
+ |
println!(
|
|
401 |
+ |
" {} row(s) carry no label in this space ({}) and are excluded from",
|
|
402 |
+ |
dropped.unprojectable,
|
|
403 |
+ |
dropped
|
|
404 |
+ |
.origins
|
|
405 |
+ |
.iter()
|
|
406 |
+ |
.cloned()
|
|
407 |
+ |
.collect::<Vec<_>>()
|
|
408 |
+ |
.join(", ")
|
|
409 |
+ |
);
|
|
410 |
+ |
println!(" training and testing both:");
|
|
411 |
+ |
println!("{}", families::DROPPED_NOTE);
|
|
412 |
+ |
}
|
| 316 |
413 |
|
let ambiguous = rows.iter().filter(|r| r.truth.is_none()).count();
|
| 317 |
414 |
|
let testable = rows.len() - ambiguous;
|
| 318 |
415 |
|
if testable == 0 {
|
| 384 |
481 |
|
top1,
|
| 385 |
482 |
|
scores,
|
| 386 |
483 |
|
fold,
|
|
484 |
+ |
origin: row.origin.clone(),
|
| 387 |
485 |
|
});
|
| 388 |
486 |
|
}
|
| 389 |
487 |
|
}
|
| 391 |
489 |
|
println!();
|
| 392 |
490 |
|
|
| 393 |
491 |
|
let mut report = Report::new("layer-eval");
|
|
492 |
+ |
report.set("label_space", format!("{space:?}"));
|
|
493 |
+ |
report.set("dropped_unprojectable", dropped.unprojectable);
|
| 394 |
494 |
|
report.set("folds", folds);
|
| 395 |
495 |
|
report.set("k", DEFAULT_K);
|
| 396 |
496 |
|
report.set("feat_version", FEATURE_VERSION);
|
| 406 |
506 |
|
.expect("the sweep always contains the runtime k");
|
| 407 |
507 |
|
report.set("tested", default_k.len());
|
| 408 |
508 |
|
|
| 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);
|
|
509 |
+ |
let top1 = print_confusion(default_k, &classes, space, &mut report);
|
|
510 |
+ |
print_origin_breakdown(default_k, &classes, space, &mut report);
|
|
511 |
+ |
print_shipped_defaults(default_k, &classes, space, &mut report);
|
|
512 |
+ |
print_threshold_sweep(default_k, &classes, space);
|
|
513 |
+ |
let calibrated = print_calibration(default_k, &classes, folds, space, &mut report);
|
| 413 |
514 |
|
if k_sweep.len() > 1 {
|
| 414 |
515 |
|
print_k_sweep(&by_k, &classes, folds, &mut report);
|
| 415 |
516 |
|
}
|
| 416 |
|
- |
print_verdict(&classes, &top1, &calibrated, &mut report);
|
|
517 |
+ |
print_verdict(&classes, &top1, &calibrated, space, &mut report);
|
| 417 |
518 |
|
report.write();
|
| 418 |
519 |
|
}
|
| 419 |
520 |
|
|
| 421 |
522 |
|
fn print_confusion(
|
| 422 |
523 |
|
predictions: &[Prediction],
|
| 423 |
524 |
|
classes: &[String],
|
|
525 |
+ |
space: LabelSpace,
|
| 424 |
526 |
|
report: &mut Report,
|
| 425 |
527 |
|
) -> BTreeMap<String, Counts> {
|
| 426 |
528 |
|
println!("━━━ TOP-1 CONFUSION (k = {DEFAULT_K}) ━━━");
|
| 432 |
534 |
|
|
| 433 |
535 |
|
let width = classes
|
| 434 |
536 |
|
.iter()
|
| 435 |
|
- |
.map(|c| label_for_tag(c).len().max(5))
|
|
537 |
+ |
.map(|c| families::label_for(space, c).len().max(5))
|
| 436 |
538 |
|
.collect::<Vec<_>>();
|
| 437 |
539 |
|
|
| 438 |
540 |
|
print!(" {:<12}", "true \\ pred");
|
| 439 |
541 |
|
for (c, w) in classes.iter().zip(&width) {
|
| 440 |
|
- |
print!(" {:>w$}", label_for_tag(c), w = w);
|
|
542 |
+ |
print!(" {:>w$}", families::label_for(space, c), w = w);
|
| 441 |
543 |
|
}
|
| 442 |
544 |
|
println!(" {:>6} {:>8}", "(none)", "recall");
|
| 443 |
545 |
|
println!(
|
| 450 |
552 |
|
|
| 451 |
553 |
|
for truth in classes {
|
| 452 |
554 |
|
let mine: Vec<&Prediction> = predictions.iter().filter(|p| &p.truth == truth).collect();
|
| 453 |
|
- |
print!(" {:<12}", label_for_tag(truth));
|
|
555 |
+ |
print!(" {:<12}", families::label_for(space, truth));
|
| 454 |
556 |
|
let mut correct = 0usize;
|
| 455 |
557 |
|
for (pred, w) in classes.iter().zip(&width) {
|
| 456 |
558 |
|
let n = mine
|
| 477 |
579 |
|
.filter(|p| p.top1.as_deref() == Some(truth.as_str()))
|
| 478 |
580 |
|
.count();
|
| 479 |
581 |
|
if predicted_as == 0 {
|
| 480 |
|
- |
never_predicted.push(label_for_tag(truth));
|
|
582 |
+ |
never_predicted.push(families::label_for(space, truth));
|
| 481 |
583 |
|
}
|
| 482 |
584 |
|
counts.insert(
|
| 483 |
585 |
|
truth.clone(),
|
| 511 |
613 |
|
report.set("top1_accuracy", round4(micro));
|
| 512 |
614 |
|
report.set("never_predicted", never_predicted.len());
|
| 513 |
615 |
|
for (tag, c) in &counts {
|
| 514 |
|
- |
let label = label_for_tag(tag);
|
|
616 |
+ |
let label = families::label_for(space, tag);
|
| 515 |
617 |
|
if let Some(r) = c.recall() {
|
| 516 |
618 |
|
report.set(&format!("top1_{label}_recall"), round4(r));
|
| 517 |
619 |
|
}
|
| 522 |
624 |
|
counts
|
| 523 |
625 |
|
}
|
| 524 |
626 |
|
|
|
627 |
+ |
/// Per-class top-1 recall broken out by the corpus folder each sample came from.
|
|
628 |
+ |
///
|
|
629 |
+ |
/// A family is only a family if its members behave like one. `low` is kick plus
|
|
630 |
+ |
/// tom, and `af-coarse-families` calls that the one open split worth measuring:
|
|
631 |
+ |
/// tom sits between `low` and `bass` on centroid (p25-p75 904-1996 against kick's
|
|
632 |
+ |
/// 397-885) and is 23% of the drum corpus, so folding it in silently assumes the
|
|
633 |
+ |
/// answer. If toms are recovered as `low` about as often as kicks are, the fold
|
|
634 |
+ |
/// holds; if they are systematically lost, `low` is two things wearing one label.
|
|
635 |
+ |
///
|
|
636 |
+ |
/// This is the measurement, not a proposal to add a `tom` family. Reading it
|
|
637 |
+ |
/// against `AF_BENCH_EVAL_LABELS=family-tom-split`, which grades tom as its own
|
|
638 |
+ |
/// class, is what separates "tom is hard" from "tom is not low".
|
|
639 |
+ |
fn print_origin_breakdown(
|
|
640 |
+ |
predictions: &[Prediction],
|
|
641 |
+ |
classes: &[String],
|
|
642 |
+ |
space: LabelSpace,
|
|
643 |
+ |
report: &mut Report,
|
|
644 |
+ |
) {
|
|
645 |
+ |
let origins: BTreeSet<&str> = predictions.iter().map(|p| p.origin.as_str()).collect();
|
|
646 |
+ |
// Nothing to say when every class is one folder: the table would be the
|
|
647 |
+ |
// recall column of the confusion matrix, transposed.
|
|
648 |
+ |
if origins.len() <= classes.len() {
|
|
649 |
+ |
return;
|
|
650 |
+ |
}
|
|
651 |
+ |
|
|
652 |
+ |
println!("━━━ BY CORPUS ORIGIN (k = {DEFAULT_K}) ━━━");
|
|
653 |
+ |
println!();
|
|
654 |
+ |
println!(" The same top-1 answers, grouped by the folder the sample came from");
|
|
655 |
+ |
println!(" rather than by the class it was projected onto. A family whose");
|
|
656 |
+ |
println!(" members disagree here is not one family.");
|
|
657 |
+ |
println!();
|
|
658 |
+ |
println!(
|
|
659 |
+ |
" {:<14} {:<14} {:>6} {:>9} most common wrong answer",
|
|
660 |
+ |
"origin", "projects to", "n", "recall"
|
|
661 |
+ |
);
|
|
662 |
+ |
println!(" {}", "─".repeat(76));
|
|
663 |
+ |
|
|
664 |
+ |
for origin in origins {
|
|
665 |
+ |
let mine: Vec<&Prediction> = predictions.iter().filter(|p| p.origin == origin).collect();
|
|
666 |
+ |
let Some(truth) = mine.first().map(|p| p.truth.clone()) else {
|
|
667 |
+ |
continue;
|
|
668 |
+ |
};
|
|
669 |
+ |
let correct = mine
|
|
670 |
+ |
.iter()
|
|
671 |
+ |
.filter(|p| p.top1.as_deref() == Some(truth.as_str()))
|
|
672 |
+ |
.count();
|
|
673 |
+ |
let recall = correct as f64 / mine.len() as f64;
|
|
674 |
+ |
|
|
675 |
+ |
// Where the misses go, which is the informative half: a tom read as
|
|
676 |
+ |
// drum-bright says something different from a tom the index has no
|
|
677 |
+ |
// answer for at all.
|
|
678 |
+ |
let mut wrong: BTreeMap<&str, usize> = BTreeMap::new();
|
|
679 |
+ |
for p in &mine {
|
|
680 |
+ |
match p.top1.as_deref() {
|
|
681 |
+ |
Some(t) if t != truth => *wrong.entry(t).or_default() += 1,
|
|
682 |
+ |
None => *wrong.entry("(none)").or_default() += 1,
|
|
683 |
+ |
_ => {}
|
|
684 |
+ |
}
|
|
685 |
+ |
}
|
|
686 |
+ |
let worst = wrong.iter().max_by_key(|(_, n)| **n).map_or_else(
|
|
687 |
+ |
|| "-".to_string(),
|
|
688 |
+ |
|(t, n)| {
|
|
689 |
+ |
let label = if *t == "(none)" {
|
|
690 |
+ |
"(none)"
|
|
691 |
+ |
} else {
|
|
692 |
+ |
families::label_for(space, t)
|
|
693 |
+ |
};
|
|
694 |
+ |
format!("{label} ({n})")
|
|
695 |
+ |
},
|
|
696 |
+ |
);
|
|
697 |
+ |
|
|
698 |
+ |
println!(
|
|
699 |
+ |
" {:<14} {:<14} {:>6} {:>9} {worst}",
|
|
700 |
+ |
origin,
|
|
701 |
+ |
families::label_for(space, &truth),
|
|
702 |
+ |
mine.len(),
|
|
703 |
+ |
pct(Some(recall)),
|
|
704 |
+ |
);
|
|
705 |
+ |
report.set(&format!("origin_{origin}_recall"), round4(recall));
|
|
706 |
+ |
report.set(&format!("origin_{origin}_n"), mine.len());
|
|
707 |
+ |
}
|
|
708 |
+ |
println!();
|
|
709 |
+ |
}
|
|
710 |
+ |
|
| 525 |
711 |
|
/// What the layer does today, unchanged: one global auto threshold for every
|
| 526 |
712 |
|
/// 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) {
|
|
713 |
+ |
fn print_shipped_defaults(
|
|
714 |
+ |
predictions: &[Prediction],
|
|
715 |
+ |
classes: &[String],
|
|
716 |
+ |
space: LabelSpace,
|
|
717 |
+ |
report: &mut Report,
|
|
718 |
+ |
) {
|
| 528 |
719 |
|
println!("━━━ AT THE SHIPPED DEFAULTS (one global threshold) ━━━");
|
| 529 |
720 |
|
println!();
|
| 530 |
721 |
|
|
| 545 |
736 |
|
let c = counts[class];
|
| 546 |
737 |
|
println!(
|
| 547 |
738 |
|
" {:<12} {:>7} {:>8} {:>10} {:>9}",
|
| 548 |
|
- |
label_for_tag(class),
|
|
739 |
+ |
families::label_for(space, class),
|
| 549 |
740 |
|
c.actual(),
|
| 550 |
741 |
|
c.fired(),
|
| 551 |
742 |
|
pct(c.precision()),
|
| 574 |
765 |
|
println!();
|
| 575 |
766 |
|
|
| 576 |
767 |
|
for (tag, c) in &counts {
|
| 577 |
|
- |
let label = label_for_tag(tag);
|
|
768 |
+ |
let label = families::label_for(space, tag);
|
| 578 |
769 |
|
if let Some(p) = c.precision() {
|
| 579 |
770 |
|
report.set(&format!("shipped_{label}_precision"), round4(p));
|
| 580 |
771 |
|
}
|
| 589 |
780 |
|
///
|
| 590 |
781 |
|
/// This is the evidence that one global threshold cannot serve seven classes: read
|
| 591 |
782 |
|
/// down a column and the same number means a different thing in every row.
|
| 592 |
|
- |
fn print_threshold_sweep(predictions: &[Prediction], classes: &[String]) {
|
|
783 |
+ |
fn print_threshold_sweep(predictions: &[Prediction], classes: &[String], space: LabelSpace) {
|
| 593 |
784 |
|
let points: BTreeMap<&String, Vec<Point>> = classes
|
| 594 |
785 |
|
.iter()
|
| 595 |
786 |
|
.map(|c| (c, class_points(predictions, c)))
|
| 608 |
799 |
|
println!();
|
| 609 |
800 |
|
println!(" {}", "─".repeat(12 + SWEEP_THRESHOLDS.len() * 8));
|
| 610 |
801 |
|
for class in classes {
|
| 611 |
|
- |
print!(" {:<12}", label_for_tag(class));
|
|
802 |
+ |
print!(" {:<12}", families::label_for(space, class));
|
| 612 |
803 |
|
for t in SWEEP_THRESHOLDS {
|
| 613 |
804 |
|
let c = calibration::counts_at(&points[class], *t);
|
| 614 |
805 |
|
print!(" {:>7}", pct(metric(c)));
|
| 630 |
821 |
|
predictions: &[Prediction],
|
| 631 |
822 |
|
classes: &[String],
|
| 632 |
823 |
|
folds: usize,
|
|
824 |
+ |
space: LabelSpace,
|
| 633 |
825 |
|
report: &mut Report,
|
| 634 |
826 |
|
) -> BTreeMap<String, Counts> {
|
| 635 |
827 |
|
println!(
|
| 657 |
849 |
|
calibration::out_of_fold(&points, folds, GATE.target_precision, GATE.min_support);
|
| 658 |
850 |
|
out_of_fold.insert(class.clone(), oof);
|
| 659 |
851 |
|
|
| 660 |
|
- |
let label = label_for_tag(class);
|
|
852 |
+ |
let label = families::label_for(space, class);
|
| 661 |
853 |
|
match in_sample {
|
| 662 |
854 |
|
Some(op) => println!(
|
| 663 |
855 |
|
" {:<12} {:>7} {:>10.3} {:>10} {:>9} {:>18}",
|
| 844 |
1036 |
|
classes: &[String],
|
| 845 |
1037 |
|
top1: &BTreeMap<String, Counts>,
|
| 846 |
1038 |
|
calibrated: &BTreeMap<String, Counts>,
|
|
1039 |
+ |
space: LabelSpace,
|
| 847 |
1040 |
|
report: &mut Report,
|
| 848 |
1041 |
|
) {
|
| 849 |
1042 |
|
println!("━━━ VERDICT ━━━");
|
| 852 |
1045 |
|
let mut failures: Vec<String> = Vec::new();
|
| 853 |
1046 |
|
|
| 854 |
1047 |
|
for class in classes {
|
| 855 |
|
- |
let label = label_for_tag(class);
|
|
1048 |
+ |
let label = families::label_for(space, class);
|
| 856 |
1049 |
|
let c = calibrated[class];
|
| 857 |
1050 |
|
if c.fired() == 0 {
|
| 858 |
1051 |
|
failures.push(format!(
|
| 900 |
1093 |
|
println!(" calibrated thresholds with it: set `include_policy` in afcl_gen and");
|
| 901 |
1094 |
|
println!(" export the tag_policy rows, or the layer inherits the global 0.85");
|
| 902 |
1095 |
|
println!(" and none of the above holds.");
|
| 903 |
|
- |
println!();
|
| 904 |
|
- |
println!(" This clears the measurement only. The layer is still drums-only, so");
|
| 905 |
|
- |
println!(" it answers for a bass with the nearest drum it knows; that is Phase C.");
|
| 906 |
1096 |
|
} else {
|
| 907 |
1097 |
|
println!(" FAIL on {} criterion/criteria:", failures.len());
|
| 908 |
1098 |
|
for f in &failures {
|
| 911 |
1101 |
|
}
|
| 912 |
1102 |
|
println!();
|
| 913 |
1103 |
|
|
|
1104 |
+ |
// Printed on pass and on fail both, because the scope caveat is not a
|
|
1105 |
+ |
// consolation for a failure: a pass here is the more dangerous of the two to
|
|
1106 |
+ |
// read as a verdict on the layer.
|
|
1107 |
+ |
print_scope(classes, space);
|
|
1108 |
+ |
|
| 914 |
1109 |
|
report.set("gate_pass", failures.is_empty());
|
| 915 |
1110 |
|
report.set("gate_failures", failures.len());
|
| 916 |
1111 |
|
}
|
| 917 |
1112 |
|
|
|
1113 |
+ |
/// What this corpus can and cannot support a claim about.
|
|
1114 |
+ |
///
|
|
1115 |
+ |
/// The gate above says whether the classes present are shippable. It cannot say
|
|
1116 |
+ |
/// anything about the classes absent, and the absent ones are the majority: the
|
|
1117 |
+ |
/// corpus is drum one-shots, so it reaches two of the seven families and five have
|
|
1118 |
+ |
/// no material at all. A layer that answers `drum-bright` confidently for a vocal
|
|
1119 |
+ |
/// it has never seen passes every criterion above.
|
|
1120 |
+ |
fn print_scope(classes: &[String], space: LabelSpace) {
|
|
1121 |
+ |
if space == LabelSpace::Instrument {
|
|
1122 |
+ |
println!(" SCOPE: this run grades specific drum instruments, which is the");
|
|
1123 |
+ |
println!(" retired question (wiki af-browse-axes, 2026-07-29). Kept runnable so");
|
|
1124 |
+ |
println!(" the results already written up stay reproducible. Do not extend it.");
|
|
1125 |
+ |
println!();
|
|
1126 |
+ |
return;
|
|
1127 |
+ |
}
|
|
1128 |
+ |
|
|
1129 |
+ |
let (covered, uncovered) = families::covered_families(classes);
|
|
1130 |
+ |
println!(
|
|
1131 |
+ |
" SCOPE: {} of {} families measured: {}.",
|
|
1132 |
+ |
covered.len(),
|
|
1133 |
+ |
families::FAMILIES.len(),
|
|
1134 |
+ |
covered.join(", ")
|
|
1135 |
+ |
);
|
|
1136 |
+ |
if !uncovered.is_empty() {
|
|
1137 |
+ |
println!(" No corpus material for: {}.", uncovered.join(", "));
|
|
1138 |
+ |
println!(" The layer has never been asked about them and will answer with the");
|
|
1139 |
+ |
println!(" nearest drum it knows. Nothing above is a verdict on the layer;");
|
|
1140 |
+ |
println!(" widening the corpus is the next phase.");
|
|
1141 |
+ |
}
|
|
1142 |
+ |
println!();
|
|
1143 |
+ |
}
|
|
1144 |
+ |
|
| 918 |
1145 |
|
/// Fold count from the environment, or [`DEFAULT_FOLDS`].
|
| 919 |
1146 |
|
pub(crate) fn folds_from_env() -> usize {
|