gam_models/bms/conditional_score_covariance.rs
1//! The CONDITIONAL score covariance `Σ(a) = Var(z | a)` (gam#2766).
2//!
3//! # What this fixes
4//!
5//! The marginal-slope probit identity, transcribed from
6//! `crate::bms::gradient_paths`, is
7//!
8//! ```text
9//! z | a ~ N(0, Σ(a)), η = c(a)·q(t, a) + r(a)ᵀ z
10//! E_z[Φ(−η) | a] = Φ(−q(t, a)) ⟺ c(a) = √(1 + r(a)ᵀ Σ(a) r(a))
11//! ```
12//!
13//! `Σ(a)`, conditional on the marginal-index span. Until this module existed the
14//! families supplied it from `marginal_slope_covariance_from_scores` — ONE
15//! weighted empirical covariance pooled over every row. Substituting a constant
16//! `c̄ = √(1 + rᵀΣ̄r)` into the exact integral leaves
17//!
18//! ```text
19//! E_z[Φ(−η) | a] = Φ(−q · c̄ / √(1 + rᵀΣ(a)r))
20//! ```
21//!
22//! so the realised marginal index is `q · c̄/c(a)`: a multiplicative,
23//! covariate-dependent distortion of the one estimand this family exists to
24//! deliver. It is the same failure the `homoskedastic_var` field doc records for
25//! the K=1 diagonal (`Φ(q√(1+b²)/√(1+b²v))`), one dimension up.
26//!
27//! gam#2768 removed the per-coordinate half of it: after that gate every
28//! coordinate satisfies `E[ζ_j|a] = 0` and `Var(ζ_j|a) = 1`. What no
29//! per-coordinate location-scale map can reach is the OFF-DIAGONAL —
30//! `Cov(ζ_j, ζ_k | a)` — and that is what this module models.
31//!
32//! # The parameterisation, and why this one
33//!
34//! A covariance-valued regression has to return a positive-definite matrix at
35//! every `a`, including rows the fit never saw. Modelling the entries of `Σ(a)`
36//! directly does not: nothing keeps a fitted `[[1, ρ(a)], [ρ(a), 1]]` inside
37//! `|ρ| < 1`, and one row over the edge makes `c(a)` the square root of a
38//! negative number.
39//!
40//! This module uses Pourahmadi's **modified Cholesky decomposition** (MCD),
41//! whose parameters are *unconstrained* — every real value of every parameter
42//! yields a positive-definite `Σ(a)`, so extrapolation cannot manufacture an
43//! inadmissible covariance:
44//!
45//! ```text
46//! T(a) Σ(a) T(a)ᵀ = D(a),
47//! T(a) unit lower triangular with T[j][k] = −φ_{jk}(a) (k < j),
48//! D(a) = diag(d_0(a), …, d_{K−1}(a)), log d_j(a) = γ_jᵀ A(a).
49//! ```
50//!
51//! Read forwards this is a triangular system of ordinary regressions, which is
52//! exactly why it is the right object here — it is the SAME shape as the
53//! machinery gam#2768 already ships, applied one coordinate at a time:
54//!
55//! ```text
56//! ζ_j = Σ_{k<j} φ_{jk}(a)·ζ_k + ε_j, Var(ε_j | a) = d_j(a)
57//! ```
58//!
59//! the `φ` stage being a weighted ridge (like the conditional MEAN stage) and
60//! the `d` stage a log-linear variance fit (the conditional VARIANCE stage). The
61//! reconstruction `Σ(a) = T(a)⁻¹ D(a) T(a)⁻ᵀ` is one forward substitution, and
62//! `L(a) = T(a)⁻¹ D(a)^{1/2}` is its exact Cholesky factor — which is precisely
63//! the `Σ = L Lᵀ` shape [`MarginalSlopeCovariance::low_rank`] already admits, so
64//! the row program's quadratic forms stay exact sums of squares and no runtime
65//! eigendecomposition or PSD tolerance appears anywhere on this path.
66//!
67//! `log` d rather than a linear `d` with a floor (the shape gam#2768 used for
68//! the K=1 variance) because a floor is a non-differentiable clamp that can and
69//! does bind, whereas `exp` is positive by construction on the whole real line.
70//!
71//! # What triggers it
72//!
73//! One robust Rao score test per score PAIR, on the same centred marginal-index
74//! span the gam#2768 gate uses and at the same level: `u_i = ζ_ij·ζ_ik − mean`
75//! against `ã(C)`. That statistic tests exactly the sentence this issue is
76//! titled with — "the covariance between two scores varies" — and nothing else.
77//! If no pair fires, this module returns `None` and the caller keeps the pooled
78//! `Σ̄` object it already built, byte for byte.
79//!
80//! Once a pair HAS fired, every stage of the MCD is fitted honestly with its own
81//! gate: a `φ_{jk}` becomes `a`-varying only if its own interaction test fires,
82//! a `log d_j` becomes `a`-varying only if its own Breusch-Pagan test fires.
83//! That ordering — escalate on the thing the issue names, then fit the escalated
84//! model without further hedging — is the one gam#2768 already uses (a
85//! pure-variance trigger there still fits the conditional mean).
86//!
87//! # Extrapolation
88//!
89//! Each fitted linear predictor (`φ_{jk}(a)` and `log d_j(a)`) is clamped at
90//! evaluation to the range it took over the TRAINING rows. The bound is the
91//! data's, not a constant: a linear predictor is identified only on the range
92//! the sample explored, and holding the boundary value beyond it is the same
93//! monotone-extrapolation contract [`crate::bms::LatentZRankIntCalibration`]
94//! already states for out-of-support scores. Without it a predict row far
95//! outside the training hull could return an arbitrarily large `Σ(a)` from a
96//! model that had no evidence there.
97//!
98//! # What `Σ(a)` is a moment OF, and why the ordering with gam#2768 matters
99//!
100//! The estimated object is the conditional SECOND CENTRAL MOMENT about the
101//! weighted GLOBAL score mean, `E[(z − z̄)(z − z̄)ᵀ | a]` — which is what makes
102//! the no-escalation limit of this model exactly the pooled
103//! `marginal_slope_covariance_from_scores` object it refines, rather than
104//! something merely close to it.
105//!
106//! That equals `Var(z | a)` precisely when `E[z | a]` is constant. It is, by the
107//! time this runs: the gam#2768 per-coordinate gate is sequenced FIRST, and it
108//! either removes a detected conditional mean (`ζ = (z − m(a))/√v(a)`) or
109//! certifies at the same `α` that there is none to remove. Running the two in
110//! the other order would be wrong in both directions — this model would absorb
111//! mean structure into a "covariance", and the mean gate would then be
112//! correcting an axis whose scale had already moved.
113//!
114//! # Numerical edge cases
115//!
116//! * **Degenerate (collinear) scores.** A rank-deficient score geometry is a
117//! real, expected input — [`MarginalSlopeCovariance::full`] says so and admits
118//! the exactly-singular matrix it produces. Here it lands as a zero innovation
119//! variance, which `log d` cannot represent, so the innovation is floored at
120//! the SAME band that admission clamps a numerically-zero eigenvalue inside
121//! (`128·K·ε·max weighted second moment`). The result is positive definite
122//! rather than singular, which is strictly better for the `√(1 + rᵀΣr)` that
123//! consumes it.
124//! * **Extreme magnitudes.** Every stage checks finiteness of what it produces
125//! and refuses with the offending value rather than propagating a `NaN`: a
126//! score scale that overflows `ε²` overflows the pooled estimator too, and
127//! this path says so at the point it happens.
128//! * **An ill-conditioned conditioning span.** A penalised-spline marginal index
129//! is routinely rank-deficient. Every regression here — the couplings and the
130//! Fisher-scoring step for the innovation alike — goes through the same
131//! relative, column-scaled Tikhonov primitive the gam#2768 stages use, so the
132//! two degrade identically instead of one of them having its own normal
133//! equations.
134//! * **Parallel and GPU row lanes.** `Σ(a_i)` is a per-row CONSTANT, not a
135//! function of `β`, so every existing derivative formula holds verbatim and
136//! nothing about the chain rule changes. Each rayon chunk binds its own row
137//! workspace, and the survival GPU row kernel already carried `cov_ones` per
138//! row, so the conditional lane needed no new device state.
139//!
140//! # `K = 1` is deliberately out of scope
141//!
142//! At `K = 1` there is no off-diagonal, and `Var(z|a)` is gam#2768's
143//! per-coordinate branch. Running a second, differently parameterised
144//! conditional-variance model on top of the score that branch has already
145//! standardised would double-correct it. [`ConditionalScoreCovariance::fit`]
146//! therefore returns `None` at `K < 2`, and the K=1 path is bit-for-bit
147//! unchanged.
148
149use super::{
150 AUTO_Z_CONDITIONAL_RAO_ALPHA, AUTO_Z_CONDITIONAL_RIDGE_REL, MarginalSlopeCovariance,
151 build_intercept_basis, robust_conditional_score_pvalue,
152};
153use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
154use serde::{Deserialize, Serialize};
155
156/// Damped Fisher-scoring iterations allowed for one log-linear innovation
157/// variance. The objective is strictly concave and bounded above and every
158/// accepted step strictly ascends it, so the loop terminates on its own; this
159/// cap exists to bound it, not to select an answer, and a run that reaches it is
160/// a refusal rather than a truncation. Measured on the module's own fixtures the
161/// hardest fit converges in 22 accepted steps, with the gain shrinking by about
162/// 4.5x per step — from which 64 is roughly 40 orders of magnitude of headroom.
163const LOG_INNOVATION_MAX_ITERATIONS: usize = 64;
164
165/// One coordinate of the modified Cholesky decomposition.
166///
167/// `autoregression[k]` (for `k < j`) holds the coefficients of `φ_{jk}(a)` and
168/// `log_innovation` the coefficients of `log d_j(a)`, both over the
169/// intercept-augmented basis `A = [1 | a]`. A coefficient vector of length `1`
170/// is a CONSTANT — the stage's own gate did not fire — and one of length
171/// `1 + basis_ncols` is `a`-varying. The two lengths are the only encoding of
172/// "this stage varies"; there is no separate flag that could disagree with the
173/// coefficients.
174#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
175pub struct ConditionalScoreCoordinate {
176 /// `φ_{jk}(a)` for `k = 0 … j−1`, in `k` order. Empty for `j = 0`.
177 pub autoregression: Vec<Vec<f64>>,
178 /// `log d_j(a)`.
179 pub log_innovation: Vec<f64>,
180 /// Training range `[min, max]` of each `φ_{jk}(a)` linear predictor, in the
181 /// same order as `autoregression`. Length-1 (constant) stages still carry
182 /// their degenerate range so evaluation has one code path.
183 pub autoregression_range: Vec<[f64; 2]>,
184 /// Training range `[min, max]` of the `log d_j(a)` linear predictor.
185 pub log_innovation_range: [f64; 2],
186}
187
188/// The fitted conditional score covariance `Σ(a) = Var(z | a)`.
189///
190/// Evaluation is [`Self::factor_into`], which writes the exact lower-triangular
191/// Cholesky factor `L(a)` with `Σ(a) = L(a)·L(a)ᵀ`.
192#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
193pub struct ConditionalScoreCovariance {
194 /// Number of latent-score coordinates `K`. Always `≥ 2`.
195 pub score_dim: usize,
196 /// Number of marginal-design columns in `a` (EXCLUDING the leading
197 /// intercept). A predict-time conditioning block must present exactly this
198 /// many columns.
199 pub basis_ncols: usize,
200 /// Per-coordinate MCD blocks, in coordinate order. Length `score_dim`.
201 pub coordinates: Vec<ConditionalScoreCoordinate>,
202 /// Weighted mean of each raw score column at fit time. `Σ(a)` is the second
203 /// CENTRAL moment about this vector, which is what makes the no-fire limit
204 /// of this model the pooled `marginal_slope_covariance_from_scores` object
205 /// it replaces.
206 pub score_mean: Vec<f64>,
207 /// The pair-wise Rao p-values that decided the escalation, `(j, k, p)` with
208 /// `j < k`. Diagnostic; carried so a fit can say WHY it escalated.
209 pub pair_pvalues: Vec<(usize, usize, f64)>,
210}
211
212/// The score-covariance geometry a marginal-slope fit consumes, ROW BY ROW.
213///
214/// One object with two states, because every consumer wants the same thing —
215/// "the covariance at this row" — and only the fit's own gate decides which
216/// state it is in:
217///
218/// * `Pooled` — the single weighted empirical `Σ̄` from
219/// `marginal_slope_covariance_from_scores`. Every row returns the same
220/// object, so a fit that does not escalate is bit-for-bit what it was before
221/// gam#2766.
222/// * conditional — a materialised `Σ(a_i)` per row, produced by
223/// [`ConditionalScoreCovariance::row_covariances`]. The pooled object is
224/// RETAINED alongside it: it is still the fit's summary statistic (it is what
225/// the on-disk contract carries and what a diagnostic reports), and keeping it
226/// means no caller has to decide between "the covariance" and "the covariance
227/// here".
228///
229/// The row lane is an index, not a branch on a model: `at_row` is one match on
230/// an `Option` and one slice index, so the conditional path costs the hot loop
231/// nothing beyond the indirection.
232#[derive(Clone)]
233pub struct ScoreCovarianceField {
234 pooled: MarginalSlopeCovariance,
235 per_row: Option<std::sync::Arc<Vec<MarginalSlopeCovariance>>>,
236 model: Option<std::sync::Arc<ConditionalScoreCovariance>>,
237}
238
239impl std::fmt::Debug for ScoreCovarianceField {
240 /// Summarises rather than dumps. A derived `Debug` on a conditional field
241 /// prints one `K × K` matrix PER ROW, which on a biobank-scale fit is a
242 /// multi-gigabyte line the first time anything formats a family.
243 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 formatter
245 .debug_struct("ScoreCovarianceField")
246 .field("pooled", &self.pooled)
247 .field("materialised_rows", &self.materialised_rows())
248 .field("conditional_model", &self.model)
249 .finish()
250 }
251}
252
253impl PartialEq for ScoreCovarianceField {
254 /// Compares the geometry, not the materialised stack: two fields built from
255 /// the same pooled covariance and the same conditional model ARE the same
256 /// field, and the per-row stack is a pure function of the two.
257 fn eq(&self, other: &Self) -> bool {
258 self.pooled == other.pooled && self.model == other.model
259 }
260}
261
262impl From<MarginalSlopeCovariance> for ScoreCovarianceField {
263 fn from(pooled: MarginalSlopeCovariance) -> Self {
264 Self {
265 pooled,
266 per_row: None,
267 model: None,
268 }
269 }
270}
271
272impl ScoreCovarianceField {
273 /// The pooled, row-invariant field. This is the pre-gam#2766 object.
274 pub fn pooled(pooled: MarginalSlopeCovariance) -> Self {
275 Self::from(pooled)
276 }
277
278 /// Materialise `Σ(a_i)` for every row of `a_block`, keeping `pooled` as the
279 /// fit's summary. Refuses a model whose score dimension disagrees with the
280 /// pooled covariance's, because the two are the same `K` by construction and
281 /// a mismatch means the caller paired a field with the wrong fit.
282 pub fn conditional(
283 pooled: MarginalSlopeCovariance,
284 model: ConditionalScoreCovariance,
285 a_block: ArrayView2<'_, f64>,
286 ) -> Result<Self, String> {
287 if model.score_dim != pooled.dim() {
288 return Err(format!(
289 "conditional score covariance is K={} but the pooled covariance is K={}",
290 model.score_dim,
291 pooled.dim()
292 ));
293 }
294 let per_row = model.row_covariances(a_block)?;
295 Ok(Self {
296 pooled,
297 per_row: Some(std::sync::Arc::new(per_row)),
298 model: Some(std::sync::Arc::new(model)),
299 })
300 }
301
302 /// The covariance at `row`.
303 #[inline(always)]
304 pub fn at_row(&self, row: usize) -> &MarginalSlopeCovariance {
305 match &self.per_row {
306 None => &self.pooled,
307 Some(stack) => &stack[row],
308 }
309 }
310
311 /// The fit's pooled summary covariance, whatever the field's state.
312 #[inline]
313 pub fn pooled_covariance(&self) -> &MarginalSlopeCovariance {
314 &self.pooled
315 }
316
317 /// `K`.
318 #[inline]
319 pub fn dim(&self) -> usize {
320 self.pooled.dim()
321 }
322
323 /// Whether the covariance varies by row.
324 #[inline]
325 pub fn is_conditional(&self) -> bool {
326 self.per_row.is_some()
327 }
328
329 /// The fitted conditional model, when the field carries one. This is the
330 /// object persistence and prediction need; the materialised stack is a
331 /// training-row cache and is never saved.
332 #[inline]
333 pub fn model(&self) -> Option<&ConditionalScoreCovariance> {
334 self.model.as_deref()
335 }
336
337 /// Rows the field was materialised for, when it is conditional. A caller
338 /// that indexes past this has mixed two samples.
339 #[inline]
340 pub fn materialised_rows(&self) -> Option<usize> {
341 self.per_row.as_ref().map(|stack| stack.len())
342 }
343}
344
345/// Affine evaluation `coeffs·[1 | a]`, clamped to the training range of that
346/// linear predictor. A length-1 coefficient vector is the constant stage.
347#[inline]
348fn clamped_affine(coeffs: &[f64], range: &[f64; 2], a_row: ArrayView1<'_, f64>) -> f64 {
349 let mut acc = coeffs[0];
350 for (coefficient, &value) in coeffs[1..].iter().zip(a_row.iter()) {
351 acc += coefficient * value;
352 }
353 acc.clamp(range[0], range[1])
354}
355
356impl ConditionalScoreCovariance {
357 /// The lower-triangular Cholesky factor `L(a)` of `Σ(a)`, written into
358 /// `factor` (shape `K × K`, fully overwritten including the strict upper
359 /// triangle).
360 ///
361 /// `T(a)` is unit lower triangular with `T[j][k] = −φ_{jk}(a)`, so its
362 /// inverse `U = T⁻¹` is unit lower triangular and obtained by one forward
363 /// substitution:
364 ///
365 /// ```text
366 /// U[j][j] = 1, U[j][k] = φ_{jk}(a) + Σ_{m=k+1}^{j−1} φ_{jm}(a)·U[m][k]
367 /// ```
368 ///
369 /// and `L = U·D^{1/2}`, i.e. `L[j][k] = U[j][k]·√d_k(a)`. Positive
370 /// definiteness needs only `d_k(a) > 0`, which `exp` guarantees.
371 pub fn factor_into(
372 &self,
373 a_row: ArrayView1<'_, f64>,
374 factor: &mut Array2<f64>,
375 ) -> Result<(), String> {
376 let k = self.score_dim;
377 if a_row.len() != self.basis_ncols {
378 return Err(format!(
379 "conditional score covariance expects {} basis columns, got {}",
380 self.basis_ncols,
381 a_row.len()
382 ));
383 }
384 if factor.dim() != (k, k) {
385 return Err(format!(
386 "conditional score covariance factor must be {k}x{k}, got {}x{}",
387 factor.nrows(),
388 factor.ncols()
389 ));
390 }
391 factor.fill(0.0);
392 for j in 0..k {
393 let block = &self.coordinates[j];
394 // U[j][*] by forward substitution, written in place into row j.
395 factor[[j, j]] = 1.0;
396 for k_index in 0..j {
397 let phi = clamped_affine(
398 &block.autoregression[k_index],
399 &block.autoregression_range[k_index],
400 a_row,
401 );
402 if !phi.is_finite() {
403 return Err(format!(
404 "conditional score covariance autoregression ({j},{k_index}) is not finite"
405 ));
406 }
407 let mut value = phi;
408 for m in (k_index + 1)..j {
409 let phi_jm = clamped_affine(
410 &block.autoregression[m],
411 &block.autoregression_range[m],
412 a_row,
413 );
414 value += phi_jm * factor[[m, k_index]];
415 }
416 factor[[j, k_index]] = value;
417 }
418 }
419 // Scale column `k` by √d_k(a).
420 for column in 0..k {
421 let block = &self.coordinates[column];
422 let log_d = clamped_affine(
423 &block.log_innovation,
424 &block.log_innovation_range,
425 a_row,
426 );
427 let scale = (0.5 * log_d).exp();
428 if !(scale.is_finite() && scale > 0.0) {
429 return Err(format!(
430 "conditional score covariance innovation {column} evaluated to log d = {log_d}"
431 ));
432 }
433 for row in column..k {
434 factor[[row, column]] *= scale;
435 }
436 }
437 Ok(())
438 }
439
440 /// `Σ(a)` as a dense symmetric matrix. Diagnostics and tests; the row
441 /// program consumes [`Self::row_covariances`].
442 pub fn dense_at(&self, a_row: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
443 let mut factor = Array2::<f64>::zeros((self.score_dim, self.score_dim));
444 self.factor_into(a_row, &mut factor)?;
445 Ok(factor.dot(&factor.t()))
446 }
447
448 /// One admitted [`MarginalSlopeCovariance`] per row of `a_block`, in the
449 /// `Σ = L Lᵀ` low-rank representation whose quadratic forms are exact sums
450 /// of squares.
451 ///
452 /// Materialising the whole stack once is deliberate. The row program
453 /// evaluates `rᵀΣ(a_i)r` inside the inner Newton loop, many times per row
454 /// per outer step; re-running the forward substitution there would put an
455 /// `O(K³)` triangular solve in the hot path to save `K²` doubles of storage.
456 /// The stack costs `K` times the score matrix `z` the family already holds.
457 pub fn row_covariances(
458 &self,
459 a_block: ArrayView2<'_, f64>,
460 ) -> Result<Vec<MarginalSlopeCovariance>, String> {
461 if a_block.ncols() != self.basis_ncols {
462 return Err(format!(
463 "conditional score covariance expects {} basis columns, got {}",
464 self.basis_ncols,
465 a_block.ncols()
466 ));
467 }
468 let mut factor = Array2::<f64>::zeros((self.score_dim, self.score_dim));
469 let mut out = Vec::with_capacity(a_block.nrows());
470 for row in 0..a_block.nrows() {
471 self.factor_into(a_block.row(row), &mut factor)?;
472 out.push(MarginalSlopeCovariance::low_rank(factor.clone())?);
473 }
474 Ok(out)
475 }
476
477 /// Fit the conditional covariance, or decide there is nothing to fit.
478 ///
479 /// `scores` are the latent scores the row kernel will actually see — i.e.
480 /// AFTER the gam#2768 per-coordinate calibration, because `Σ` is the
481 /// covariance of the axis the likelihood integrates over. Returns `None`
482 /// when `K < 2`, when the basis is degenerate, or when no score pair's
483 /// covariance is significantly non-constant on `a_block`; in every one of
484 /// those the caller must keep the pooled object unchanged.
485 pub fn fit(
486 scores: ArrayView2<'_, f64>,
487 weights: ArrayView1<'_, f64>,
488 a_block: ArrayView2<'_, f64>,
489 ) -> Result<Option<Self>, String> {
490 let (n, k) = scores.dim();
491 let p = a_block.ncols();
492 if k < 2 || p == 0 || n == 0 {
493 return Ok(None);
494 }
495 if weights.len() != n || a_block.nrows() != n {
496 return Err(format!(
497 "conditional score covariance length mismatch: rows={n}, weights={}, basis rows={}",
498 weights.len(),
499 a_block.nrows()
500 ));
501 }
502 let total_weight = weights.iter().copied().sum::<f64>();
503 if !(total_weight.is_finite() && total_weight > 0.0) {
504 return Ok(None);
505 }
506 if scores.iter().chain(a_block.iter()).any(|v| !v.is_finite()) {
507 return Ok(None);
508 }
509
510 // Centre on the weighted score mean, so the no-escalation limit of this
511 // model is exactly `marginal_slope_covariance_from_scores`.
512 let mut score_mean = vec![0.0_f64; k];
513 for coordinate in 0..k {
514 score_mean[coordinate] = scores
515 .column(coordinate)
516 .iter()
517 .zip(weights.iter())
518 .map(|(&value, &weight)| weight * value)
519 .sum::<f64>()
520 / total_weight;
521 }
522 let mut centered = Array2::<f64>::zeros((n, k));
523 for row in 0..n {
524 for coordinate in 0..k {
525 centered[[row, coordinate]] = scores[[row, coordinate]] - score_mean[coordinate];
526 }
527 }
528
529 // Centre the basis columns: the score tests are about conditional
530 // structure BEYOND the global level, and a constant column collapses to
531 // ~0 and is dropped by the statistic's own pseudo-inverse rank.
532 let mut a_centered = a_block.to_owned();
533 for column in 0..p {
534 let column_mean = a_block
535 .column(column)
536 .iter()
537 .zip(weights.iter())
538 .map(|(&value, &weight)| weight * value)
539 .sum::<f64>()
540 / total_weight;
541 a_centered
542 .column_mut(column)
543 .mapv_inplace(|value| value - column_mean);
544 }
545
546 // The escalation gate: one robust Rao score test per PAIR, on the
547 // cross-product residual. This is the statistic for the sentence the
548 // issue is titled with and nothing wider.
549 let mut pair_pvalues = Vec::new();
550 let mut any_pair_fires = false;
551 for left in 0..k {
552 for right in (left + 1)..k {
553 let products: Vec<f64> = (0..n)
554 .map(|row| centered[[row, left]] * centered[[row, right]])
555 .collect();
556 let mean = products
557 .iter()
558 .zip(weights.iter())
559 .map(|(&value, &weight)| weight * value)
560 .sum::<f64>()
561 / total_weight;
562 let residual: Vec<f64> = products.iter().map(|&value| value - mean).collect();
563 let p_value =
564 robust_conditional_score_pvalue(a_centered.view(), &residual, weights)?;
565 if let Some(p_value) = p_value {
566 pair_pvalues.push((left, right, p_value));
567 any_pair_fires |= p_value < AUTO_Z_CONDITIONAL_RAO_ALPHA;
568 }
569 }
570 }
571 if !any_pair_fires {
572 return Ok(None);
573 }
574
575 let basis = build_intercept_basis(a_block);
576 // The band inside which an innovation variance is indistinguishable
577 // from zero. Same form and same coefficient as the PSD band
578 // `MarginalSlopeCovariance::full` admits an eigenvalue inside
579 // (`128·k·ε·max|λ̂|`), against the largest weighted second moment of the
580 // centred scores -- the scale a variance of this sample can have. It is
581 // derived from the sample and the floating-point type, and it exists
582 // because `log 0` is not a number, not because a number had to be
583 // picked.
584 let score_scale = (0..k)
585 .map(|coordinate| {
586 (0..n)
587 .map(|row| {
588 weights[row] * centered[[row, coordinate]] * centered[[row, coordinate]]
589 })
590 .sum::<f64>()
591 / total_weight
592 })
593 .fold(0.0_f64, f64::max);
594 let innovation_floor =
595 (128.0 * k as f64 * f64::EPSILON * score_scale).max(f64::MIN_POSITIVE);
596 // One MCD block per coordinate, in order: the triangular structure means
597 // coordinate `j` regresses on the CENTRED scores below it, not on their
598 // innovations, so no state carries between iterations.
599 let mut coordinates = Vec::with_capacity(k);
600 for j in 0..k {
601 let (autoregression, autoregression_range, residual) =
602 fit_autoregression(¢ered, j, basis.view(), a_centered.view(), weights)?;
603 let (log_innovation, log_innovation_range) = fit_log_innovation(
604 &residual,
605 basis.view(),
606 a_centered.view(),
607 weights,
608 total_weight,
609 innovation_floor,
610 )?;
611 coordinates.push(ConditionalScoreCoordinate {
612 autoregression,
613 log_innovation,
614 autoregression_range,
615 log_innovation_range,
616 });
617 }
618
619 Ok(Some(Self {
620 score_dim: k,
621 basis_ncols: p,
622 coordinates,
623 score_mean,
624 pair_pvalues,
625 }))
626 }
627}
628
629/// Fit `ζ_j = Σ_{k<j} φ_{jk}(a)·ζ_k + ε_j`, returning the coefficient blocks,
630/// their realised training ranges, and the residual `ε_j`.
631///
632/// Each `φ_{jk}` starts constant. A robust Rao score test of the constant-fit
633/// residual against `ã ⊙ ζ_k` — the exact LM statistic for "the coefficient of
634/// `ζ_k` depends on `a`" — decides whether that one coefficient is promoted to
635/// the full basis. Promotion is per `(j, k)`, so a model with one varying
636/// coupling does not spend `p` parameters on the couplings that are constant.
637fn fit_autoregression(
638 centered: &Array2<f64>,
639 j: usize,
640 basis: ArrayView2<'_, f64>,
641 a_centered: ArrayView2<'_, f64>,
642 weights: ArrayView1<'_, f64>,
643) -> Result<(Vec<Vec<f64>>, Vec<[f64; 2]>, Vec<f64>), String> {
644 let n = centered.nrows();
645 let response: Vec<f64> = (0..n).map(|row| centered[[row, j]]).collect();
646 if j == 0 {
647 return Ok((Vec::new(), Vec::new(), response));
648 }
649
650 // Stage 1 — constant couplings.
651 let mut constant_design = Array2::<f64>::zeros((n, j));
652 for row in 0..n {
653 for k_index in 0..j {
654 constant_design[[row, k_index]] = centered[[row, k_index]];
655 }
656 }
657 let (constant_coeffs, constant_fitted) =
658 weighted_ridge_columns(constant_design.view(), &response, weights)?;
659 let constant_residual: Vec<f64> = (0..n)
660 .map(|row| response[row] - constant_fitted[row])
661 .collect();
662
663 // Stage 2 — per-coupling interaction tests on that residual.
664 let mut varying = vec![false; j];
665 for k_index in 0..j {
666 let interaction: Vec<f64> = (0..n)
667 .map(|row| constant_residual[row] * centered[[row, k_index]])
668 .collect();
669 if let Some(p_value) =
670 robust_conditional_score_pvalue(a_centered, &interaction, weights)?
671 {
672 varying[k_index] = p_value < AUTO_Z_CONDITIONAL_RAO_ALPHA;
673 }
674 }
675 if varying.iter().all(|fires| !fires) {
676 let coeffs: Vec<Vec<f64>> = (0..j).map(|k| vec![constant_coeffs[k]]).collect();
677 let ranges: Vec<[f64; 2]> = (0..j)
678 .map(|k| [constant_coeffs[k], constant_coeffs[k]])
679 .collect();
680 return Ok((coeffs, ranges, constant_residual));
681 }
682
683 // Stage 3 — refit with the promoted couplings expanded over `[1 | a]`.
684 let basis_width = basis.ncols();
685 let mut widths = Vec::with_capacity(j);
686 let mut total_columns = 0usize;
687 for &fires in varying.iter() {
688 let width = if fires { basis_width } else { 1 };
689 widths.push(width);
690 total_columns += width;
691 }
692 let mut design = Array2::<f64>::zeros((n, total_columns));
693 let mut offset = 0usize;
694 for (k_index, &width) in widths.iter().enumerate() {
695 for row in 0..n {
696 let score = centered[[row, k_index]];
697 if width == 1 {
698 design[[row, offset]] = score;
699 } else {
700 for column in 0..basis_width {
701 design[[row, offset + column]] = score * basis[[row, column]];
702 }
703 }
704 }
705 offset += width;
706 }
707 let (coeffs, fitted) = weighted_ridge_columns(design.view(), &response, weights)?;
708 let residual: Vec<f64> = (0..n).map(|row| response[row] - fitted[row]).collect();
709
710 // Unpack into per-coupling blocks and record each one's realised range.
711 let mut blocks = Vec::with_capacity(j);
712 let mut ranges = Vec::with_capacity(j);
713 let mut offset = 0usize;
714 for &width in widths.iter() {
715 let block: Vec<f64> = coeffs[offset..offset + width].to_vec();
716 let range = linear_predictor_range(&block, basis, weights);
717 blocks.push(block);
718 ranges.push(range);
719 offset += width;
720 }
721 Ok((blocks, ranges, residual))
722}
723
724/// Fit `log d(a) = γᵀ[1 | a]` for the innovation `ε` by exact Fisher scoring of
725/// the Gaussian log-linear variance model, gated by a Breusch-Pagan score test.
726///
727/// With `ε_i ~ N(0, d_i)`, `d_i = exp(A_iᵀγ)`, the weighted log-likelihood score
728/// and Fisher information are
729///
730/// ```text
731/// s(γ) = ½ Σ_i w_i A_i (ε_i²/d_i − 1), I(γ) = ½ Σ_i w_i A_i A_iᵀ
732/// ```
733///
734/// so the scoring step is `Δγ = (Σ w A Aᵀ)⁻¹ Σ w A (ε²/d − 1)`: the halves
735/// cancel and the information does not depend on the response. That is why this
736/// is a short exact loop rather than the log-of-squares linear regression
737/// (Harvey's two-step), whose intercept carries the `E[log χ²₁] = −1.2704` bias
738/// and whose slopes are inefficient.
739///
740/// `Σ w A Aᵀ` is the EXPECTED information, not the observed one — the observed
741/// Hessian carries an `ε²/d` weight — so the undamped step overshoots and the
742/// iteration is monotone in the log-likelihood but not in any norm of the step.
743/// The loop is therefore a line-searched ascent, not a bare Newton iteration;
744/// see the body for the measurement that forced it.
745fn fit_log_innovation(
746 residual: &[f64],
747 basis: ArrayView2<'_, f64>,
748 a_centered: ArrayView2<'_, f64>,
749 weights: ArrayView1<'_, f64>,
750 total_weight: f64,
751 innovation_floor: f64,
752) -> Result<(Vec<f64>, [f64; 2]), String> {
753 let n = residual.len();
754 let raw = residual
755 .iter()
756 .zip(weights.iter())
757 .map(|(&value, &weight)| weight * value * value)
758 .sum::<f64>()
759 / total_weight;
760 if !raw.is_finite() {
761 return Err(format!(
762 "conditional score covariance innovation variance is {raw}, which no log-linear \
763 variance model can represent"
764 ));
765 }
766 let homoskedastic = raw.max(innovation_floor);
767 let constant = vec![homoskedastic.ln()];
768 let constant_range = [constant[0], constant[0]];
769
770 // A direction the sample does not distinguish from a point. Collinear
771 // scores are a REAL and expected input -- `MarginalSlopeCovariance::full`
772 // says so, and admits an eigenvalue anywhere inside its own solver band as
773 // an exact zero -- so this cannot be a refusal. It is instead the one place
774 // the log parameterisation needs a floor, because `log 0` is not a number:
775 // the innovation is held at the same band `full` clamps inside, which
776 // contributes no spread at the working precision and keeps `Σ(a)` positive
777 // definite rather than singular. Below it there is nothing to model, so the
778 // Breusch-Pagan stage is skipped as well.
779 if raw <= innovation_floor {
780 return Ok((constant, constant_range));
781 }
782
783 // Breusch-Pagan: does the innovation variance depend on `a` at all?
784 let bp_residual: Vec<f64> = residual
785 .iter()
786 .map(|&value| value * value - homoskedastic)
787 .collect();
788 let fires = robust_conditional_score_pvalue(a_centered, &bp_residual, weights)?
789 .is_some_and(|p_value| p_value < AUTO_Z_CONDITIONAL_RAO_ALPHA);
790 if !fires {
791 return Ok((constant, constant_range));
792 }
793
794 let width = basis.ncols();
795 let mut gamma = vec![0.0_f64; width];
796 gamma[0] = homoskedastic.ln();
797
798 // The Gaussian log-likelihood of the log-linear variance model, up to a
799 // constant: `ℓ(γ) = −½ Σ w (A_iᵀγ + ε_i²·exp(−A_iᵀγ))`. Non-finite is a
800 // legitimate answer — an iterate that drives `exp(−A_iᵀγ)` past the
801 // representable range is simply not an ascent step, and the line search
802 // below halves past it rather than the whole fit refusing.
803 let log_likelihood = |coefficients: &[f64]| -> Option<f64> {
804 let mut total = 0.0_f64;
805 for row in 0..n {
806 let weight = weights[row];
807 if !(weight > 0.0) {
808 continue;
809 }
810 let mut linear = 0.0;
811 for column in 0..width {
812 linear += coefficients[column] * basis[[row, column]];
813 }
814 total -= 0.5 * weight * (linear + residual[row] * residual[row] * (-linear).exp());
815 }
816 total.is_finite().then_some(total)
817 };
818
819 // Fisher scoring with a step-halving line search.
820 //
821 // The scoring step `Δγ = (Σ w A Aᵀ)⁻¹ Σ w A (ε²/d − 1)` is literally a
822 // weighted least-squares fit of `u_i = ε_i²/d_i − 1` on `A`, so it is taken
823 // with the same regularised primitive the coupling stage uses; the two then
824 // degrade identically on a rank-deficient span instead of one of them having
825 // its own hand-rolled normal equations.
826 //
827 // The line search is not optional. `Σ w A Aᵀ` is the EXPECTED information;
828 // the observed one carries an `ε²/d` weight, so the undamped step
829 // systematically overshoots and the iteration is not monotone in any norm of
830 // the step. Measured on a cubic span: the raw step's `max|A·Δγ|` went
831 // 3.21 → 1.04 → 1.66, and an earlier "stop when the step stops shrinking"
832 // rule took that third reading as convergence and returned a `log d` short of
833 // the optimum by 4.5 nats. Damping restores monotone ASCENT, which is the
834 // property a strictly concave objective actually supports, and the same run
835 // then converges in 22 accepted steps.
836 let mut current = log_likelihood(&gamma).ok_or_else(|| {
837 format!(
838 "conditional score covariance log-variance seed log d = {} is not evaluable",
839 gamma[0]
840 )
841 })?;
842 let mut converged = false;
843 for _ in 0..LOG_INNOVATION_MAX_ITERATIONS {
844 let mut deviation = Array1::<f64>::zeros(n);
845 for row in 0..n {
846 let mut linear = 0.0;
847 for column in 0..width {
848 linear += gamma[column] * basis[[row, column]];
849 }
850 deviation[row] = residual[row] * residual[row] * (-linear).exp() - 1.0;
851 }
852 if !deviation.iter().all(|value| value.is_finite()) {
853 return Err(
854 "conditional score covariance log-variance iterate left the representable range"
855 .to_string(),
856 );
857 }
858 let (step, _) = weighted_ridge_columns(
859 basis,
860 deviation.as_slice().expect("deviation is standard layout"),
861 weights,
862 )?;
863 // Halve until the step ascends. Two derived exits and no chosen
864 // tolerance: a trial that no longer moves `γ` at all in floating point
865 // cannot ascend, and a gain below the log-likelihood's own last place is
866 // not a gain.
867 let mut trial = 1.0_f64;
868 let mut accepted: Option<(Vec<f64>, f64)> = None;
869 while trial > 0.0 {
870 let candidate: Vec<f64> = gamma
871 .iter()
872 .zip(step.iter())
873 .map(|(value, direction)| value + trial * direction)
874 .collect();
875 if candidate
876 .iter()
877 .zip(gamma.iter())
878 .all(|(new, old)| new.to_bits() == old.to_bits())
879 {
880 break;
881 }
882 if let Some(value) = log_likelihood(&candidate)
883 && value > current
884 {
885 accepted = Some((candidate, value));
886 break;
887 }
888 trial *= 0.5;
889 }
890 let Some((candidate, value)) = accepted else {
891 // No representable step ascends: this IS the optimum to round-off.
892 converged = true;
893 break;
894 };
895 let gain = value - current;
896 gamma = candidate;
897 current = value;
898 if gain <= f64::EPSILON * (1.0 + current.abs()) {
899 converged = true;
900 break;
901 }
902 }
903 if !converged {
904 return Err(format!(
905 "conditional score covariance log-variance scoring did not converge in \
906 {LOG_INNOVATION_MAX_ITERATIONS} damped Fisher steps"
907 ));
908 }
909 let range = linear_predictor_range(&gamma, basis, weights);
910 Ok((gamma, range))
911}
912
913/// `[min, max]` of `coeffs·[1 | a]` over the training rows that carry weight.
914/// A constant stage returns its own degenerate range, so evaluation has exactly
915/// one code path.
916fn linear_predictor_range(
917 coeffs: &[f64],
918 basis: ArrayView2<'_, f64>,
919 weights: ArrayView1<'_, f64>,
920) -> [f64; 2] {
921 if coeffs.len() == 1 {
922 return [coeffs[0], coeffs[0]];
923 }
924 let mut low = f64::INFINITY;
925 let mut high = f64::NEG_INFINITY;
926 for row in 0..basis.nrows() {
927 if !(weights[row] > 0.0) {
928 continue;
929 }
930 let mut linear = 0.0;
931 for column in 0..coeffs.len() {
932 linear += coeffs[column] * basis[[row, column]];
933 }
934 low = low.min(linear);
935 high = high.max(linear);
936 }
937 if !(low.is_finite() && high.is_finite() && low <= high) {
938 return [coeffs[0], coeffs[0]];
939 }
940 [low, high]
941}
942
943/// Weighted ridge of `response` on `design` with the same relative,
944/// column-scaled Tikhonov penalty the conditional location-scale stages use.
945/// Returns the coefficients and the fitted values.
946fn weighted_ridge_columns(
947 design: ArrayView2<'_, f64>,
948 response: &[f64],
949 weights: ArrayView1<'_, f64>,
950) -> Result<(Vec<f64>, Vec<f64>), String> {
951 let width = design.ncols();
952 let mut penalty = Array2::<f64>::zeros((width, width));
953 for column in 0..width {
954 let diagonal = design
955 .column(column)
956 .iter()
957 .zip(weights.iter())
958 .map(|(&value, &weight)| weight * value * value)
959 .sum::<f64>()
960 .max(f64::MIN_POSITIVE);
961 penalty[[column, column]] = diagonal;
962 }
963 let response_array = Array1::from_vec(response.to_vec());
964 let response_column = response_array.view().insert_axis(ndarray::Axis(1));
965 let (coeffs, fitted) = gam_linalg::utils::gaussian_weighted_ridge(
966 design,
967 response_column,
968 penalty.view(),
969 weights,
970 AUTO_Z_CONDITIONAL_RIDGE_REL,
971 )?;
972 Ok((
973 coeffs.column(0).to_vec(),
974 fitted.column(0).to_vec(),
975 ))
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981
982 /// Deterministic standard normals (Box–Muller over splitmix64).
983 fn gaussians(n: usize, seed: u64) -> Vec<f64> {
984 let mut state = seed;
985 let mut unit = || {
986 state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
987 let mut z = state;
988 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
989 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
990 z ^= z >> 31;
991 ((z >> 11) as f64 + 0.5) / (1u64 << 53) as f64
992 };
993 let mut out = Vec::with_capacity(n + 1);
994 while out.len() < n {
995 let u1 = unit().max(1e-12);
996 let u2 = unit();
997 let r = (-2.0 * u1.ln()).sqrt();
998 out.push(r * (std::f64::consts::TAU * u2).cos());
999 out.push(r * (std::f64::consts::TAU * u2).sin());
1000 }
1001 out.truncate(n);
1002 out
1003 }
1004
1005 fn standardized(mut v: Vec<f64>) -> Vec<f64> {
1006 let n = v.len() as f64;
1007 let mean = v.iter().sum::<f64>() / n;
1008 let sd = (v.iter().map(|x| (x - mean) * (x - mean)).sum::<f64>() / n)
1009 .sqrt()
1010 .max(1e-12);
1011 for value in v.iter_mut() {
1012 *value = (*value - mean) / sd;
1013 }
1014 v
1015 }
1016
1017 /// A `K = 2` sample drawn from EXACTLY the model this module fits:
1018 /// `ζ₀ = √d₀·e₀`, `ζ₁ = φ(x)·ζ₀ + √d₁(x)·e₁` with `φ(x) = φ₀ + φ₁x` and
1019 /// `log d₁(x) = γ₀ + γ₁x`. Recovery is then a statement about the estimator
1020 /// and not about approximation error.
1021 fn in_class_fixture(
1022 n: usize,
1023 phi: [f64; 2],
1024 gamma: [f64; 2],
1025 log_d0: f64,
1026 ) -> (Array2<f64>, Array1<f64>, Array2<f64>) {
1027 let x = standardized(gaussians(n, 0x2766_D4));
1028 let e0 = standardized(gaussians(n, 0x2766_E5));
1029 let e1 = standardized(gaussians(n, 0x2766_F6));
1030 let mut scores = Array2::<f64>::zeros((n, 2));
1031 let mut a_block = Array2::<f64>::zeros((n, 1));
1032 let sd0 = (0.5 * log_d0).exp();
1033 for row in 0..n {
1034 a_block[[row, 0]] = x[row];
1035 let z0 = sd0 * e0[row];
1036 let sd1 = (0.5 * (gamma[0] + gamma[1] * x[row])).exp();
1037 scores[[row, 0]] = z0;
1038 scores[[row, 1]] = (phi[0] + phi[1] * x[row]) * z0 + sd1 * e1[row];
1039 }
1040 (scores, Array1::<f64>::ones(n), a_block)
1041 }
1042
1043 /// The estimator must recover a truth drawn from its own model class:
1044 /// the coupling slope, the innovation-variance slope, and hence `Σ(a)`
1045 /// itself across the covariate range.
1046 #[test]
1047 fn recovers_an_in_class_conditional_covariance() {
1048 let n = 40_000;
1049 let phi = [0.3_f64, 0.5];
1050 let gamma = [(0.75_f64).ln(), -0.4];
1051 let log_d0 = 0.0;
1052 let (scores, weights, a_block) = in_class_fixture(n, phi, gamma, log_d0);
1053 let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1054 .expect("fit")
1055 .expect("a varying cross-score covariance must escalate");
1056
1057 assert_eq!(fitted.score_dim, 2);
1058 assert_eq!(fitted.basis_ncols, 1);
1059 // Coordinate 1's coupling must be the planted affine function.
1060 let coupling = &fitted.coordinates[1].autoregression[0];
1061 assert_eq!(
1062 coupling.len(),
1063 2,
1064 "the coupling depends on a, so its own interaction gate must promote it"
1065 );
1066 assert!(
1067 (coupling[0] - phi[0]).abs() < 0.02 && (coupling[1] - phi[1]).abs() < 0.02,
1068 "coupling {coupling:?} against planted {phi:?}"
1069 );
1070 let innovation = &fitted.coordinates[1].log_innovation;
1071 assert_eq!(innovation.len(), 2, "the innovation variance depends on a");
1072 assert!(
1073 (innovation[0] - gamma[0]).abs() < 0.03 && (innovation[1] - gamma[1]).abs() < 0.03,
1074 "log innovation {innovation:?} against planted {gamma:?}"
1075 );
1076
1077 // And the assembled Σ(a) itself, against the closed-form truth.
1078 for &probe in &[-1.5_f64, -0.5, 0.0, 0.5, 1.5] {
1079 let a_row = Array1::from_vec(vec![probe]);
1080 let sigma = fitted.dense_at(a_row.view()).expect("Σ(a)");
1081 let phi_true = phi[0] + phi[1] * probe;
1082 let d0_true = log_d0.exp();
1083 let d1_true = (gamma[0] + gamma[1] * probe).exp();
1084 let truth = [
1085 d0_true,
1086 phi_true * d0_true,
1087 phi_true * phi_true * d0_true + d1_true,
1088 ];
1089 let got = [sigma[[0, 0]], sigma[[0, 1]], sigma[[1, 1]]];
1090 for (index, (&want, &have)) in truth.iter().zip(got.iter()).enumerate() {
1091 assert!(
1092 (want - have).abs() < 0.05 * (1.0 + want.abs()),
1093 "Σ(a={probe}) entry {index}: got {have}, want {want}"
1094 );
1095 }
1096 assert_eq!(sigma[[0, 1]], sigma[[1, 0]], "Σ(a) must be symmetric");
1097 }
1098 // The gate's own evidence must name the pair it fired on.
1099 assert!(
1100 fitted
1101 .pair_pvalues
1102 .iter()
1103 .any(|&(left, right, p)| left == 0 && right == 1 && p < 1.0e-3),
1104 "the (0,1) pair must be the recorded trigger: {:?}",
1105 fitted.pair_pvalues
1106 );
1107 }
1108
1109 /// The escalation gate must hold its SIZE. A trigger-happy gate would
1110 /// install a fitted covariance field on every multi-score fit, replacing an
1111 /// exactly-correct pooled object with an estimated one — a worse trade than
1112 /// the defect it exists to fix.
1113 ///
1114 /// Size, not one sample. The gate is a level-`AUTO_Z_CONDITIONAL_RAO_ALPHA`
1115 /// hypothesis test, so "it did not fire on this null sample" is an assertion
1116 /// about a `1 − α` event and a single unlucky draw would make it red for a
1117 /// reason that is not a defect. (It does happen: at `n = 40000` the first
1118 /// seed tried here drew `|Z| = 3.31`, `p = 9.2e-4`, just inside `α = 1e-3`.)
1119 /// What is actually claimed is the escalation RATE over a bank of null
1120 /// replicates, and the bound is derived rather than chosen: with
1121 /// `R = REPLICATES` draws at level `α`, the escalation count is
1122 /// `Binomial(R, α)`, and `MAX_NULL_ESCALATIONS` is the smallest `k` for
1123 /// which `P(Binomial(R, α) > k) < α` — i.e. the smallest bound that makes
1124 /// THIS test's own false-alarm rate no worse than the gate's. At `R = 32`,
1125 /// `α = 1e-3`: `P(X ≥ 1) = 3.2e-2` (too loose), `P(X ≥ 2) = 4.9e-4 < α`, so
1126 /// `k = 1`.
1127 #[test]
1128 fn the_escalation_gate_holds_its_size_on_null_replicates() {
1129 const REPLICATES: usize = 32;
1130 const MAX_NULL_ESCALATIONS: usize = 1;
1131 let n = 4_000;
1132 let mut escalations = Vec::new();
1133 for replicate in 0..REPLICATES {
1134 let (scores, weights, a_block) = null_fixture(n, replicate as u64);
1135 if ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1136 .expect("fit")
1137 .is_some()
1138 {
1139 escalations.push(replicate);
1140 }
1141 }
1142 assert!(
1143 escalations.len() <= MAX_NULL_ESCALATIONS,
1144 "the pair gate escalated on {} of {REPLICATES} null replicates (bound \
1145 {MAX_NULL_ESCALATIONS}): {escalations:?}",
1146 escalations.len()
1147 );
1148 }
1149
1150 /// The same fixture shape as [`in_class_fixture`] with every `a`-varying
1151 /// coefficient set to zero, so `Cov(z₀, z₁ | a)` is constant by
1152 /// construction, seeded per replicate.
1153 fn null_fixture(n: usize, replicate: u64) -> (Array2<f64>, Array1<f64>, Array2<f64>) {
1154 let base = 0x2766_0000_u64 + replicate * 3;
1155 let x = standardized(gaussians(n, base));
1156 let e0 = standardized(gaussians(n, base + 1));
1157 let e1 = standardized(gaussians(n, base + 2));
1158 let mut scores = Array2::<f64>::zeros((n, 2));
1159 let mut a_block = Array2::<f64>::zeros((n, 1));
1160 for row in 0..n {
1161 a_block[[row, 0]] = x[row];
1162 scores[[row, 0]] = e0[row];
1163 scores[[row, 1]] = 0.4 * e0[row] + (0.75_f64).sqrt() * e1[row];
1164 }
1165 (scores, Array1::<f64>::ones(n), a_block)
1166 }
1167
1168 /// `K = 1` has no off-diagonal, and its conditional variance is gam#2768's
1169 /// per-coordinate branch. Escalating here would double-correct it.
1170 #[test]
1171 fn a_single_score_is_out_of_scope() {
1172 let n = 4_000;
1173 let x = standardized(gaussians(n, 0x2766_D4));
1174 let e = standardized(gaussians(n, 0x2766_E5));
1175 let mut scores = Array2::<f64>::zeros((n, 1));
1176 let mut a_block = Array2::<f64>::zeros((n, 1));
1177 for row in 0..n {
1178 a_block[[row, 0]] = x[row];
1179 scores[[row, 0]] = (0.5 * x[row]).exp() * e[row];
1180 }
1181 let weights = Array1::<f64>::ones(n);
1182 assert!(
1183 ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1184 .expect("fit")
1185 .is_none(),
1186 "K = 1 is gam#2768's branch, not this one"
1187 );
1188 }
1189
1190 /// Positive definiteness is a property of the PARAMETERISATION, so it must
1191 /// survive rows the fit never saw — including rows far outside the training
1192 /// hull, where a model on the entries of `Σ` would return `|ρ| > 1`.
1193 #[test]
1194 fn the_factor_is_positive_definite_off_the_training_hull() {
1195 let n = 20_000;
1196 let (scores, weights, a_block) =
1197 in_class_fixture(n, [0.3, 0.9], [(0.5_f64).ln(), -0.8], 0.0);
1198 let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1199 .expect("fit")
1200 .expect("escalates");
1201 for &probe in &[-1.0e6_f64, -50.0, -5.0, 0.0, 5.0, 50.0, 1.0e6] {
1202 let a_row = Array1::from_vec(vec![probe]);
1203 let mut factor = Array2::<f64>::zeros((2, 2));
1204 fitted.factor_into(a_row.view(), &mut factor).expect("L(a)");
1205 assert!(
1206 factor[[0, 1]] == 0.0,
1207 "L(a) must be lower triangular; got {factor:?}"
1208 );
1209 assert!(
1210 factor[[0, 0]] > 0.0 && factor[[1, 1]] > 0.0,
1211 "L(a) must have a strictly positive diagonal at a={probe}: {factor:?}"
1212 );
1213 let sigma = fitted.dense_at(a_row.view()).expect("Σ(a)");
1214 let determinant = sigma[[0, 0]] * sigma[[1, 1]] - sigma[[0, 1]] * sigma[[1, 0]];
1215 assert!(
1216 sigma[[0, 0]] > 0.0 && determinant > 0.0,
1217 "Σ(a={probe}) must be positive definite: {sigma:?} (det {determinant})"
1218 );
1219 let correlation = sigma[[0, 1]] / (sigma[[0, 0]] * sigma[[1, 1]]).sqrt();
1220 assert!(
1221 correlation.abs() < 1.0,
1222 "an admissible Σ cannot have |corr| >= 1; got {correlation} at a={probe}"
1223 );
1224 }
1225 }
1226
1227 /// The materialised per-row stack must be the same object `dense_at`
1228 /// describes, in the `Σ = L Lᵀ` representation whose quadratic forms the row
1229 /// program evaluates as exact sums of squares.
1230 #[test]
1231 fn the_row_stack_agrees_with_the_dense_evaluation() {
1232 let n = 4_000;
1233 let (scores, weights, a_block) =
1234 in_class_fixture(n, [0.2, 0.6], [(0.9_f64).ln(), 0.3], 0.1);
1235 let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1236 .expect("fit")
1237 .expect("escalates");
1238 let stack = fitted.row_covariances(a_block.view()).expect("row stack");
1239 assert_eq!(stack.len(), n);
1240 for &row in &[0usize, 1, 17, n / 2, n - 1] {
1241 let dense = fitted.dense_at(a_block.row(row)).expect("Σ(a)");
1242 let from_stack = stack[row].to_dense();
1243 for left in 0..2 {
1244 for right in 0..2 {
1245 assert!(
1246 (dense[[left, right]] - from_stack[[left, right]]).abs() < 1.0e-12,
1247 "row {row} entry ({left},{right}): {} vs {}",
1248 dense[[left, right]],
1249 from_stack[[left, right]]
1250 );
1251 }
1252 }
1253 // `1ᵀΣ1` is the cached scalar the SHARED log-slope lane consumes; it
1254 // must be the same number the dense matrix implies.
1255 let ones_form: f64 = dense.iter().sum();
1256 assert!(
1257 (stack[row].ones_quadratic_form() - ones_form).abs()
1258 < 1.0e-10 * (1.0 + ones_form.abs()),
1259 "row {row}: cached 1'Σ1 {} vs dense {ones_form}",
1260 stack[row].ones_quadratic_form()
1261 );
1262 }
1263 }
1264
1265 /// Collinear scores are a real and expected input — `MarginalSlopeCovariance`
1266 /// says so in its own admission doc, and admits the exactly-singular pooled
1267 /// matrix they produce. The log parameterisation cannot represent a zero
1268 /// innovation variance, so this is the one place it needs a floor, and the
1269 /// floor must not turn a valid input into a refusal.
1270 ///
1271 /// The fixture makes the pair gate fire on a perfectly collinear pair:
1272 /// `z₁ = z₀` with `Var(z₀|a)` moving, so `Cov(z₀, z₁|a) = Var(z₀|a)` moves
1273 /// too. Before the floor this reached `fit_log_innovation` with an exactly
1274 /// zero residual variance and errored the whole fit.
1275 #[test]
1276 fn collinear_scores_are_admitted_rather_than_refused() {
1277 let n = 20_000;
1278 let x = standardized(gaussians(n, 0x2766_5E));
1279 let e0 = standardized(gaussians(n, 0x2766_6F));
1280 let mut scores = Array2::<f64>::zeros((n, 2));
1281 let mut a_block = Array2::<f64>::zeros((n, 1));
1282 for row in 0..n {
1283 a_block[[row, 0]] = x[row];
1284 let z0 = (0.5 * (0.6 * x[row])).exp() * e0[row];
1285 scores[[row, 0]] = z0;
1286 scores[[row, 1]] = z0;
1287 }
1288 let weights = Array1::<f64>::ones(n);
1289 let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1290 .expect("a collinear score pair must be admitted, not refused")
1291 .expect("a moving Var(z₀|a) makes Cov(z₀,z₁|a) move, so the pair gate fires");
1292 for &probe in &[-1.5_f64, 0.0, 1.5] {
1293 let a_row = Array1::from_vec(vec![probe]);
1294 let sigma = fitted.dense_at(a_row.view()).expect("Σ(a)");
1295 let determinant = sigma[[0, 0]] * sigma[[1, 1]] - sigma[[0, 1]] * sigma[[1, 0]];
1296 assert!(
1297 sigma[[0, 0]] > 0.0 && determinant > 0.0,
1298 "Σ(a={probe}) must stay positive definite on a collinear pair: {sigma:?}"
1299 );
1300 let correlation = sigma[[0, 1]] / (sigma[[0, 0]] * sigma[[1, 1]]).sqrt();
1301 assert!(
1302 (correlation - 1.0).abs() < 1.0e-6,
1303 "a collinear pair must read as correlation 1; got {correlation} at a={probe}"
1304 );
1305 // And the variance itself must track the planted `exp(0.6·x)`.
1306 let planted = (0.6 * probe).exp();
1307 assert!(
1308 (sigma[[0, 0]] - planted).abs() < 0.1 * (1.0 + planted),
1309 "Σ₀₀(a={probe}) = {} against planted {planted}",
1310 sigma[[0, 0]]
1311 );
1312 }
1313 }
1314
1315 /// Three scores, so the triangular reconstruction is exercised past the
1316 /// `K = 2` special case where `T⁻¹` has no accumulated term.
1317 #[test]
1318 fn three_scores_reconstruct_through_the_forward_substitution() {
1319 let n = 30_000;
1320 let x = standardized(gaussians(n, 0x2766_1A));
1321 let e0 = standardized(gaussians(n, 0x2766_2B));
1322 let e1 = standardized(gaussians(n, 0x2766_3C));
1323 let e2 = standardized(gaussians(n, 0x2766_4D));
1324 let mut scores = Array2::<f64>::zeros((n, 3));
1325 let mut a_block = Array2::<f64>::zeros((n, 1));
1326 // φ₁₀(x) = 0.4 + 0.3x, φ₂₀ = 0.2 (constant), φ₂₁(x) = −0.1 + 0.45x.
1327 for row in 0..n {
1328 let xi = x[row];
1329 a_block[[row, 0]] = xi;
1330 let z0 = e0[row];
1331 let z1 = (0.4 + 0.3 * xi) * z0 + 0.8 * e1[row];
1332 let z2 = 0.2 * z0 + (-0.1 + 0.45 * xi) * z1 + 0.7 * e2[row];
1333 scores[[row, 0]] = z0;
1334 scores[[row, 1]] = z1;
1335 scores[[row, 2]] = z2;
1336 }
1337 let weights = Array1::<f64>::ones(n);
1338 let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1339 .expect("fit")
1340 .expect("escalates");
1341 assert_eq!(fitted.coordinates.len(), 3);
1342 assert_eq!(fitted.coordinates[2].autoregression.len(), 2);
1343 for &probe in &[-1.0_f64, 0.0, 1.0] {
1344 let a_row = Array1::from_vec(vec![probe]);
1345 let sigma = fitted.dense_at(a_row.view()).expect("Σ(a)");
1346 // Closed-form truth by the same forward recursion.
1347 let phi10 = 0.4 + 0.3 * probe;
1348 let phi20 = 0.2_f64;
1349 let phi21 = -0.1 + 0.45 * probe;
1350 let (d0, d1, d2) = (1.0_f64, 0.64_f64, 0.49_f64);
1351 let s00 = d0;
1352 let s01 = phi10 * d0;
1353 let s11 = phi10 * phi10 * d0 + d1;
1354 let s02 = phi20 * d0 + phi21 * s01;
1355 let s12 = phi20 * s01 + phi21 * s11;
1356 let s22 = phi20 * s02 + phi21 * s12 + d2;
1357 let truth = [s00, s01, s02, s11, s12, s22];
1358 let got = [
1359 sigma[[0, 0]],
1360 sigma[[0, 1]],
1361 sigma[[0, 2]],
1362 sigma[[1, 1]],
1363 sigma[[1, 2]],
1364 sigma[[2, 2]],
1365 ];
1366 for (index, (&want, &have)) in truth.iter().zip(got.iter()).enumerate() {
1367 assert!(
1368 (want - have).abs() < 0.06 * (1.0 + want.abs()),
1369 "K=3 Σ(a={probe}) entry {index}: got {have}, want {want}"
1370 );
1371 }
1372 }
1373 }
1374
1375 /// An INDEPENDENT oracle: the fitted `Σ(a)` against a nonparametric local
1376 /// estimate.
1377 ///
1378 /// Every other recovery test here compares the fit to the coefficients it
1379 /// was generated from, which is a comparison inside the model's own
1380 /// parameterisation. This one is not: it bins the rows by the conditioning
1381 /// covariate, takes the ordinary empirical second moments inside each bin,
1382 /// and asks the fitted surface to reproduce them. It would catch a
1383 /// parameterisation that recovers its own planted coefficients and still
1384 /// assembles the wrong matrix — a forward-substitution transposition, a
1385 /// column scaled by `d` instead of `√d`, a coordinate order swapped.
1386 ///
1387 /// The truth is IN the model class here, deliberately: the bar is each bin's
1388 /// own sampling band, so any approximation error would be read as a defect.
1389 /// How the model behaves when the truth is out of class is a different
1390 /// question and is measured where it belongs — on the end-to-end identity,
1391 /// in `survival_multi_z_conditional_covariance_2766`, against a planted
1392 /// sigmoid.
1393 #[test]
1394 fn the_fitted_surface_matches_a_nonparametric_local_covariance() {
1395 const BINS: usize = 8;
1396 let n = 40_000;
1397 let phi = [0.15_f64, 0.42];
1398 let gamma = [(0.7_f64).ln(), -0.3];
1399 let (scores, weights, a_block) = in_class_fixture(n, phi, gamma, 0.0);
1400 let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1401 .expect("fit")
1402 .expect("escalates");
1403
1404 // Bin on the conditioning covariate's own rank, so every bin holds the
1405 // same number of rows and the band below is the same in each.
1406 let mut order: Vec<usize> = (0..n).collect();
1407 order.sort_by(|&left, &right| {
1408 a_block[[left, 0]]
1409 .partial_cmp(&a_block[[right, 0]])
1410 .expect("the fixture covariate is finite, so the ordering is total")
1411 });
1412 let mut bin = vec![0usize; n];
1413 for (rank, &row) in order.iter().enumerate() {
1414 bin[row] = (rank * BINS / n).min(BINS - 1);
1415 }
1416
1417 let mut counts = vec![0usize; BINS];
1418 let mut empirical = vec![[0.0_f64; 3]; BINS];
1419 let mut modelled = vec![[0.0_f64; 3]; BINS];
1420 for row in 0..n {
1421 let index = bin[row];
1422 counts[index] += 1;
1423 let (left, right) = (scores[[row, 0]], scores[[row, 1]]);
1424 empirical[index][0] += left * left;
1425 empirical[index][1] += left * right;
1426 empirical[index][2] += right * right;
1427 let sigma = fitted.dense_at(a_block.row(row)).expect("Σ(a)");
1428 modelled[index][0] += sigma[[0, 0]];
1429 modelled[index][1] += sigma[[0, 1]];
1430 modelled[index][2] += sigma[[1, 1]];
1431 }
1432 let mut worst = 0.0_f64;
1433 let mut worst_label = String::new();
1434 for index in 0..BINS {
1435 let scale = counts[index] as f64;
1436 // A second moment of Gaussians has sampling standard error
1437 // `moment·√(2/n_bin)`; three of those is the band a correct surface
1438 // sits inside, and it is derived from the bin rather than chosen.
1439 for entry in 0..3 {
1440 let want = empirical[index][entry] / scale;
1441 let have = modelled[index][entry] / scale;
1442 let reference = (empirical[index][0] / scale).max(empirical[index][2] / scale);
1443 let band = 3.0 * reference * (2.0 / scale).sqrt();
1444 let miss = (want - have).abs() / band;
1445 if miss > worst {
1446 worst = miss;
1447 worst_label = format!(
1448 "bin {index} (n={}) entry {entry}: empirical {want:.4} against fitted \
1449 {have:.4}, band {band:.4}",
1450 counts[index]
1451 );
1452 }
1453 }
1454 }
1455 assert!(
1456 worst <= 1.0,
1457 "the fitted surface must sit inside each bin's own sampling band; worst {worst:.2}x \
1458 at {worst_label}"
1459 );
1460 }
1461
1462 /// A fitted field has to survive the on-disk round trip unchanged: the
1463 /// predict path rebuilds `Σ(a)` from exactly these coefficients, so a
1464 /// serialisation that dropped a stage would silently evaluate a different
1465 /// model.
1466 #[test]
1467 fn the_fitted_field_round_trips_through_serde() {
1468 let n = 8_000;
1469 let (scores, weights, a_block) =
1470 in_class_fixture(n, [0.25, 0.55], [(0.8_f64).ln(), -0.35], 0.0);
1471 let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1472 .expect("fit")
1473 .expect("escalates");
1474 let encoded = serde_json::to_string(&fitted).expect("encode");
1475 let decoded: ConditionalScoreCovariance = serde_json::from_str(&encoded).expect("decode");
1476 assert_eq!(decoded, fitted);
1477 let a_row = Array1::from_vec(vec![0.37]);
1478 let before = fitted.dense_at(a_row.view()).expect("Σ before");
1479 let after = decoded.dense_at(a_row.view()).expect("Σ after");
1480 assert_eq!(before, after);
1481 }
1482
1483 /// Against the object it replaces: averaged over the training rows, the
1484 /// conditional field must reproduce the pooled covariance. `Σ̄ = E[Σ(a)] +
1485 /// Var(E[z|a])`, and the conditional mean is zero here, so the two agree —
1486 /// which is the sense in which this is a refinement of the pooled estimator
1487 /// and not a different quantity.
1488 #[test]
1489 fn the_row_average_reproduces_the_pooled_covariance() {
1490 let n = 40_000;
1491 let (scores, weights, a_block) =
1492 in_class_fixture(n, [0.3, 0.5], [(0.75_f64).ln(), -0.4], 0.0);
1493 let fitted = ConditionalScoreCovariance::fit(scores.view(), weights.view(), a_block.view())
1494 .expect("fit")
1495 .expect("escalates");
1496 let pooled = super::super::marginal_slope_covariance_from_scores(scores.view(), &weights)
1497 .expect("pooled Σ")
1498 .to_dense();
1499 let stack = fitted.row_covariances(a_block.view()).expect("stack");
1500 let mut average = Array2::<f64>::zeros((2, 2));
1501 for covariance in &stack {
1502 average += &covariance.to_dense();
1503 }
1504 average /= n as f64;
1505 for left in 0..2 {
1506 for right in 0..2 {
1507 let want = pooled[[left, right]];
1508 let have = average[[left, right]];
1509 assert!(
1510 (want - have).abs() < 0.05 * (1.0 + want.abs()),
1511 "row-averaged Σ({left},{right}) = {have} against pooled {want}"
1512 );
1513 }
1514 }
1515 }
1516}