| 334 |
334 |
|
let calibrated = print_calibration(default_k, &classes, folds, space, &mut report);
|
| 335 |
335 |
|
if k_sweep.len() > 1 {
|
| 336 |
336 |
|
print_k_sweep(&by_k, &classes, folds, &mut report);
|
|
337 |
+ |
print_nested_k(&rows, &fold_of, &classes, k_sweep, folds, &mut report);
|
| 337 |
338 |
|
}
|
| 338 |
339 |
|
print_verdict(&classes, &top1, &calibrated, space, &mut report);
|
| 339 |
340 |
|
report.write();
|
| 824 |
825 |
|
println!(" the precision bar. Those are the classes a shipped layer cannot apply");
|
| 825 |
826 |
|
println!(" at all, whatever the global default is set to.");
|
| 826 |
827 |
|
println!();
|
| 827 |
|
- |
println!(" CAVEAT, and it is the same one the thresholds were fixed for: this");
|
| 828 |
|
- |
println!(" table selects k on the data it reports. Thresholds are calibrated out");
|
| 829 |
|
- |
println!(" of fold, k is not, so reading the best row here and shipping that k");
|
| 830 |
|
- |
println!(" would be choosing a hyperparameter on the test set. Treat it as");
|
| 831 |
|
- |
println!(" evidence about the mechanism, and confirm a chosen k on a corpus this");
|
| 832 |
|
- |
println!(" has not seen, or under an outer fold that holds k out too.");
|
|
828 |
+ |
println!(" CAVEAT: this table selects k on the data it reports. Thresholds are");
|
|
829 |
+ |
println!(" calibrated out of fold, k is not, so reading the best row here and");
|
|
830 |
+ |
println!(" shipping that k would be choosing a hyperparameter on the test set.");
|
|
831 |
+ |
println!(" It is evidence about the mechanism. The outer fold below is the");
|
|
832 |
+ |
println!(" number to quote instead.");
|
| 833 |
833 |
|
println!();
|
| 834 |
834 |
|
}
|
| 835 |
835 |
|
|
|
836 |
+ |
/// Select `k` under an outer fold, so the selection is never scored on the data
|
|
837 |
+ |
/// it was made from.
|
|
838 |
+ |
///
|
|
839 |
+ |
/// The sweep above is the standard trap: it fits thresholds out of fold and then
|
|
840 |
+ |
/// picks `k` by reading every row of the result. That is choosing a
|
|
841 |
+ |
/// hyperparameter on the test set, and the honest correction is the same shape as
|
|
842 |
+ |
/// the one that fixed the thresholds — hold the selection out too.
|
|
843 |
+ |
///
|
|
844 |
+ |
/// Procedure. For each outer fold: take the other folds as a training corpus,
|
|
845 |
+ |
/// split THOSE by an inner fold, select the `k` with the best inner macro
|
|
846 |
+ |
/// calibrated recall, then build one index over the whole training corpus and
|
|
847 |
+ |
/// score the outer fold at the selected `k`. Nothing about the outer fold is
|
|
848 |
+ |
/// visible to the selection, so the pooled result is what "select k this way"
|
|
849 |
+ |
/// generalises to.
|
|
850 |
+ |
///
|
|
851 |
+ |
/// Indexes are rebuilt rather than reused from `by_k`: a prediction there was
|
|
852 |
+ |
/// made by an index containing the outer test fold, so recycling them would leak
|
|
853 |
+ |
/// exactly what this exists to stop. That costs an extra `folds * folds` index
|
|
854 |
+ |
/// builds and no extra analysis, which is the cheap half.
|
|
855 |
+ |
///
|
|
856 |
+ |
/// What to read: if the outer number matches the best row of the sweep, the
|
|
857 |
+ |
/// sweep was not being flattered by its own selection and the mechanism finding
|
|
858 |
+ |
/// stands. If it comes in below, the difference is the selection bias, and the
|
|
859 |
+ |
/// outer number is the one that would survive contact with a user's library.
|
|
860 |
+ |
fn print_nested_k(
|
|
861 |
+ |
rows: &[Row],
|
|
862 |
+ |
outer_of: &[Option<usize>],
|
|
863 |
+ |
classes: &[String],
|
|
864 |
+ |
k_sweep: &[usize],
|
|
865 |
+ |
folds: usize,
|
|
866 |
+ |
report: &mut Report,
|
|
867 |
+ |
) {
|
|
868 |
+ |
println!("━━━ k UNDER AN OUTER FOLD ━━━");
|
|
869 |
+ |
println!();
|
|
870 |
+ |
println!(" k selected inside each outer fold's training corpus, then scored on the");
|
|
871 |
+ |
println!(" outer fold it never saw. This is the k-sweep number with the selection");
|
|
872 |
+ |
println!(" bias removed.");
|
|
873 |
+ |
println!();
|
|
874 |
+ |
println!(
|
|
875 |
+ |
" {:>7} {:>10} {:>10} {:>16} {:>14}",
|
|
876 |
+ |
"outer", "train", "k chosen", "inner recall", "outer recall"
|
|
877 |
+ |
);
|
|
878 |
+ |
println!(" {}", "─".repeat(64));
|
|
879 |
+ |
|
|
880 |
+ |
let mut pooled: Vec<Prediction> = Vec::new();
|
|
881 |
+ |
let mut chosen: Vec<usize> = Vec::new();
|
|
882 |
+ |
|
|
883 |
+ |
for outer in 0..folds {
|
|
884 |
+ |
let train: Vec<&Row> = rows
|
|
885 |
+ |
.iter()
|
|
886 |
+ |
.zip(outer_of)
|
|
887 |
+ |
.filter(|(_, f)| **f != Some(outer))
|
|
888 |
+ |
.map(|(r, _)| r)
|
|
889 |
+ |
.collect();
|
|
890 |
+ |
let test: Vec<&Row> = rows
|
|
891 |
+ |
.iter()
|
|
892 |
+ |
.zip(outer_of)
|
|
893 |
+ |
.filter(|(_, f)| **f == Some(outer))
|
|
894 |
+ |
.map(|(r, _)| r)
|
|
895 |
+ |
.collect();
|
|
896 |
+ |
if test.is_empty() {
|
|
897 |
+ |
continue;
|
|
898 |
+ |
}
|
|
899 |
+ |
|
|
900 |
+ |
// Inner CV over the training corpus only.
|
|
901 |
+ |
let train_rows: Vec<Row> = train.iter().map(|r| clone_row(r)).collect();
|
|
902 |
+ |
let inner_of = rows::assign_folds(&train_rows, folds);
|
|
903 |
+ |
let mut inner: BTreeMap<usize, Vec<Prediction>> =
|
|
904 |
+ |
k_sweep.iter().map(|k| (*k, Vec::new())).collect();
|
|
905 |
+ |
for i in 0..folds {
|
|
906 |
+ |
let itrain: Vec<&Row> = train_rows
|
|
907 |
+ |
.iter()
|
|
908 |
+ |
.zip(&inner_of)
|
|
909 |
+ |
.filter(|(_, f)| **f != Some(i))
|
|
910 |
+ |
.map(|(r, _)| r)
|
|
911 |
+ |
.collect();
|
|
912 |
+ |
let itest: Vec<&Row> = train_rows
|
|
913 |
+ |
.iter()
|
|
914 |
+ |
.zip(&inner_of)
|
|
915 |
+ |
.filter(|(_, f)| **f == Some(i))
|
|
916 |
+ |
.map(|(r, _)| r)
|
|
917 |
+ |
.collect();
|
|
918 |
+ |
let Ok(db) = rows::local_db(&itrain) else {
|
|
919 |
+ |
continue;
|
|
920 |
+ |
};
|
|
921 |
+ |
let Ok(index) = exemplar::build_index(&db) else {
|
|
922 |
+ |
continue;
|
|
923 |
+ |
};
|
|
924 |
+ |
for row in itest {
|
|
925 |
+ |
for &k in k_sweep {
|
|
926 |
+ |
push_prediction(inner.entry(k).or_default(), &index, row, k, i);
|
|
927 |
+ |
}
|
|
928 |
+ |
}
|
|
929 |
+ |
}
|
|
930 |
+ |
|
|
931 |
+ |
// Selection criterion: macro calibrated recall, the same quantity the
|
|
932 |
+ |
// sweep table ranks on, so the two are comparable.
|
|
933 |
+ |
let score_of = |preds: &Vec<Prediction>| {
|
|
934 |
+ |
let mut calibrated: BTreeMap<String, Counts> = BTreeMap::new();
|
|
935 |
+ |
for class in classes {
|
|
936 |
+ |
let points = class_points(preds, class);
|
|
937 |
+ |
let (oof, _) = calibration::out_of_fold(
|
|
938 |
+ |
&points,
|
|
939 |
+ |
folds,
|
|
940 |
+ |
GATE.target_precision,
|
|
941 |
+ |
GATE.min_support,
|
|
942 |
+ |
);
|
|
943 |
+ |
calibrated.insert(class.clone(), oof);
|
|
944 |
+ |
}
|
|
945 |
+ |
macro_average(classes, &calibrated, Counts::recall).unwrap_or(0.0)
|
|
946 |
+ |
};
|
|
947 |
+ |
let Some((best_k, inner_recall)) = k_sweep
|
|
948 |
+ |
.iter()
|
|
949 |
+ |
.map(|k| (*k, score_of(&inner[k])))
|
|
950 |
+ |
// total_cmp then the smaller k, so a tie ships the tighter
|
|
951 |
+ |
// neighbourhood rather than whichever the map iterated first.
|
|
952 |
+ |
.max_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0)))
|
|
953 |
+ |
else {
|
|
954 |
+ |
continue;
|
|
955 |
+ |
};
|
|
956 |
+ |
chosen.push(best_k);
|
|
957 |
+ |
|
|
958 |
+ |
// Score the outer fold at the selected k, from an index over the whole
|
|
959 |
+ |
// training corpus.
|
|
960 |
+ |
let Ok(db) = rows::local_db(&train) else {
|
|
961 |
+ |
continue;
|
|
962 |
+ |
};
|
|
963 |
+ |
let Ok(index) = exemplar::build_index(&db) else {
|
|
964 |
+ |
continue;
|
|
965 |
+ |
};
|
|
966 |
+ |
let mut outer_preds: Vec<Prediction> = Vec::new();
|
|
967 |
+ |
for row in &test {
|
|
968 |
+ |
push_prediction(&mut outer_preds, &index, row, best_k, outer);
|
|
969 |
+ |
}
|
|
970 |
+ |
let outer_recall = {
|
|
971 |
+ |
let mut top1: BTreeMap<String, Counts> = BTreeMap::new();
|
|
972 |
+ |
for class in classes {
|
|
973 |
+ |
let mine = outer_preds.iter().filter(|p| &p.truth == class).count();
|
|
974 |
+ |
let correct = outer_preds
|
|
975 |
+ |
.iter()
|
|
976 |
+ |
.filter(|p| &p.truth == class && p.top1.as_deref() == Some(class.as_str()))
|
|
977 |
+ |
.count();
|
|
978 |
+ |
let predicted_as = outer_preds
|
|
979 |
+ |
.iter()
|
|
980 |
+ |
.filter(|p| p.top1.as_deref() == Some(class.as_str()))
|
|
981 |
+ |
.count();
|
|
982 |
+ |
top1.insert(
|
|
983 |
+ |
class.clone(),
|
|
984 |
+ |
Counts {
|
|
985 |
+ |
tp: correct,
|
|
986 |
+ |
fp: predicted_as - correct,
|
|
987 |
+ |
fn_: mine - correct,
|
|
988 |
+ |
},
|
|
989 |
+ |
);
|
|
990 |
+ |
}
|
|
991 |
+ |
macro_average(classes, &top1, Counts::recall)
|
|
992 |
+ |
};
|
|
993 |
+ |
|
|
994 |
+ |
println!(
|
|
995 |
+ |
" {:>7} {:>10} {:>10} {:>16} {:>14}",
|
|
996 |
+ |
outer,
|
|
997 |
+ |
train.len(),
|
|
998 |
+ |
best_k,
|
|
999 |
+ |
pct(Some(inner_recall)),
|
|
1000 |
+ |
pct(outer_recall),
|
|
1001 |
+ |
);
|
|
1002 |
+ |
pooled.extend(outer_preds);
|
|
1003 |
+ |
}
|
|
1004 |
+ |
println!();
|
|
1005 |
+ |
|
|
1006 |
+ |
if chosen.is_empty() {
|
|
1007 |
+ |
println!(" no outer fold completed");
|
|
1008 |
+ |
println!();
|
|
1009 |
+ |
return;
|
|
1010 |
+ |
}
|
|
1011 |
+ |
|
|
1012 |
+ |
let mut top1: BTreeMap<String, Counts> = BTreeMap::new();
|
|
1013 |
+ |
for class in classes {
|
|
1014 |
+ |
let mine = pooled.iter().filter(|p| &p.truth == class).count();
|
|
1015 |
+ |
let correct = pooled
|
|
1016 |
+ |
.iter()
|
|
1017 |
+ |
.filter(|p| &p.truth == class && p.top1.as_deref() == Some(class.as_str()))
|
|
1018 |
+ |
.count();
|
|
1019 |
+ |
let predicted_as = pooled
|
|
1020 |
+ |
.iter()
|
|
1021 |
+ |
.filter(|p| p.top1.as_deref() == Some(class.as_str()))
|
|
1022 |
+ |
.count();
|
|
1023 |
+ |
top1.insert(
|
|
1024 |
+ |
class.clone(),
|
|
1025 |
+ |
Counts {
|
|
1026 |
+ |
tp: correct,
|
|
1027 |
+ |
fp: predicted_as - correct,
|
|
1028 |
+ |
fn_: mine - correct,
|
|
1029 |
+ |
},
|
|
1030 |
+ |
);
|
|
1031 |
+ |
}
|
|
1032 |
+ |
let pooled_recall = macro_average(classes, &top1, Counts::recall);
|
|
1033 |
+ |
let agreed: BTreeSet<usize> = chosen.iter().copied().collect();
|
|
1034 |
+ |
|
|
1035 |
+ |
println!(
|
|
1036 |
+ |
" Pooled outer macro top-1 recall {} over {} predictions.",
|
|
1037 |
+ |
pct(pooled_recall),
|
|
1038 |
+ |
pooled.len()
|
|
1039 |
+ |
);
|
|
1040 |
+ |
if agreed.len() == 1 {
|
|
1041 |
+ |
let k = *agreed.iter().next().unwrap_or(&DEFAULT_K);
|
|
1042 |
+ |
println!(" Every outer fold selected k = {k}. A selection that does not move with");
|
|
1043 |
+ |
println!(" the training data is one the corpus supports, not one it happened onto.");
|
|
1044 |
+ |
if k != DEFAULT_K {
|
|
1045 |
+ |
println!(
|
|
1046 |
+ |
" It is NOT the shipped k ({DEFAULT_K}). That is a real finding, not a rounding:"
|
|
1047 |
+ |
);
|
|
1048 |
+ |
println!(" the runtime constant predates every measurement of this layer.");
|
|
1049 |
+ |
}
|
|
1050 |
+ |
report.set("nested_k_selected", k);
|
|
1051 |
+ |
} else {
|
|
1052 |
+ |
let spread: Vec<String> = agreed.iter().map(usize::to_string).collect();
|
|
1053 |
+ |
println!(" Outer folds disagreed on k: {}.", spread.join(", "));
|
|
1054 |
+ |
println!(" A selection that moves with the training data is not a property of the");
|
|
1055 |
+ |
println!(" corpus, and shipping any single one of these is a coin toss dressed as a");
|
|
1056 |
+ |
println!(" measurement. Read the sweep as a mechanism finding and leave k alone.");
|
|
1057 |
+ |
report.set("nested_k_disagreed", agreed.len());
|
|
1058 |
+ |
}
|
|
1059 |
+ |
if let Some(r) = pooled_recall {
|
|
1060 |
+ |
report.set("nested_k_outer_macro_recall", round4(r));
|
|
1061 |
+ |
}
|
|
1062 |
+ |
println!();
|
|
1063 |
+ |
}
|
|
1064 |
+ |
|
|
1065 |
+ |
/// Score one row against one index and push the prediction.
|
|
1066 |
+ |
fn push_prediction(
|
|
1067 |
+ |
into: &mut Vec<Prediction>,
|
|
1068 |
+ |
index: &exemplar::ExemplarIndex,
|
|
1069 |
+ |
row: &Row,
|
|
1070 |
+ |
k: usize,
|
|
1071 |
+ |
fold: usize,
|
|
1072 |
+ |
) {
|
|
1073 |
+ |
// No `exclude_hash`: the row is not in this index at all, which is the
|
|
1074 |
+ |
// property the fold split exists to give.
|
|
1075 |
+ |
let scored = index.score(&row.vector, k, None);
|
|
1076 |
+ |
into.push(Prediction {
|
|
1077 |
+ |
truth: row.truth.clone().unwrap_or_default(),
|
|
1078 |
+ |
top1: scored.first().map(|s| s.tag.clone()),
|
|
1079 |
+ |
scores: scored.into_iter().map(|s| (s.tag, s.score)).collect(),
|
|
1080 |
+ |
fold,
|
|
1081 |
+ |
origin: row.origin.clone(),
|
|
1082 |
+ |
});
|
|
1083 |
+ |
}
|
|
1084 |
+ |
|
|
1085 |
+ |
/// A `Row` copy, so the inner split can own its training corpus.
|
|
1086 |
+ |
///
|
|
1087 |
+ |
/// `assign_folds` takes `&[Row]` rather than `&[&Row]`, and the inner fold is
|
|
1088 |
+ |
/// assigned over a subset that only exists as borrows. Copying ~800 short vectors
|
|
1089 |
+ |
/// once per outer fold is cheaper than threading a second lifetime through the
|
|
1090 |
+ |
/// split.
|
|
1091 |
+ |
fn clone_row(r: &Row) -> Row {
|
|
1092 |
+ |
Row {
|
|
1093 |
+ |
hash: r.hash.clone(),
|
|
1094 |
+ |
vector: r.vector.clone(),
|
|
1095 |
+ |
tags: r.tags.clone(),
|
|
1096 |
+ |
truth: r.truth.clone(),
|
|
1097 |
+ |
origin: r.origin.clone(),
|
|
1098 |
+ |
name: r.name.clone(),
|
|
1099 |
+ |
}
|
|
1100 |
+ |
}
|
|
1101 |
+ |
|
| 836 |
1102 |
|
fn macro_average(
|
| 837 |
1103 |
|
classes: &[String],
|
| 838 |
1104 |
|
counts: &BTreeMap<String, Counts>,
|