Skip to main content

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}