1#![allow(
14 clippy::cast_possible_truncation,
15 clippy::cast_precision_loss,
16 clippy::many_single_char_names,
17 clippy::similar_names,
18 clippy::float_cmp
19)]
20
21use std::sync::Arc;
22
23use antecedent_core::{ExecutionContext, VariableId};
24use antecedent_data::TableView;
25use antecedent_estimate::{EstimationWorkspace, LinearAdjustmentAte};
26use antecedent_stats::{DenseLinearAlgebra, FaerBackend, LeastSquaresWorkspace};
27
28use crate::common::{
29 RefutationProblem, RefutationReport, complete_case_rows, fill_gaussian, fit_once, float64_full,
30 linear_estimator_no_bootstrap, refit_effect, sample_sd, with_replaced_float,
31};
32use crate::error::ValidationError;
33
34fn default_grid() -> Vec<f64> {
36 vec![0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.5]
37}
38
39fn run_grid(
40 problem: &RefutationProblem<'_>,
41 workspace: &mut EstimationWorkspace,
42 ctx: &ExecutionContext,
43 estimator: &LinearAdjustmentAte,
44 grid: &[f64],
45 noise_stream: u64,
46 nonparametric: bool,
47) -> Result<(f64, f64, bool), ValidationError> {
48 let n = problem.data.row_count();
49 let t0 = float64_full(problem.data, problem.treatment())?;
50 let y0 = float64_full(problem.data, problem.outcome())?;
51 let mut ids = vec![problem.treatment(), problem.outcome()];
52 if problem.temporal.is_none() {
53 ids.extend_from_slice(&problem.estimand.adjustment_set);
54 }
55 let (mask, _valid) = complete_case_rows(problem.data, &ids)?;
56 let sd_t = residual_sd_on_adjustment(problem, problem.treatment(), &mask)?.max(1e-12);
65 let sd_y = residual_sd_on_adjustment(problem, problem.outcome(), &mask)?.max(1e-12);
66 let mut u = vec![0.0; n];
67 if nonparametric {
68 fill_bounded(&mut u, ctx, noise_stream);
69 } else {
70 fill_gaussian(&mut u, ctx, noise_stream);
71 }
72
73 let mut sorted_grid = grid.to_vec();
74 sorted_grid.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
75
76 let original_sign = problem.original.ate.signum();
77 let dir = if problem.original.ate >= 0.0 { -1.0 } else { 1.0 };
81 let mut last_ate = problem.original.ate;
82 for &r in &sorted_grid {
83 let r = r.clamp(0.0, 0.999);
84 let scale = (r / (1.0 - r)).sqrt();
85 let t: Vec<f64> = t0.iter().zip(&u).map(|(&t, &u)| t + scale * sd_t * u).collect();
86 let y: Vec<f64> = y0.iter().zip(&u).map(|(&y, &u)| y + dir * scale * sd_y * u).collect();
87 let data = with_replaced_float(problem.data, problem.treatment(), Arc::from(t))?;
88 let data = with_replaced_float(&data, problem.outcome(), Arc::from(y))?;
89 let est = if problem.temporal.is_some() {
90 refit_effect(problem, &data, problem.estimand, &[], estimator, workspace, ctx)?
91 } else {
92 fit_once(estimator, &data, problem.estimand, problem.query, workspace, ctx)?
93 };
94 last_ate = est.ate;
95 let explained_away = est.ate.abs() < 1e-9 || est.ate.signum() != original_sign;
96 if explained_away {
97 return Ok((r, last_ate, true));
98 }
99 }
100 let robustness_value = sorted_grid.last().copied().unwrap_or(1.0);
101 Ok((robustness_value, last_ate, false))
102}
103
104fn fill_bounded(out: &mut [f64], ctx: &ExecutionContext, stream_id: u64) {
105 let mut rng = ctx.rng.stream(stream_id);
108 let sqrt3 = 3.0_f64.sqrt();
109 for slot in out.iter_mut() {
110 *slot = rng.next_f64().mul_add(2.0, -1.0) * sqrt3;
111 }
112}
113
114#[derive(Clone, Debug)]
116pub struct LinearSensitivity {
117 pub partial_r2_grid: Vec<f64>,
119 pub pass_threshold: f64,
121 pub estimator: LinearAdjustmentAte,
123}
124
125impl Default for LinearSensitivity {
126 fn default() -> Self {
127 Self::new()
128 }
129}
130
131impl LinearSensitivity {
132 #[must_use]
134 pub fn new() -> Self {
135 Self {
136 partial_r2_grid: default_grid(),
137 pass_threshold: 0.1,
138 estimator: linear_estimator_no_bootstrap(),
139 }
140 }
141
142 pub fn refute(
148 &self,
149 problem: &RefutationProblem<'_>,
150 workspace: &mut EstimationWorkspace,
151 ctx: &ExecutionContext,
152 ) -> Result<RefutationReport, ValidationError> {
153 if self.partial_r2_grid.is_empty() {
154 return Err(ValidationError::NotApplicable {
155 message: "linear sensitivity requires a non-empty partial_r2_grid",
156 });
157 }
158 let (robustness_value, refuted_ate, _explained_away) = run_grid(
159 problem,
160 workspace,
161 ctx,
162 &self.estimator,
163 &self.partial_r2_grid,
164 0xA7E0_000A_0000_u64,
165 false,
166 )?;
167 let passed = robustness_value >= self.pass_threshold;
168 Ok(RefutationReport {
169 refuter: Arc::from("sensitivity.linear"),
170 original_ate: problem.original.ate,
171 refuted_ate,
172 comparison: robustness_value,
173 informative: true,
174 passed,
175 failure_condition: if passed {
176 None
177 } else {
178 Some(Arc::from(format!(
179 "effect explained away at partial R²={robustness_value}, below threshold {}",
180 self.pass_threshold
181 )))
182 },
183 replicates: self.partial_r2_grid.len() as u32,
184 })
185 }
186}
187
188#[derive(Clone, Debug)]
191pub struct PartialLinearSensitivity {
192 pub partial_r2_grid: Vec<f64>,
194 pub pass_threshold: f64,
196 pub estimator: LinearAdjustmentAte,
198}
199
200impl Default for PartialLinearSensitivity {
201 fn default() -> Self {
202 Self::new()
203 }
204}
205
206impl PartialLinearSensitivity {
207 #[must_use]
209 pub fn new() -> Self {
210 Self {
211 partial_r2_grid: default_grid(),
212 pass_threshold: 0.1,
213 estimator: linear_estimator_no_bootstrap(),
214 }
215 }
216
217 pub fn refute(
223 &self,
224 problem: &RefutationProblem<'_>,
225 workspace: &mut EstimationWorkspace,
226 ctx: &ExecutionContext,
227 ) -> Result<RefutationReport, ValidationError> {
228 if self.partial_r2_grid.is_empty() {
229 return Err(ValidationError::NotApplicable {
230 message: "partial-linear sensitivity requires a non-empty partial_r2_grid",
231 });
232 }
233 let (robustness_value, refuted_ate, _explained_away) = run_grid(
234 problem,
235 workspace,
236 ctx,
237 &self.estimator,
238 &self.partial_r2_grid,
239 0xA7E0_000B_0000_u64,
240 true,
241 )?;
242 let passed = robustness_value >= self.pass_threshold;
243 Ok(RefutationReport {
244 refuter: Arc::from("sensitivity.partial_linear"),
245 original_ate: problem.original.ate,
246 refuted_ate,
247 comparison: robustness_value,
248 informative: true,
249 passed,
250 failure_condition: if passed {
251 None
252 } else {
253 Some(Arc::from(format!(
254 "effect explained away at partial R²={robustness_value}, below threshold {}",
255 self.pass_threshold
256 )))
257 },
258 replicates: self.partial_r2_grid.len() as u32,
259 })
260 }
261}
262
263fn nw_loo_predict(y: &[f64], cov_rowmajor: &[f64], dim: usize, bandwidth: f64) -> Vec<f64> {
265 let n = y.len();
266 let h2 = (bandwidth.max(1e-6)).powi(2);
267 let mut out = vec![0.0; n];
268 for i in 0..n {
269 let xi = &cov_rowmajor[i * dim..(i + 1) * dim];
270 let mut num = 0.0;
271 let mut den = 0.0;
272 for j in 0..n {
273 if i == j {
274 continue;
275 }
276 let xj = &cov_rowmajor[j * dim..(j + 1) * dim];
277 let mut d2 = 0.0;
278 for d in 0..dim {
279 let t = xi[d] - xj[d];
280 d2 += t * t;
281 }
282 let w = (-0.5 * d2 / h2).exp();
283 num += w * y[j];
284 den += w;
285 }
286 out[i] = if den > 1e-15 { num / den } else { y[i] };
287 }
288 out
289}
290
291pub(crate) fn residual_sd_on_adjustment(
297 problem: &RefutationProblem<'_>,
298 target: VariableId,
299 mask: &[bool],
300) -> Result<f64, ValidationError> {
301 let z_ids = problem.estimand.adjustment_set.to_vec();
302 let y = problem.data.float64_masked(target, mask).map_err(ValidationError::from)?;
303 if z_ids.is_empty() || y.len() < z_ids.len() + 2 {
304 return Ok(sample_sd(&y));
305 }
306 let n = y.len();
307 let ncols = z_ids.len() + 1;
308 let mut design = Vec::with_capacity(n * ncols);
310 design.extend(std::iter::repeat_n(1.0, n));
311 for &z in &z_ids {
312 let col = problem.data.float64_masked(z, mask).map_err(ValidationError::from)?;
313 if col.len() != n {
314 return Ok(sample_sd(&y));
315 }
316 design.extend_from_slice(&col);
317 }
318 let mut ws = LeastSquaresWorkspace::default();
319 let Ok(fit) = FaerBackend.least_squares(&design, n, ncols, &y, &mut ws) else {
320 return Ok(sample_sd(&y));
321 };
322 if fit.coefficients.iter().any(|c| !c.is_finite()) {
323 return Ok(sample_sd(&y));
324 }
325 let residuals: Vec<f64> = (0..n)
326 .map(|r| {
327 let mut pred = fit.coefficients[0];
328 for c in 1..ncols {
329 pred += fit.coefficients[c] * design[c * n + r];
330 }
331 y[r] - pred
332 })
333 .collect();
334 let sd = sample_sd(&residuals);
335 if sd.is_finite() { Ok(sd) } else { Ok(sample_sd(&y)) }
336}
337
338fn covariate_matrix(
339 problem: &RefutationProblem<'_>,
340) -> Result<(Vec<f64>, usize, usize), ValidationError> {
341 let ids = problem.estimand.adjustment_set.to_vec();
342 let mut all = ids.clone();
343 all.push(problem.treatment());
344 all.push(problem.outcome());
345 let mask = problem.data.complete_case_mask(&all).map_err(ValidationError::from)?;
346 let n = mask.iter().filter(|&&k| k).count();
347 if ids.is_empty() {
348 return Ok((vec![1.0; n], n, 1));
349 }
350 let dim = ids.len();
351 let mut cov = vec![0.0; n * dim];
352 for (c, &z) in ids.iter().enumerate() {
353 let col = problem.data.float64_masked(z, &mask).map_err(ValidationError::from)?;
354 for (r, &v) in col.iter().enumerate() {
355 cov[r * dim + c] = v;
356 }
357 }
358 Ok((cov, n, dim))
359}
360
361fn silverman_bandwidth(cov_rowmajor: &[f64], n: usize, dim: usize) -> f64 {
362 if n == 0 || dim == 0 {
363 return 1.0;
364 }
365 let mut sum_sd = 0.0;
366 for d in 0..dim {
367 let mut vals = Vec::with_capacity(n);
368 for r in 0..n {
369 vals.push(cov_rowmajor[r * dim + d]);
370 }
371 sum_sd += sample_sd(&vals);
372 }
373 let mean_sd = (sum_sd / dim as f64).max(1e-6);
374 mean_sd * (n as f64).powf(-1.0 / (dim as f64 + 4.0))
375}
376
377#[derive(Clone, Debug)]
379pub struct NonparametricSensitivity {
380 pub partial_r2_grid: Vec<f64>,
382 pub pass_threshold: f64,
384 pub bandwidth: Option<f64>,
386}
387
388impl Default for NonparametricSensitivity {
389 fn default() -> Self {
390 Self::new()
391 }
392}
393
394impl NonparametricSensitivity {
395 #[must_use]
397 pub fn new() -> Self {
398 Self { partial_r2_grid: default_grid(), pass_threshold: 0.1, bandwidth: None }
399 }
400
401 pub fn refute(
407 &self,
408 problem: &RefutationProblem<'_>,
409 _workspace: &mut EstimationWorkspace,
410 ctx: &ExecutionContext,
411 ) -> Result<RefutationReport, ValidationError> {
412 if self.partial_r2_grid.is_empty() {
413 return Err(ValidationError::NotApplicable {
414 message: "nonparametric sensitivity requires a non-empty partial_r2_grid",
415 });
416 }
417 let (cov, n, dim) = covariate_matrix(problem)?;
418 let mut ids = problem.estimand.adjustment_set.to_vec();
419 ids.push(problem.treatment());
420 ids.push(problem.outcome());
421 let mask = problem.data.complete_case_mask(&ids).map_err(ValidationError::from)?;
422 let t = problem
423 .data
424 .float64_masked(problem.treatment(), &mask)
425 .map_err(ValidationError::from)?;
426 let y =
427 problem.data.float64_masked(problem.outcome(), &mask).map_err(ValidationError::from)?;
428 if t.len() != n || y.len() != n {
429 return Err(ValidationError::data_msg("nonparametric sensitivity row mismatch"));
430 }
431 let h = self.bandwidth.unwrap_or_else(|| silverman_bandwidth(&cov, n, dim));
432 let t_hat = nw_loo_predict(&t, &cov, dim, h);
433 let y_hat = nw_loo_predict(&y, &cov, dim, h);
434 let t_res: Vec<f64> = t.iter().zip(&t_hat).map(|(&a, &b)| a - b).collect();
435 let y_res: Vec<f64> = y.iter().zip(&y_hat).map(|(&a, &b)| a - b).collect();
436
437 let residual_ate = residual_ols_ate(&t_res, &y_res);
438 let sd_t = sample_sd(&t_res).max(1e-12);
439 let sd_y = sample_sd(&y_res).max(1e-12);
440 let mut u = vec![0.0; n];
441 fill_gaussian(&mut u, ctx, 0xA7E0_000C_0000_u64);
442
443 let mut sorted_grid = self.partial_r2_grid.clone();
444 sorted_grid.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
445 let original_sign = residual_ate.signum();
446 let dir = if residual_ate >= 0.0 { -1.0 } else { 1.0 };
448 let mut last_ate = residual_ate;
449 let mut robustness_value = sorted_grid.last().copied().unwrap_or(1.0);
450 for &r in &sorted_grid {
451 let r = r.clamp(0.0, 0.999);
452 let scale = (r / (1.0 - r)).sqrt();
453 let t_pert: Vec<f64> =
454 t_res.iter().zip(&u).map(|(&tv, &uu)| tv + scale * sd_t * uu).collect();
455 let y_pert: Vec<f64> =
456 y_res.iter().zip(&u).map(|(&yv, &uu)| yv + dir * scale * sd_y * uu).collect();
457 last_ate = residual_ols_ate(&t_pert, &y_pert);
458 if last_ate.abs() < 1e-9 || last_ate.signum() != original_sign {
459 robustness_value = r;
460 break;
461 }
462 }
463 let passed = robustness_value >= self.pass_threshold;
464 Ok(RefutationReport {
465 refuter: Arc::from("sensitivity.nonparametric"),
466 original_ate: problem.original.ate,
467 refuted_ate: last_ate,
468 comparison: robustness_value,
469 informative: true,
470 passed,
471 failure_condition: if passed {
472 None
473 } else {
474 Some(Arc::from(format!(
475 "nonparametric residual effect explained away at partial R²={robustness_value}, \
476 below threshold {}",
477 self.pass_threshold
478 )))
479 },
480 replicates: self.partial_r2_grid.len() as u32,
481 })
482 }
483}
484
485fn residual_ols_ate(t: &[f64], y: &[f64]) -> f64 {
486 let n = t.len() as f64;
487 if n < 2.0 {
488 return f64::NAN;
489 }
490 let mean_t = t.iter().sum::<f64>() / n;
491 let mean_y = y.iter().sum::<f64>() / n;
492 let mut num = 0.0;
493 let mut den = 0.0;
494 for (&ti, &yi) in t.iter().zip(y) {
495 let dt = ti - mean_t;
496 num += dt * (yi - mean_y);
497 den += dt * dt;
498 }
499 if den < 1e-15 { 0.0 } else { num / den }
500}