gam_models/marginal_slope_orthogonal.rs
1//! Neyman-orthogonal, cross-fitted marginal-slope calibration (closes #461):
2//! the Stage-1 score-influence Jacobian and the absorbed influence block.
3//!
4//! Stage 1 (a conditional transformation-normal, "CTN", model) fits a monotone
5//! `h(y | x; θ₁)` and emits a latent score `z_i = Φ⁻¹(u_i)` from the
6//! finite-support PIT
7//!
8//! u_i = [Φ(h_i) − Φ(L_i)] / [Φ(U_i) − Φ(L_i)],
9//!
10//! with (SCOP form, see `transformation_normal.rs`)
11//!
12//! h_i = b(x_i) + ε·(y_i − median) + Σ_{k≥1} I_k(y_i)·γ_k(x_i)²
13//! L_i = h(y_min | x_i), U_i = h(y_max | x_i)
14//! b(x_i) = Xᶜᵒᵛ_i · θ_b (location column, response basis col 0)
15//! γ_k(x_i) = Xᶜᵒᵛ_i · θ_{γk} (squared SCOP shape coeffs, cols k≥1).
16//!
17//! Stage 2 (marginal-slope) treats `z_i` as a **generated regressor**:
18//! `z_i` depends on the Stage-1 estimate θ̂₁, so the β estimating equation is
19//! not orthogonal to θ₁. This module exposes the score-influence Jacobian
20//! `J = ∂z/∂θ₁` (design §2) and the absorbed influence block
21//! `Z_infl = diag(s_f·β̂₀)·J` (design §3); Stage 2 appends `Z_infl` as a
22//! null-penalized absorbed block, making the β estimating equation orthogonal
23//! to `span(Z_infl)` — the discrete realization of `ψ − Π_η[ψ]`.
24//!
25//! ## Column ordering of `J`
26//!
27//! `θ₁` is the Stage-1 coefficient vector of length `p₁ = p_resp · p_cov`,
28//! reshaped row-major to the `(p_resp, p_cov)` matrix `Γ` (`beta_mat`):
29//! response component `k` (row) crossed with covariate column `j` (col). The
30//! flat index of `Γ[k, j]` is `k·p_cov + j`. **`J`'s columns follow exactly
31//! this order**: column `k·p_cov + j` holds `∂z_i/∂Γ[k, j]`. Response row
32//! `k = 0` is the unconstrained location block `b(x)`; rows `k ≥ 1` are the
33//! squared SCOP shape blocks for `γ_k(x)`.
34
35use gam_linalg::faer_ndarray::{
36 FaerArrayView, factorize_symmetricwith_fallback, fast_ab, fast_abt, fast_xt_diag_x,
37 fast_xt_diag_y,
38};
39use crate::transformation_normal::{
40 TRANSFORMATION_MONOTONICITY_EPS, TransformationNormalFitResult, transformation_normal_pit_score,
41};
42use crate::inference::model::TRANSFORMATION_SCORE_PIT_CLIP_EPS;
43use gam_linalg::matrix::FactorizedSystem;
44use crate::probability::{
45 log1mexp_positive, normal_cdf, normal_logcdf, normal_pdf, standard_normal_quantile,
46};
47use gam_terms::smooth::build_term_collection_design;
48use faer::Side;
49use ndarray::{Array1, Array2, ArrayView2};
50
51/// Fixed (NOT REML-learned) log-λ for the influence-absorber block's ridge.
52///
53/// The §3 absorbed block `+Z_infl·γ` carries a small fixed ridge `½·ρ·‖γ‖²`
54/// (`ρ = exp(log_λ)`) so the joint solve soaks the `span(Z_infl)` component of
55/// the η-residual into `γ` without that block being treated as a smooth/REML
56/// surface. `log_λ = 0` ⇒ `ρ = 1`: enough to keep `γ` finite and bounded while
57/// the much larger marginal/logslope likelihood curvature dominates the fit.
58pub(crate) const INFLUENCE_ABSORBER_FIXED_LOG_LAMBDA: f64 = 0.0;
59
60/// Per-row, per-θ₁ score-influence Jacobian `∂z/∂θ₁` for a fitted CTN, plus the
61/// latent score `z` itself on the same rows.
62///
63/// `columns` is `n × p₁` with `p₁ = p_resp · p_cov`; see the module-level
64/// "Column ordering of `J`" note for the layout of the second axis. Computing
65/// `J` already evaluates `h/L/U` and the finite-support PIT, so `z = Φ⁻¹(PIT)`
66/// comes for free — exposing it here is the single source of truth for the
67/// cross-fit fold loop, which needs the out-of-fold `z` alongside `J` and must
68/// not re-run the PIT path to get it.
69pub struct ScoreInfluenceJacobian {
70 /// `n × p₁` matrix of `∂z_i/∂θ₁`.
71 pub columns: Array2<f64>,
72 /// `n` latent scores `z_i = Φ⁻¹(PIT_i)` at the same rows `J` was evaluated.
73 pub z: Array1<f64>,
74}
75
76/// Compute `J = ∂z/∂θ₁` from a fitted CTN at the given `(x, y)` rows.
77///
78/// `response` (length `n`) and `covariate_data` (`n × d`) are the rows at which
79/// to evaluate the Jacobian; they need not be the Stage-1 training rows (for
80/// cross-fitting they are the held-out fold). The fitted CTN supplies the
81/// coefficient vector θ̂₁, the response basis (re-evaluated at these `y` via the
82/// fitted knots), and the resolved covariate spec (re-built at these `x`).
83///
84/// `offset` (length `n`) is the per-row additive transformation linear-predictor
85/// offset on these rows. The CTN row build adds this offset identically to
86/// `h`, `L`, and `U` (`row_quantities`: `h_acc = … + offset_i`, and likewise for
87/// the lower/upper endpoints), so the finite-support PIT — and hence the latent
88/// score `z` reported here — depends on it. The Jacobian itself (`∂z/∂θ₁`) does
89/// NOT depend on the offset (it is θ₁-independent), but the *operating point*
90/// at which the φ/Φ ratios are evaluated does; omitting a non-zero offset would
91/// place `h/L/U` (and the emitted `z`) at the wrong point. For an offset-free
92/// Stage-1 (`offset ≡ 0`) this is a no-op. Pass the held-out fold's offset rows.
93///
94/// Implements design §2:
95///
96/// ```text
97/// ∂z_i/∂θ₁ = (1/φ(z_i)) · ∂u_i/∂θ₁
98/// ∂u_i/∂θ₁ = [ φ(h_i)·∂h_i/∂θ₁
99/// − u_i·(φ(U_i)·∂U_i/∂θ₁ − φ(L_i)·∂L_i/∂θ₁)
100/// − φ(L_i)·∂L_i/∂θ₁ ] / (Φ(U_i) − Φ(L_i))
101/// ∂h_i/∂Γ[0,j] = Xᶜᵒᵛ_{i,j}
102/// ∂h_i/∂Γ[k,j] = 2·I_k(y_i)·γ_k(x_i)·Xᶜᵒᵛ_{i,j} (k ≥ 1)
103/// ```
104///
105/// with `∂L_i`, `∂U_i` analogous (the response basis `I_k` evaluated at the
106/// lower/upper support endpoints). Non-finite rows, support-order violations,
107/// and an under-resolvable endpoint mass return `Err` with row context.
108pub fn score_influence_jacobian(
109 fit: &TransformationNormalFitResult,
110 response: &Array1<f64>,
111 covariate_data: ArrayView2<f64>,
112 offset: &Array1<f64>,
113) -> Result<ScoreInfluenceJacobian, String> {
114 let family = &fit.family;
115 let n = response.len();
116 if covariate_data.nrows() != n {
117 return Err(format!(
118 "score_influence_jacobian: covariate rows ({}) != response rows ({n})",
119 covariate_data.nrows()
120 ));
121 }
122 if offset.len() != n {
123 return Err(format!(
124 "score_influence_jacobian: offset rows ({}) != response rows ({n})",
125 offset.len()
126 ));
127 }
128 if n == 0 {
129 return Err("score_influence_jacobian: empty input rows".to_string());
130 }
131
132 let p_resp = family.p_resp();
133 let p_cov = family.p_cov();
134 let p1 = p_resp.checked_mul(p_cov).ok_or_else(|| {
135 format!("score_influence_jacobian: p_resp({p_resp}) * p_cov({p_cov}) overflowed")
136 })?;
137
138 let beta = &fit
139 .fit
140 .block_states
141 .first()
142 .ok_or_else(|| "score_influence_jacobian: fitted CTN has no block states".to_string())?
143 .beta;
144 if beta.len() != p1 {
145 return Err(format!(
146 "score_influence_jacobian: beta length {} != p_resp({p_resp}) * p_cov({p_cov})",
147 beta.len()
148 ));
149 }
150 // θ₁ reshaped row-major to Γ (p_resp × p_cov): row k = response component k,
151 // col j = covariate column j. Matches the CTN fit reshape exactly.
152 let beta_mat = beta
153 .view()
154 .into_shape_with_order((p_resp, p_cov))
155 .map_err(|e| format!("score_influence_jacobian: beta reshape failed: {e}"))?;
156
157 // Response value basis [1, I_1(y), …, I_K(y)] at the fitted knots.
158 let resp_val = family.evaluate_response_value_basis(response.view())?;
159 if resp_val.nrows() != n || resp_val.ncols() != p_resp {
160 return Err(format!(
161 "score_influence_jacobian: response basis shape {}x{} != {n}x{p_resp}",
162 resp_val.nrows(),
163 resp_val.ncols()
164 ));
165 }
166
167 // Covariate design at these rows, rebuilt from the fitted resolved spec so
168 // the column geometry matches Stage-1. Materialize dense once.
169 let cov_design = build_term_collection_design(covariate_data, &fit.covariate_spec_resolved)
170 .map_err(|e| format!("score_influence_jacobian: covariate design build failed: {e}"))?;
171 if cov_design.design.ncols() != p_cov {
172 return Err(format!(
173 "score_influence_jacobian: rebuilt covariate design has {} columns, fitted p_cov is {p_cov}",
174 cov_design.design.ncols()
175 ));
176 }
177 let x_cov = cov_design.design.try_row_chunk(0..n).map_err(|e| {
178 format!("score_influence_jacobian: covariate design materialization failed: {e}")
179 })?;
180
181 // γ_k(x_i) = Σ_j Xᶜᵒᵛ_{i,j}·Γ[k,j] ⇒ gamma = Xᶜᵒᵛ · Γᵀ (n × p_resp).
182 let gamma = fast_abt(&x_cov, &beta_mat);
183 if gamma.nrows() != n || gamma.ncols() != p_resp {
184 return Err(format!(
185 "score_influence_jacobian: gamma shape {}x{} != {n}x{p_resp}",
186 gamma.nrows(),
187 gamma.ncols()
188 ));
189 }
190
191 // Row-independent endpoint response bases and floor offsets (fitted).
192 let lower_basis = family.response_lower_basis();
193 let upper_basis = family.response_upper_basis();
194 let lower_floor = family.response_lower_floor_offset();
195 let upper_floor = family.response_upper_floor_offset();
196 let median = family.response_median();
197
198 // Floor on φ(z): at the PIT clip the score saturates at a fixed extreme
199 // quantile, so φ(z) is bounded below by φ(Φ⁻¹(clip_eps)). Clamping the
200 // ∂z = ∂u/φ(z) denominator there keeps a saturated row's influence bounded
201 // rather than infinite — the same bound the PIT clip already imposes on z.
202 let pdf_z_floor = normal_pdf(
203 standard_normal_quantile(TRANSFORMATION_SCORE_PIT_CLIP_EPS)
204 .map_err(|e| format!("score_influence_jacobian: clip quantile failed: {e}"))?,
205 );
206
207 let mut columns = Array2::<f64>::zeros((n, p1));
208 let mut z_scores = Array1::<f64>::zeros(n);
209
210 for i in 0..n {
211 let gamma_row = gamma.row(i);
212 let val_row = resp_val.row(i);
213 let x_row = x_cov.row(i);
214 let g0 = gamma_row[0];
215
216 // h, L, U exactly as the CTN row-quantity build assembles them
217 // (`row_quantities`): the additive linear-predictor offset enters h, L,
218 // and U identically, so it is added here at all three to place the PIT
219 // operating point (and the emitted z) where the fitted model evaluates
220 // it. The per-row monotonicity floor ε·(y − median) and the endpoint
221 // floors are recomputed from the fitted median.
222 let offset_i = offset[i];
223 let value_floor = TRANSFORMATION_MONOTONICITY_EPS * (response[i] - median);
224 let mut h = val_row[0] * g0 + offset_i + value_floor;
225 let mut l = lower_basis[0] * g0 + offset_i + lower_floor;
226 let mut u = upper_basis[0] * g0 + offset_i + upper_floor;
227 for k in 1..p_resp {
228 let gk = gamma_row[k];
229 let gk_sq = gk * gk;
230 h += val_row[k] * gk_sq;
231 l += lower_basis[k] * gk_sq;
232 u += upper_basis[k] * gk_sq;
233 }
234
235 if !(h.is_finite() && l.is_finite() && u.is_finite()) {
236 return Err(format!(
237 "score_influence_jacobian: non-finite transform geometry at row {i}: h={h}, L={l}, U={u}"
238 ));
239 }
240 if u <= l {
241 return Err(format!(
242 "score_influence_jacobian: support order violated at row {i}: L={l:.6e} >= U={u:.6e}"
243 ));
244 }
245
246 // z is the finite-support PIT score, computed by the SAME canonical
247 // kernel the normal Stage-2 path consumes (`calibrate_transformation_scores`
248 // → `transformation_normal_pit_score`). That kernel forms the PIT ratio in
249 // LOG space (`normal_logcdf` + `log1mexp_positive`), which stays accurate
250 // when h/L/U all sit deep in a tail where the direct CDF subtraction
251 // `(Φ(h)−Φ(L))/(Φ(U)−Φ(L))` would cancel catastrophically. Reusing it makes
252 // z bit-identical to Stage-2's z — single source of truth.
253 let z = transformation_normal_pit_score(h, l, u, TRANSFORMATION_SCORE_PIT_CLIP_EPS)
254 .map_err(|e| format!("score_influence_jacobian: PIT score failed at row {i}: {e}"))?;
255 z_scores[i] = z;
256
257 // The ∂u/∂θ chain is evaluated at the SAME (clipped) operating point z
258 // represents: u_pit = Φ(z) exactly inverts z = Φ⁻¹(u_clamped), so the
259 // derivative coefficient and the reported score stay self-consistent
260 // without recomputing the (less stable) direct ratio. The endpoint
261 // φ/D ratios below are the analytic derivatives of that ratio.
262 //
263 // Compute log(D) = log(Φ(U)−Φ(L)) in log-space to avoid catastrophic
264 // cancellation when L and U both sit deep in the same tail (e.g. L=5,
265 // U=6 where normal_cdf returns 1.0 for both in direct form). This
266 // mirrors the `log_normal_cdf_diff` approach in `transformation_normal.rs`:
267 //
268 // If L > 0 : Φ(U)−Φ(L) = Φ(−L)−Φ(−U) (reflection, both < 0.5)
269 // log_denom = normal_logcdf(−L) + log1mexp(normal_logcdf(−L) − normal_logcdf(−U))
270 // Otherwise: log_denom = normal_logcdf(U) + log1mexp(normal_logcdf(U) − normal_logcdf(L))
271 let log_denom = if l > 0.0 {
272 let log_neg_l = normal_logcdf(-l);
273 let log_neg_u = normal_logcdf(-u);
274 let gap = log_neg_l - log_neg_u;
275 if !(gap.is_finite() && gap > 0.0) {
276 return Err(format!(
277 "score_influence_jacobian: endpoint mass not resolvable at row {i}: l={l:.6e}, u={u:.6e}"
278 ));
279 }
280 log_neg_l + log1mexp_positive(gap)
281 } else {
282 let log_cu = normal_logcdf(u);
283 let log_cl = normal_logcdf(l);
284 let gap = log_cu - log_cl;
285 if !(gap.is_finite() && gap > 0.0) {
286 return Err(format!(
287 "score_influence_jacobian: endpoint mass not resolvable at row {i}: l={l:.6e}, u={u:.6e}"
288 ));
289 }
290 log_cu + log1mexp_positive(gap)
291 };
292 if !log_denom.is_finite() {
293 return Err(format!(
294 "score_influence_jacobian: log endpoint mass not finite at row {i}: l={l:.6e}, u={u:.6e}"
295 ));
296 }
297
298 let u_pit = normal_cdf(z);
299
300 // φ(x)/D = exp(log φ(x) − log D). Using log-space for these ratios
301 // keeps them accurate when D is tiny (deep-tail support intervals).
302 // log φ(x) = −½x² − log(√(2π)).
303 const LOG_SQRT_2PI: f64 = 0.918_938_533_204_672_7;
304 let log_phi = |x: f64| -0.5 * x * x - LOG_SQRT_2PI;
305
306 // φ at h/L/U. The chain ∂u/∂θ uses φ at the *unclamped* h when
307 // h is inside [L,U]; at the boundary (h clamped) φ(h)·∂h is the limiting
308 // contribution and the clamp keeps it finite.
309 let h_clamped = h.clamp(l, u);
310 let c_h = (log_phi(h_clamped) - log_denom).exp();
311 let c_l = (log_phi(l) - log_denom).exp();
312 let c_u = (log_phi(u) - log_denom).exp();
313
314 // ∂z = ∂u / φ(z). Where the score saturates (|z| large), φ(z) underflows;
315 // the `pdf_z_floor` clamp keeps a saturated row's influence bounded.
316 let pdf_z = normal_pdf(z).max(pdf_z_floor);
317
318 let mut row = columns.row_mut(i);
319 for k in 0..p_resp {
320 // ∂h/∂Γ[k,j], ∂L/∂Γ[k,j], ∂U/∂Γ[k,j] share the factor Xᶜᵒᵛ_{i,j};
321 // the response-side scalar differs between the location block
322 // (k = 0, basis value, unsquared) and the shape blocks
323 // (k ≥ 1, 2·basis·γ_k).
324 let (dh_scalar, dl_scalar, du_scalar) = if k == 0 {
325 (val_row[0], lower_basis[0], upper_basis[0])
326 } else {
327 let two_gk = 2.0 * gamma_row[k];
328 (
329 val_row[k] * two_gk,
330 lower_basis[k] * two_gk,
331 upper_basis[k] * two_gk,
332 )
333 };
334 let base = k * p_cov;
335 for j in 0..p_cov {
336 let xij = x_row[j];
337 let dh = dh_scalar * xij;
338 let dl = dl_scalar * xij;
339 let du = du_scalar * xij;
340 // ∂u = [φ(h)∂h − u·(φ(U)∂U − φ(L)∂L) − φ(L)∂L] / (Φ(U)−Φ(L))
341 // = c_h·∂h − u_pit·(c_u·∂U − c_l·∂L) − c_l·∂L
342 let du_pit = c_h * dh - u_pit * (c_u * du - c_l * dl) - c_l * dl;
343 row[base + j] = du_pit / pdf_z;
344 }
345 }
346 }
347
348 if columns.iter().any(|v| !v.is_finite()) {
349 return Err("score_influence_jacobian: produced non-finite Jacobian entries".to_string());
350 }
351 if z_scores.iter().any(|v| !v.is_finite()) {
352 return Err("score_influence_jacobian: produced non-finite z scores".to_string());
353 }
354
355 Ok(ScoreInfluenceJacobian {
356 columns,
357 z: z_scores,
358 })
359}
360
361/// Build the absorbed influence block `Z_infl = diag(s_f·β̂₀)·J` for Stage 2
362/// (design §3): row-scale each Jacobian row `i` by `s_f · pilot_beta0[i]`,
363/// where `pilot_beta0` is the rigid-pilot logslope `β̂₀(x_i)` (length `n`).
364///
365/// The returned `n × p₁` matrix spans the realized η-space leakage directions
366/// at the rigid pilot. Stage 2 appends it as a **plain additive** absorbed
367/// parameter block `+Z_infl·γ` carrying a fixed small ridge `½·ρ·‖γ‖²` (γ is a
368/// training-time leakage absorber, not a smooth/REML-learned block). This is
369/// NOT routed through the multiplicative `score_warp` / `DeviationRuntime`
370/// path — that path evaluates a scalar 1-D cubic in η and cannot carry the
371/// arbitrary x-dependent `n × p₁` matrix. The absorber is orthogonalized
372/// against the marginal block but deliberately overlaps logslope, with gauge
373/// priority above logslope, and is dropped at predict time.
374pub fn influence_block_design(
375 jac: &ScoreInfluenceJacobian,
376 pilot_beta0: &Array1<f64>,
377 s_f: f64,
378) -> Array2<f64> {
379 let n = jac.columns.nrows();
380 assert_eq!(
381 pilot_beta0.len(),
382 n,
383 "influence_block_design: pilot_beta0 length must equal Jacobian rows"
384 );
385 let mut out = jac.columns.clone();
386 for (i, mut row) in out.axis_iter_mut(ndarray::Axis(0)).enumerate() {
387 let scale = s_f * pilot_beta0[i];
388 row.mapv_inplace(|v| v * scale);
389 }
390 out
391}
392
393/// Residualize the influence columns `Z_infl` against the **marginal** design
394/// span in the rigid-pilot row metric `W`, retaining the logslope overlap
395/// (#461, design §3 — single source of truth for the BMS and survival absorbed
396/// blocks):
397///
398/// Z̃ = Z − M·(MᵀWM + εI)⁻¹·MᵀW·Z.
399///
400/// Residualizing against **marginal only** deliberately keeps the
401/// logslope-aligned component, so the absorber soaks the leakage direction that
402/// would otherwise manufacture spurious `β(x)` heterogeneity. `W` is the PIRLS
403/// row inner product at the rigid pilot, so the resulting orthogonality
404/// `MᵀW Z̃ ≈ 0` holds in the same metric the penalized joint solve sees, not
405/// merely in the Euclidean sense. `eps` is the (caller-scaled) ridge added to
406/// the weighted marginal Gram diagonal so the projection solve stays stable when
407/// the marginal design is rank-deficient at the pilot.
408///
409/// `z_infl` must already be the `influence_block_design` output (`n × p₁`).
410/// When the marginal design has zero columns the raw `z_infl` is returned (no
411/// span to project out).
412pub(crate) fn residualize_influence_columns(
413 z_infl: &Array2<f64>,
414 marginal_design: ArrayView2<f64>,
415 w_metric: &Array1<f64>,
416 eps: f64,
417) -> Array2<f64> {
418 let n = marginal_design.nrows();
419 assert_eq!(
420 z_infl.nrows(),
421 n,
422 "residualize_influence_columns: Z_infl rows must equal marginal design rows"
423 );
424 assert_eq!(
425 w_metric.len(),
426 n,
427 "residualize_influence_columns: row metric length must equal marginal design rows"
428 );
429 let p_m = marginal_design.ncols();
430 if p_m == 0 {
431 // No marginal span to residualize against; the raw directions are the
432 // absorbed columns.
433 return z_infl.clone();
434 }
435 // Weighted Gram MᵀWM and cross term MᵀW Z in the pilot row metric.
436 let mut gram = fast_xt_diag_x(&marginal_design, w_metric);
437 for i in 0..p_m {
438 gram[[i, i]] += eps;
439 }
440 let cross = fast_xt_diag_y(&marginal_design, w_metric, z_infl);
441 let gram_view = FaerArrayView::new(&gram);
442 let factor = factorize_symmetricwith_fallback(gram_view.as_ref(), Side::Lower)
443 .expect("residualize_influence_columns: weighted marginal Gram factorization failed");
444 // coeffs = (MᵀWM + εI)⁻¹ MᵀW Z (p_m × p₁)
445 let coeffs = factor
446 .solvemulti(&cross)
447 .expect("residualize_influence_columns: marginal projection solve failed");
448 // Z̃ = Z − M·coeffs.
449 let projection = fast_ab(&marginal_design, &coeffs);
450 z_infl - &projection
451}
452
453/// Relative magnitude (vs. the largest weighted marginal-Gram diagonal) of the
454/// ridge added to `MᵀWM` in the §3 projection solve. Tiny — it only regularizes
455/// a rank-deficient marginal design at the pilot (a dropped/aliased spatial
456/// column leaves a zero pivot) and never perturbs a well-conditioned projection.
457pub(crate) const INFLUENCE_PROJECTION_RELATIVE_RIDGE: f64 = 1.0e-10;
458/// Absolute floor on the §3 projection ridge, so a degenerate (all-zero)
459/// weighted marginal Gram still yields an invertible system.
460pub(crate) const INFLUENCE_PROJECTION_RIDGE_FLOOR: f64 = 1.0e-12;
461
462/// The full §3 absorbed-block projection from the raw score-influence Jacobian —
463/// the single shared entry point for both families.
464///
465/// Performs the entire sequence, so neither BMS (widened marginal design) nor
466/// survival (dedicated η₁ channel) inlines any of it and both get byte-identical
467/// numerics:
468///
469/// 1. build the realized leakage directions `Z_infl = diag(s_f·β̂₀)·J`
470/// ([`influence_block_design`]),
471/// 2. derive the projection ridge from the weighted marginal Gram's own
472/// magnitude — `eps = max(diag(MᵀWM))·INFLUENCE_PROJECTION_RELATIVE_RIDGE`
473/// floored at `INFLUENCE_PROJECTION_RIDGE_FLOOR` — so it scales with the
474/// design rather than being a fixed absolute the caller must guess,
475/// 3. residualize against the marginal/primary span in the rigid-pilot
476/// `W`-metric ([`residualize_influence_columns`]):
477/// `Z̃ = Z_infl − M·(MᵀWM + εI)⁻¹·MᵀW·Z_infl`.
478///
479/// Returns `Err` if the residualized columns are not all finite (e.g. a
480/// non-finite pilot logslope or row metric propagated through) — the finite
481/// guard is baked in so neither call site repeats it. The two families differ
482/// ONLY in how they install the returned `Z̃` (BMS widens `[M | Z̃]`; survival
483/// adds a dedicated additive η₁ channel), never in this math.
484///
485/// `raw_jac` is the bare `n × p₁` score-influence Jacobian (`∂z/∂θ₁`) — i.e.
486/// the value carried by the spec's `score_influence_jacobian` field — and
487/// `oof_z` is the matching out-of-fold latent score; callers hold these two
488/// arrays directly, so this entry point pairs them into a `ScoreInfluenceJacobian`
489/// internally rather than asking every site to construct one.
490pub(crate) fn residualized_influence_block(
491 raw_jac: &Array2<f64>,
492 oof_z: &Array1<f64>,
493 pilot_beta0: &Array1<f64>,
494 s_f: f64,
495 marginal_design: ArrayView2<f64>,
496 w_metric: &Array1<f64>,
497) -> Result<Array2<f64>, String> {
498 let jac = ScoreInfluenceJacobian {
499 columns: raw_jac.clone(),
500 z: oof_z.clone(),
501 };
502 let z_infl = influence_block_design(&jac, pilot_beta0, s_f);
503
504 // Ridge scaled to the weighted marginal Gram's own magnitude. Only the
505 // diagonal is needed to size it, so reuse the same MᵀWM the residualizer
506 // forms internally (the cost is one extra weighted Gram; kept here so the
507 // ε logic lives with the projection it regularizes).
508 let p_m = marginal_design.ncols();
509 let gram = fast_xt_diag_x(&marginal_design, w_metric);
510 let gram_scale = (0..p_m).map(|i| gram[[i, i]]).fold(0.0_f64, f64::max);
511 let eps =
512 (gram_scale * INFLUENCE_PROJECTION_RELATIVE_RIDGE).max(INFLUENCE_PROJECTION_RIDGE_FLOOR);
513
514 let residualized = residualize_influence_columns(&z_infl, marginal_design, w_metric, eps);
515 if residualized.iter().any(|v| !v.is_finite()) {
516 return Err(
517 "residualized_influence_block: residualized influence columns contain non-finite entries"
518 .to_string(),
519 );
520 }
521 Ok(residualized)
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use ndarray::array;
528
529 fn jac_from(columns: Array2<f64>) -> ScoreInfluenceJacobian {
530 let n = columns.nrows();
531 ScoreInfluenceJacobian {
532 columns,
533 z: Array1::zeros(n),
534 }
535 }
536
537 // ---- influence_block_design ----
538
539 #[test]
540 fn influence_block_design_row_scales_by_sf_times_pilot() {
541 // Z_infl[i, :] = (s_f * pilot_beta0[i]) * J[i, :].
542 let cols = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
543 let jac = jac_from(cols.clone());
544 let pilot = array![2.0, -1.0, 0.5];
545 let s_f = 3.0;
546 let out = influence_block_design(&jac, &pilot, s_f);
547 for i in 0..3 {
548 let scale = s_f * pilot[i];
549 for j in 0..2 {
550 assert_eq!(out[[i, j]], cols[[i, j]] * scale);
551 }
552 }
553 }
554
555 #[test]
556 fn influence_block_design_preserves_shape_and_does_not_mutate_input() {
557 let cols = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
558 let jac = jac_from(cols.clone());
559 let out = influence_block_design(&jac, &array![1.0, 1.0], 1.0);
560 assert_eq!(out.dim(), (2, 3));
561 // s_f = 1, pilot = ones => Z_infl == J exactly.
562 assert_eq!(out, cols);
563 // Source columns untouched (function clones internally).
564 assert_eq!(jac.columns, cols);
565 }
566
567 #[test]
568 #[should_panic(expected = "pilot_beta0 length must equal Jacobian rows")]
569 fn influence_block_design_panics_on_pilot_length_mismatch() {
570 let jac = jac_from(array![[1.0], [2.0]]);
571 influence_block_design(&jac, &array![1.0], 1.0);
572 }
573
574 // ---- residualize_influence_columns ----
575
576 #[test]
577 fn residualize_returns_input_when_no_marginal_columns() {
578 // p_m == 0 => nothing to project out, raw columns returned verbatim.
579 let z = array![[1.0, 2.0], [3.0, 4.0]];
580 let m = Array2::<f64>::zeros((2, 0));
581 let w = array![1.0, 1.0];
582 let out = residualize_influence_columns(&z, m.view(), &w, 1e-12);
583 assert_eq!(out, z);
584 }
585
586 #[test]
587 fn residualize_kills_columns_in_marginal_span() {
588 // If Z lies entirely in the column span of M, the residual is ~0
589 // (with a tiny ridge). Build Z = M * C.
590 let m = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0]];
591 let c = array![[2.0, -1.0], [0.5, 4.0]];
592 let z = fast_ab(&m, &c);
593 let w = Array1::<f64>::ones(4);
594 let out = residualize_influence_columns(&z, m.view(), &w, 1e-12);
595 let max_abs = out.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
596 assert!(max_abs < 1e-6, "residual of in-span Z too large: {max_abs}");
597 }
598
599 #[test]
600 fn residualize_yields_w_orthogonal_residual() {
601 // The defining property: MᵀW Z̃ ≈ 0 in the row metric W. Use a Z with a
602 // component outside span(M) so the residual is nonzero but W-orthogonal.
603 let m = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 4.0]];
604 let z = array![[0.3, 1.0], [-2.0, 0.5], [4.0, -1.0], [0.7, 2.0]];
605 let w = array![1.0, 2.0, 0.5, 1.5];
606 let out = residualize_influence_columns(&z, m.view(), &w, 1e-12);
607 // MᵀW Z̃ should be ~0.
608 let mtwz = fast_xt_diag_y(&m, &w, &out);
609 let max_abs = mtwz.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
610 assert!(max_abs < 1e-6, "MᵀW Z̃ not ~0: {max_abs}");
611 // The residual is genuinely nonzero (Z had an out-of-span part).
612 let resid_mag = out.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
613 assert!(resid_mag > 1e-3, "residual unexpectedly zero: {resid_mag}");
614 // Shape preserved.
615 assert_eq!(out.dim(), z.dim());
616 }
617
618 // ---- residualized_influence_block (end-to-end pure path) ----
619
620 #[test]
621 fn residualized_block_matches_manual_scale_then_residualize() {
622 // The block builds Z_infl = diag(s_f·β̂₀)·J then residualizes against M in
623 // the W-metric with a Gram-scaled ridge. Reconstruct that path manually.
624 let raw_jac = array![[1.0, 0.5], [2.0, -1.0], [0.0, 3.0], [1.5, 1.0]];
625 let oof_z = array![0.1, 0.2, 0.3, 0.4];
626 let pilot = array![1.0, 2.0, -0.5, 0.5];
627 let s_f = 1.5;
628 let m = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0], [1.0, 3.0]];
629 let w = array![1.0, 1.0, 2.0, 0.5];
630
631 let out =
632 residualized_influence_block(&raw_jac, &oof_z, &pilot, s_f, m.view(), &w).unwrap();
633
634 // Manual: scale rows, derive eps from the weighted Gram diagonal, residualize.
635 let jac = ScoreInfluenceJacobian {
636 columns: raw_jac.clone(),
637 z: oof_z.clone(),
638 };
639 let z_infl = influence_block_design(&jac, &pilot, s_f);
640 let gram = fast_xt_diag_x(&m, &w);
641 let gram_scale = (0..m.ncols()).map(|i| gram[[i, i]]).fold(0.0_f64, f64::max);
642 let eps = (gram_scale * INFLUENCE_PROJECTION_RELATIVE_RIDGE)
643 .max(INFLUENCE_PROJECTION_RIDGE_FLOOR);
644 let expected = residualize_influence_columns(&z_infl, m.view(), &w, eps);
645
646 assert_eq!(out.dim(), expected.dim());
647 for (a, b) in out.iter().zip(expected.iter()) {
648 assert!((a - b).abs() < 1e-12, "mismatch: {a} vs {b}");
649 }
650 // And the result is W-orthogonal to the marginal span.
651 let mtwz = fast_xt_diag_y(&m, &w, &out);
652 let max_abs = mtwz.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
653 assert!(max_abs < 1e-6, "MᵀW Z̃ not ~0: {max_abs}");
654 }
655}