auracle_taste/standardize.rs
1//! Feature standardization.
2//!
3//! Raw `φ` scales vary wildly (counts 0–5, log-octave axes ~0–1, log crest
4//! 0–4); a Gaussian prior over θ only makes sense on a common scale. The
5//! standardizer is re-fit at every posterior fit, over the union of the
6//! observation log and the live pool, and **persisted with the taste
7//! profile** — θ is only meaningful relative to the standardization that
8//! produced it, so a profile carries both or neither.
9//!
10//! It is a view of the data, not the data: the log stores raw φ, so a
11//! re-fit standardizer simply re-expresses the same evidence on a scale that
12//! still matches where the pool actually is.
13
14use serde::{Deserialize, Serialize};
15
16/// Per-dimension affine standardization: `(x - mean) / std`.
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
18pub struct Standardizer {
19 /// Per-dimension means.
20 pub mean: Vec<f64>,
21 /// Per-dimension standard deviations (floored to 1.0 where degenerate).
22 pub std: Vec<f64>,
23}
24
25/// Fraction of each tail pulled in when a column turns out to be runaway.
26///
27/// 2% is "one or two of the most extreme rows" at the size this actually runs
28/// on — the live pool plus the evicted rows of the log, 40–120 rows.
29const WINSOR_TAIL: f64 = 0.02;
30
31/// Below this many usable rows in a column, nothing is winsorized.
32///
33/// With a handful of values the min and max *are* the spread, and pulling them
34/// in throws away the only information about it. Ten is where one row per tail
35/// stops being a fifth of the sample.
36const WINSOR_MIN_ROWS: usize = 10;
37
38/// How many times bigger the plain σ has to be than the winsorized one before
39/// a column is judged **led by its tail rather than described by it**.
40///
41/// This threshold is the whole design, and both the shape of the rule and the
42/// size of the number were arrived at by measurement rather than by argument.
43///
44/// **Routine winsorizing was written first and thrown out.** Clipping 2% of
45/// each tail and always using those moments took a 16-seed
46/// `search_health --climb` from `+1.877 ± 0.362` mean gain, climbing on 15/16,
47/// to `+0.204 ± 1.347` on 11/16, with one seed at **−18.2**. Trimming a real
48/// tail is not free, and a data-hygiene fix that costs the search a standard
49/// deviation is not a fix. So the clip became a **fault detector**: the plain
50/// moments unless the column is provably runaway, which on clean data is a
51/// no-op *by construction* rather than by luck.
52///
53/// **Then the threshold itself was measured**, because the first guess at it
54/// (8×, on the reasoning that clean columns differ "by a factor of order one")
55/// was wrong and the paired run said so — 15 of 16 seeds came back bit-identical
56/// and the sixteenth went from `+0.12` to `−40.5`. `cargo run -p
57/// auracle-features --example winsor_ratio --release -- 150` fits 150 clean
58/// 48-patch pools and reports the largest plain/winsorized σ ratio per column:
59/// over 6 000 column-fits the maximum is **14.6** (`rms_std:p2`), with
60/// `chord_flatness_delta:p2` at 13.9 — and it is still climbing with the sample,
61/// because a log-scale audio descriptor over a pool that happens to contain one
62/// near-silent patch genuinely has a tail.
63///
64/// A single `1e30` in a column whose real values live in [0,1] gives a ratio
65/// near 2×10²⁹. `1e6` sits five orders above anything clean φ has been observed
66/// to produce and twenty-three below the fault, which is as far from both edges
67/// as this quantity allows anyone to be.
68const RUNAWAY_RATIO: f64 = 1e6;
69
70/// How many rows are pulled in at each end of a column of `n` usable values.
71///
72/// `ceil`, and floored at one above [`WINSOR_MIN_ROWS`] — not `floor`, which is
73/// how the first version of this was written and which made the whole thing
74/// inert exactly where it was needed. The reference population is a 48-patch
75/// pool and `floor(48 × 0.02)` is **0**, so nothing was clipped at the size the
76/// app actually fits at; the pre/post measurement came back bit-identical and
77/// said so.
78fn winsor_k(n: usize) -> usize {
79 if n < WINSOR_MIN_ROWS {
80 return 0;
81 }
82 // `2k < n` by construction at n ≥ 10 for any tail under 0.5, but the cap
83 // is written down rather than reasoned about: `col[k]` and `col[n-1-k]`
84 // crossing would silently collapse the column onto one value.
85 (((n as f64) * WINSOR_TAIL).ceil() as usize).clamp(1, (n - 1) / 2)
86}
87
88/// Mean and (population) standard deviation of `col`, optionally with every
89/// value pulled into `[lo, hi]` first.
90fn moments(col: &[f64], clip: Option<(f64, f64)>) -> (f64, f64) {
91 let at = |x: &f64| match clip {
92 Some((lo, hi)) => x.clamp(lo, hi),
93 None => *x,
94 };
95 let n = col.len() as f64;
96 let m = col.iter().map(at).sum::<f64>() / n;
97 let var = col
98 .iter()
99 .map(|x| {
100 let d = at(x) - m;
101 d * d
102 })
103 .sum::<f64>()
104 / n;
105 (m, var.sqrt())
106}
107
108impl Standardizer {
109 /// Fit on a reference sample (rows are feature vectors).
110 ///
111 /// **Robust against a runaway column, and otherwise exactly the plain
112 /// moments.** The unrobustified version is what turned one bad row into a
113 /// dead coordinate: six cells of `1e30` in fifty stored observations gave
114 /// `amp_sustain` a mean of ~1.2e29 and a σ of ~5.5e29, which standardizes
115 /// every real patch in the pool to −0.2 ± 1e-30 — a column with no variance
116 /// left in it, that the model can never learn from and the belief line still
117 /// prints a contribution for.
118 ///
119 /// Per column: take the plain moments and the moments with the extreme 2%
120 /// of each tail pulled in, and use the second **only** when the first is
121 /// more than [`RUNAWAY_RATIO`] times wider. That threshold, rather than
122 /// winsorizing unconditionally, is deliberate and measured — see
123 /// [`RUNAWAY_RATIO`] for what unconditional cost the search. The fault that
124 /// produced the row is fixed upstream of here (`PatchTree::clamp_domains`,
125 /// the featurizer's quarantine, the load-time repair); this is the line
126 /// that means the *next* one costs a coordinate's precision rather than the
127 /// coordinate.
128 ///
129 /// Winsorizing rather than trimming, when it does fire: the rows are not
130 /// independent draws from a nuisance distribution — they are the patches
131 /// the player has actually met, and a real extreme patch is evidence about
132 /// where the pool is. Pulling it in keeps its vote and takes away only its
133 /// leverage on the units.
134 ///
135 /// Non-finite cells are dropped from the column they appear in rather than
136 /// poisoning it; a column that is *entirely* non-finite falls back to the
137 /// degenerate case (mean 0, σ 1), which standardizes everything to itself
138 /// and is the honest reading of "no usable evidence on this axis".
139 ///
140 /// # Panics
141 /// Panics if `rows` is empty or ragged.
142 pub fn fit(rows: &[Vec<f64>]) -> Self {
143 assert!(!rows.is_empty(), "cannot fit a standardizer on no data");
144 let d = rows[0].len();
145 for r in rows {
146 assert_eq!(r.len(), d, "ragged feature rows");
147 }
148 let (mut mean, mut std) = (vec![0.0; d], vec![1.0; d]);
149 let mut col: Vec<f64> = Vec::with_capacity(rows.len());
150 let mut sorted: Vec<f64> = Vec::with_capacity(rows.len());
151 for j in 0..d {
152 col.clear();
153 col.extend(rows.iter().map(|r| r[j]).filter(|x| x.is_finite()));
154 if col.is_empty() {
155 continue; // mean 0 / σ 1: no usable evidence on this axis
156 }
157 // `col` stays in **row order** and the quantiles come off a copy.
158 // Floating-point addition is not associative, so summing the sorted
159 // column would move the mean by a ULP on clean data — and the whole
160 // claim below is that clean data comes out bit-identical.
161 let (mut m, mut s) = moments(&col, None);
162 let k = winsor_k(col.len());
163 if k > 0 {
164 sorted.clear();
165 sorted.extend_from_slice(&col);
166 sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite by construction"));
167 let (lo, hi) = (sorted[k], sorted[sorted.len() - 1 - k]);
168 // `hi > lo` is the guard that keeps a legitimately rare column
169 // intact: when 96% of the rows are the same value — a module
170 // that appears in two patches out of forty-eight — the tail
171 // *is* the column's only information, and clipping it would
172 // flatten a real coordinate to nothing in the name of
173 // robustness.
174 if hi > lo {
175 let (mw, sw) = moments(&col, Some((lo, hi)));
176 if s > RUNAWAY_RATIO * sw {
177 m = mw;
178 s = sw;
179 }
180 }
181 }
182 mean[j] = m;
183 std[j] = if s < 1e-9 { 1.0 } else { s };
184 }
185 Self { mean, std }
186 }
187
188 /// Standardize one feature vector.
189 pub fn transform(&self, x: &[f64]) -> Vec<f64> {
190 x.iter()
191 .zip(&self.mean)
192 .zip(&self.std)
193 .map(|((x, m), s)| (x - m) / s)
194 .collect()
195 }
196
197 /// Undo [`Self::transform`] — recover raw values from z-scores. The
198 /// migration path for logs written before raw-φ logging depends on this:
199 /// a legacy log plus the standardizer it was written under *is* the raw
200 /// data, just encoded.
201 pub fn inverse(&self, z: &[f64]) -> Vec<f64> {
202 z.iter()
203 .zip(&self.mean)
204 .zip(&self.std)
205 .map(|((z, m), s)| z * s + m)
206 .collect()
207 }
208
209 /// Dimension this standardizer was fit for.
210 pub fn dimension(&self) -> usize {
211 self.mean.len()
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn col(values: &[f64]) -> Vec<Vec<f64>> {
220 values.iter().map(|v| vec![*v]).collect()
221 }
222
223 /// The defect, as a number. Fifty rows of a coordinate spread over [0,1]
224 /// plus **one** escaped `1e30`: unwinsorized, the outlier owns the mean
225 /// and the scale, every real patch standardizes to the same place, and the
226 /// column is dead — the model can never learn from an axis whose fifty
227 /// honest values are separated by 1e-30 of a standard deviation.
228 #[test]
229 fn one_escaped_row_cannot_kill_a_column() {
230 let mut values: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
231 let clean = Standardizer::fit(&col(&values));
232 values.push(1e30);
233 let poisoned = Standardizer::fit(&col(&values));
234
235 // The scale still describes where the real data is, to within the one
236 // row's worth of extra weight at the top of the range.
237 assert!(
238 (poisoned.std[0] - clean.std[0]).abs() < 0.05,
239 "σ moved from {} to {}",
240 clean.std[0],
241 poisoned.std[0]
242 );
243 assert!((poisoned.mean[0] - clean.mean[0]).abs() < 0.05);
244
245 // …and the coordinate still separates two real patches, which is the
246 // only thing it is for. Unwinsorized this difference was ~1e-30.
247 let spread = poisoned.transform(&[1.0])[0] - poisoned.transform(&[0.0])[0];
248 assert!(spread > 3.0, "the column carries no information: {spread}");
249 }
250
251 /// **Clean data must come out bit-identical to the unrobustified fit.**
252 ///
253 /// The load-bearing property of the whole design, and the one the first
254 /// attempt did not have: a routine 2% clip cost the 16-seed search-health
255 /// climb `+1.877 → +0.204` mean gain. A fit that is the plain moments
256 /// unless a column is runaway cannot cost the search anything, and this is
257 /// what says so — over a heavy right tail, a near-constant column, a
258 /// bipolar one and a count, none of which may move by a ULP.
259 #[test]
260 fn clean_columns_are_bit_identical_to_the_plain_moments() {
261 let plain = |v: &[f64]| {
262 let n = v.len() as f64;
263 let m = v.iter().sum::<f64>() / n;
264 (
265 m,
266 (v.iter().map(|x| (x - m) * (x - m)).sum::<f64>() / n).sqrt(),
267 )
268 };
269 let cases: Vec<Vec<f64>> = vec![
270 // A heavy right tail (log-crest shaped).
271 (0..60).map(|i| (1.0 + i as f64 / 6.0).ln()).collect(),
272 // Near-constant with two rare non-zeros — a module in 2 of 48.
273 (0..48).map(|i| if i < 46 { 0.0 } else { 1.0 }).collect(),
274 // Bipolar, symmetric.
275 (0..80).map(|i| (i as f64 - 40.0) / 13.0).collect(),
276 // A count column with a legitimately extreme member.
277 {
278 let mut v: Vec<f64> = (0..47).map(|i| (i % 4) as f64).collect();
279 v.push(9.0);
280 v
281 },
282 // Five rows: under the winsorize floor entirely.
283 vec![0.1, 0.4, 0.55, 0.9, 0.2],
284 ];
285 for (i, values) in cases.iter().enumerate() {
286 let sz = Standardizer::fit(&col(values));
287 let (m, s) = plain(values);
288 assert_eq!(sz.mean[0], m, "case {i}: mean moved");
289 assert_eq!(
290 sz.std[0],
291 if s < 1e-9 { 1.0 } else { s },
292 "case {i}: σ moved"
293 );
294 }
295 }
296
297 /// The tail size the detector uses when it does fire. `floor` gave zero for
298 /// every n below 50 — including the 48-row reference population the
299 /// search-health harness uses — so the rule was inert exactly where it was
300 /// needed.
301 #[test]
302 fn winsor_k_covers_the_sizes_this_runs_at() {
303 assert_eq!(winsor_k(9), 0, "too few rows to call anything a tail");
304 assert_eq!(winsor_k(10), 1);
305 assert_eq!(winsor_k(48), 1, "a full pool must be able to clip a row");
306 assert_eq!(winsor_k(90), 2);
307 assert_eq!(winsor_k(200), 4);
308
309 // …and the guarantee it buys: one escaped value in a 48-row column
310 // cannot move the scale by more than the honest spread of the column.
311 let mut values: Vec<f64> = (0..47).map(|i| i as f64 / 46.0).collect();
312 let clean = Standardizer::fit(&col(&values));
313 values.push(1e30);
314 let poisoned = Standardizer::fit(&col(&values));
315 assert!(
316 (poisoned.std[0] - clean.std[0]).abs() < 0.05,
317 "σ moved from {} to {}",
318 clean.std[0],
319 poisoned.std[0]
320 );
321 }
322
323 /// A non-finite cell is dropped from its column rather than turning the
324 /// whole coordinate into NaN — which is what it used to do, silently, for
325 /// every patch in the pool.
326 #[test]
327 fn a_non_finite_cell_does_not_poison_its_column() {
328 let sz = Standardizer::fit(&col(&[0.2, f64::NAN, 0.8, 0.5]));
329 assert!(sz.mean[0].is_finite() && sz.std[0].is_finite());
330 assert!(sz.transform(&[0.5])[0].is_finite());
331 }
332}