#![allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::many_single_char_names,
clippy::needless_range_loop,
clippy::similar_names
)]
use std::sync::Arc;
use antecedent_core::{
AssumptionSet, AverageEffectQuery, ExecutionContext, Intervention, TargetPopulation, VariableId,
};
use antecedent_data::{TableView, TabularData};
use antecedent_expr::{EstimandMethod, IdentifiedEstimand};
use antecedent_stats::{
CompiledDesign, DenseLinearAlgebra, FaerBackend, FirstStageDiagnostics, LassoOptions,
LeastSquaresWorkspace, MEstimateOptions, fit_huber_m, fit_lasso_with_ones_column, fit_ridge,
form_xtx, invert_square, predict_lasso,
};
use crate::error::EstimationError;
use crate::overlap::{OverlapPolicy, OverlapReport};
use crate::prepare::{require_method, treatment_contrast, validate_ate_query_with_targets};
use crate::se::{AnalyticSeKind, residual_sandwich_coef_se};
#[derive(Clone, Debug)]
pub struct PreparedEstimationProblem {
pub design: CompiledDesign,
pub method: Arc<str>,
pub adjustment_set: Arc<[VariableId]>,
pub overlap: OverlapPolicy,
pub treatment_delta: f64,
pub target_population: TargetPopulation,
pub treatment: Arc<[f64]>,
pub active: f64,
pub control: f64,
}
#[derive(Clone, Debug, Default)]
pub struct EstimationWorkspace {
pub ols: LeastSquaresWorkspace,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct EffectEstimate {
pub ate: f64,
pub se_analytic: f64,
pub se_bootstrap: Option<f64>,
pub bootstrap_replicates_ok: Option<u32>,
pub bootstrap_replicates_failed: Option<u32>,
pub bootstrap_cancelled: bool,
pub bootstrap_early_stopped: bool,
pub assumptions: AssumptionSet,
pub overlap: OverlapPolicy,
pub overlap_report: Option<OverlapReport>,
pub first_stage_diagnostics: Option<FirstStageDiagnostics>,
pub retained_memory_bytes: Option<u64>,
}
impl EffectEstimate {
#[must_use]
pub fn new(
ate: f64,
se_analytic: f64,
assumptions: AssumptionSet,
overlap: OverlapPolicy,
) -> Self {
Self {
ate,
se_analytic,
se_bootstrap: None,
bootstrap_replicates_ok: None,
bootstrap_replicates_failed: None,
bootstrap_cancelled: false,
bootstrap_early_stopped: false,
assumptions,
overlap,
overlap_report: None,
first_stage_diagnostics: None,
retained_memory_bytes: None,
}
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn from_parts(
ate: f64,
se_analytic: f64,
se_bootstrap: Option<f64>,
bootstrap_replicates_ok: Option<u32>,
bootstrap_replicates_failed: Option<u32>,
bootstrap_cancelled: bool,
bootstrap_early_stopped: bool,
assumptions: AssumptionSet,
overlap: OverlapPolicy,
overlap_report: Option<OverlapReport>,
retained_memory_bytes: Option<u64>,
) -> Self {
Self {
ate,
se_analytic,
se_bootstrap,
bootstrap_replicates_ok,
bootstrap_replicates_failed,
bootstrap_cancelled,
bootstrap_early_stopped,
assumptions,
overlap,
overlap_report,
first_stage_diagnostics: None,
retained_memory_bytes,
}
}
#[must_use]
pub fn with_first_stage_diagnostics(
mut self,
diagnostics: Option<FirstStageDiagnostics>,
) -> Self {
self.first_stage_diagnostics = diagnostics;
self
}
#[must_use]
pub fn with_overlap_report(mut self, overlap_report: Option<OverlapReport>) -> Self {
self.overlap_report = overlap_report;
self
}
#[must_use]
pub fn with_retained_memory_bytes(mut self, retained_memory_bytes: Option<u64>) -> Self {
self.retained_memory_bytes = retained_memory_bytes;
self
}
#[must_use]
pub fn with_bootstrap(mut self, boot: Option<crate::util::BootstrapSeResult>) -> Self {
match boot {
None => {
self.se_bootstrap = None;
self.bootstrap_replicates_ok = None;
self.bootstrap_replicates_failed = None;
self.bootstrap_cancelled = false;
self.bootstrap_early_stopped = false;
}
Some(b) => {
self.se_bootstrap = b.se;
self.bootstrap_replicates_ok = Some(b.replicates_ok);
self.bootstrap_replicates_failed = Some(b.replicates_failed);
self.bootstrap_cancelled = b.cancelled;
self.bootstrap_early_stopped = b.early_stopped;
}
}
self
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LinearFitKind {
Ols,
Ridge {
lambda: f64,
},
Lasso {
lambda: f64,
},
Huber {
c: f64,
},
}
impl Default for LinearFitKind {
fn default() -> Self {
Self::Ols
}
}
#[derive(Clone, Debug)]
pub struct LinearAdjustmentAte {
pub backend: FaerBackend,
pub bootstrap_replicates: u32,
pub overlap: OverlapPolicy,
pub se_kind: AnalyticSeKind,
pub cluster_ids: Option<Vec<u32>>,
pub multiway_ids: Option<Vec<Vec<u32>>>,
pub panel_times: Option<Vec<i64>>,
pub fit_kind: LinearFitKind,
pub population_registry: Option<antecedent_core::PopulationRegistry>,
}
impl Default for LinearAdjustmentAte {
fn default() -> Self {
Self::new()
}
}
impl LinearAdjustmentAte {
#[must_use]
pub fn new() -> Self {
Self {
backend: FaerBackend,
bootstrap_replicates: 200,
overlap: OverlapPolicy::ExplicitOverride,
se_kind: AnalyticSeKind::Homoskedastic,
cluster_ids: None,
multiway_ids: None,
panel_times: None,
fit_kind: LinearFitKind::Ols,
population_registry: None,
}
}
#[must_use]
pub const fn with_backend(mut self, backend: FaerBackend) -> Self {
self.backend = backend;
self
}
#[must_use]
pub const fn with_bootstrap_replicates(mut self, replicates: u32) -> Self {
self.bootstrap_replicates = replicates;
self
}
#[must_use]
pub const fn with_overlap(mut self, overlap: OverlapPolicy) -> Self {
self.overlap = overlap;
self
}
#[must_use]
pub const fn with_se_kind(mut self, se_kind: AnalyticSeKind) -> Self {
self.se_kind = se_kind;
self
}
#[must_use]
pub fn with_cluster_ids(mut self, cluster_ids: Vec<u32>) -> Self {
self.cluster_ids = Some(cluster_ids);
self
}
#[must_use]
pub fn with_multiway_ids(mut self, multiway_ids: Vec<Vec<u32>>) -> Self {
self.multiway_ids = Some(multiway_ids);
self
}
#[must_use]
pub fn with_panel_times(mut self, panel_times: Vec<i64>) -> Self {
self.panel_times = Some(panel_times);
self
}
#[must_use]
pub const fn with_fit_kind(mut self, fit_kind: LinearFitKind) -> Self {
self.fit_kind = fit_kind;
self
}
#[must_use]
pub fn with_population_registry(
mut self,
registry: antecedent_core::PopulationRegistry,
) -> Self {
self.population_registry = Some(registry);
self
}
pub fn prepare(
&self,
data: &TabularData,
estimand: &IdentifiedEstimand,
query: &AverageEffectQuery,
) -> Result<PreparedEstimationProblem, EstimationError> {
crate::util::require_explicit_override(
self.overlap,
"LinearAdjustmentAte requires ExplicitOverride overlap policy",
)?;
require_method(
estimand,
&[EstimandMethod::BackdoorAdjustment],
"LinearAdjustmentAte expects backdoor.adjustment",
)?;
validate_ate_query_with_targets(query)?;
let treatment = query.treatment;
let outcome = query.outcome;
let (active, control, treatment_delta) = treatment_contrast(&query.active, &query.control)?;
let mut ids = Vec::with_capacity(2 + estimand.adjustment_set.len());
ids.push(treatment);
ids.push(outcome);
ids.extend_from_slice(&estimand.adjustment_set);
let mut row_mask = data.complete_case_mask(&ids).map_err(EstimationError::from)?;
crate::prepare::intersect_predicate_mask(
&mut row_mask,
&query.target_population,
data.row_count(),
self.population_registry.as_ref(),
)?;
let t = data.float64_masked(treatment, &row_mask).map_err(EstimationError::from)?;
let y = data.float64_masked(outcome, &row_mask).map_err(EstimationError::from)?;
let mut covs: Vec<(VariableId, Vec<f64>)> = Vec::new();
for &z in estimand.adjustment_set.iter() {
covs.push((z, data.float64_masked(z, &row_mask).map_err(EstimationError::from)?));
}
let cov_refs: Vec<(VariableId, &[f64])> =
covs.iter().map(|(id, v)| (*id, v.as_slice())).collect();
let selected_rows: Vec<usize> =
row_mask.iter().enumerate().filter_map(|(i, keep)| keep.then_some(i)).collect();
let design = CompiledDesign::linear_adjustment(&t, &cov_refs, &y, &selected_rows)
.map_err(EstimationError::from)?;
Ok(PreparedEstimationProblem {
design,
method: Arc::clone(&estimand.method),
adjustment_set: Arc::clone(&estimand.adjustment_set),
overlap: self.overlap,
treatment_delta,
target_population: query.target_population.clone(),
treatment: Arc::from(t),
active,
control,
})
}
pub fn fit(
&self,
problem: &PreparedEstimationProblem,
workspace: &mut EstimationWorkspace,
ctx: &ExecutionContext,
assumptions: AssumptionSet,
) -> Result<EffectEstimate, EstimationError> {
let point = self.fit_point(problem, workspace, assumptions)?;
self.attach_bootstrap(problem, workspace, ctx, point)
}
pub fn fit_point(
&self,
problem: &PreparedEstimationProblem,
workspace: &mut EstimationWorkspace,
assumptions: AssumptionSet,
) -> Result<EffectEstimate, EstimationError> {
let (coefficients, residuals, rss, analytic_se_ok) =
self.fit_coefficients(problem, workspace)?;
let t_col = problem
.design
.treatment_column()
.ok_or_else(|| EstimationError::stats_msg("missing treatment column"))?;
let ate = gcomp_or_coef_ate(problem, &coefficients, t_col)?;
let n = problem.design.nrows as f64;
let p = problem.design.ncols as f64;
let se_coef = if !analytic_se_ok {
f64::NAN
} else if let Some(se) = residual_sandwich_coef_se(
self.se_kind,
&problem.design.matrix,
problem.design.nrows,
problem.design.ncols,
&residuals,
t_col,
self.cluster_ids.as_deref(),
self.multiway_ids.as_deref(),
self.panel_times.as_deref(),
)? {
se
} else {
let sigma2 = rss / (n - p).max(1.0);
analytic_se_treatment(
&problem.design.matrix,
problem.design.nrows,
problem.design.ncols,
t_col,
sigma2,
)
};
let se_analytic = se_coef * problem.treatment_delta.abs();
Ok(EffectEstimate::new(ate, se_analytic, assumptions, problem.overlap))
}
pub fn attach_bootstrap(
&self,
problem: &PreparedEstimationProblem,
workspace: &mut EstimationWorkspace,
ctx: &ExecutionContext,
point: EffectEstimate,
) -> Result<EffectEstimate, EstimationError> {
let boot = if self.bootstrap_replicates == 0 {
None
} else {
let t_col = problem
.design
.treatment_column()
.ok_or_else(|| EstimationError::stats_msg("missing treatment column"))?;
Some(self.bootstrap_se(problem, workspace, ctx, t_col)?)
};
Ok(point.with_bootstrap(boot))
}
fn fit_coefficients(
&self,
problem: &PreparedEstimationProblem,
workspace: &mut EstimationWorkspace,
) -> Result<(Vec<f64>, Vec<f64>, f64, bool), EstimationError> {
let x = &problem.design.matrix;
let n = problem.design.nrows;
let p = problem.design.ncols;
let y = &problem.design.outcome;
match self.fit_kind {
LinearFitKind::Ols => {
let fit = problem
.design
.fit_ols(&self.backend, &mut workspace.ols)
.map_err(EstimationError::from)?;
Ok((fit.coefficients, fit.residuals, fit.rss, true))
}
LinearFitKind::Ridge { lambda } => {
let fit = fit_ridge(x, n, p, y, lambda, &self.backend, &mut workspace.ols)
.map_err(EstimationError::from)?;
Ok((fit.coefficients, fit.residuals, fit.rss, true))
}
LinearFitKind::Lasso { lambda } => {
let fit = fit_lasso_with_ones_column(
x,
n,
p,
y,
&LassoOptions { lambda, fit_intercept: true, ..LassoOptions::default() },
)
.map_err(EstimationError::from)?;
let mut coefficients = Vec::with_capacity(p);
coefficients.push(fit.intercept);
coefficients.extend_from_slice(&fit.coefficients);
let pred = if fit.coefficients.is_empty() {
vec![fit.intercept; n]
} else {
predict_lasso(&fit, &x[n..], n, p - 1).map_err(EstimationError::from)?
};
let mut residuals = vec![0.0; n];
let mut rss = 0.0;
for r in 0..n {
let e = y[r] - pred[r];
residuals[r] = e;
rss += e * e;
}
Ok((coefficients, residuals, rss, false))
}
LinearFitKind::Huber { c } => {
let opts = MEstimateOptions { c, ..MEstimateOptions::default() };
let fit = fit_huber_m(x, n, p, y, &opts, &self.backend, &mut workspace.ols)
.map_err(EstimationError::from)?;
if !fit.converged {
return Err(EstimationError::unsupported(
"Huber M-estimator did not converge; refuse rather than publish an unfinished fit",
));
}
let mut residuals = vec![0.0; n];
let mut rss = 0.0;
for r in 0..n {
let mut pred = 0.0;
for c in 0..p {
pred += x[c * n + r] * fit.coefficients[c];
}
let e = y[r] - pred;
residuals[r] = e;
rss += e * e;
}
Ok((fit.coefficients, residuals, rss, true))
}
}
}
fn bootstrap_se(
&self,
problem: &PreparedEstimationProblem,
workspace: &mut EstimationWorkspace,
ctx: &ExecutionContext,
t_col: usize,
) -> Result<crate::util::BootstrapSeResult, EstimationError> {
let n = problem.design.nrows;
let p = problem.design.ncols;
let mut x_boot = vec![0.0; n * p];
let mut y_boot = vec![0.0; n];
crate::util::bootstrap_se(self.bootstrap_replicates, ctx, 0xA7E_u64, n, |idx| {
for (r, &src) in idx.iter().enumerate() {
y_boot[r] = problem.design.outcome[src];
for c in 0..p {
x_boot[c * n + r] = problem.design.matrix[c * n + src];
}
}
let coefs = match self.fit_kind {
LinearFitKind::Ols => {
match self.backend.least_squares(&x_boot, n, p, &y_boot, &mut workspace.ols) {
Ok(fit) => fit.coefficients,
Err(_) => return Ok(None),
}
}
LinearFitKind::Ridge { lambda } => {
match fit_ridge(
&x_boot,
n,
p,
&y_boot,
lambda,
&self.backend,
&mut workspace.ols,
) {
Ok(fit) => fit.coefficients,
Err(_) => return Ok(None),
}
}
LinearFitKind::Lasso { lambda } => {
match fit_lasso_with_ones_column(
&x_boot,
n,
p,
&y_boot,
&LassoOptions { lambda, fit_intercept: true, ..LassoOptions::default() },
) {
Ok(fit) => {
let mut coefficients = Vec::with_capacity(p);
coefficients.push(fit.intercept);
coefficients.extend_from_slice(&fit.coefficients);
coefficients
}
Err(_) => return Ok(None),
}
}
LinearFitKind::Huber { c } => {
let opts = MEstimateOptions { c, ..MEstimateOptions::default() };
match fit_huber_m(
&x_boot,
n,
p,
&y_boot,
&opts,
&self.backend,
&mut workspace.ols,
) {
Ok(fit) => fit.coefficients,
Err(_) => return Ok(None),
}
}
};
Ok(Some(gcomp_or_coef_ate(problem, &coefs, t_col)?))
})
}
}
fn gcomp_or_coef_ate(
problem: &PreparedEstimationProblem,
coefficients: &[f64],
t_col: usize,
) -> Result<f64, EstimationError> {
match problem.target_population {
TargetPopulation::AllObserved | TargetPopulation::Predicate(_) => {
Ok(coefficients[t_col] * problem.treatment_delta)
}
TargetPopulation::Treated | TargetPopulation::Untreated => {
let n = problem.design.nrows;
let ncols = problem.design.ncols;
let want_treated = matches!(problem.target_population, TargetPopulation::Treated);
let mut sum = 0.0;
let mut count = 0usize;
for r in 0..n {
let treated = problem.treatment[r] > 0.5;
if treated != want_treated {
continue;
}
let mut pred_a = 0.0;
let mut pred_c = 0.0;
for c in 0..ncols {
let x = if c == t_col {
(problem.active, problem.control)
} else {
let v = problem.design.matrix[c * n + r];
(v, v)
};
pred_a += coefficients[c] * x.0;
pred_c += coefficients[c] * x.1;
}
sum += pred_a - pred_c;
count += 1;
}
if count == 0 {
return Err(EstimationError::data_msg(
"target population left no rows for g-computation",
));
}
Ok(sum / count as f64)
}
_ => Err(EstimationError::TargetPopulation),
}
}
pub(crate) fn intervention_f64(intervention: &Intervention) -> Result<f64, EstimationError> {
match intervention {
Intervention::Set { value, .. } => value.as_f64().ok_or_else(|| {
EstimationError::unsupported(" linear adjustment requires numeric treatment levels")
}),
_ => Err(EstimationError::unsupported(" linear adjustment requires Set interventions")),
}
}
fn analytic_se_treatment(
x_colmajor: &[f64],
nrows: usize,
ncols: usize,
t_col: usize,
sigma2: f64,
) -> f64 {
let mut xtx = vec![0.0; ncols * ncols];
form_xtx(x_colmajor, nrows, ncols, &mut xtx);
let Some(inv) = invert_square(&xtx, ncols) else {
return f64::NAN;
};
(sigma2 * inv[t_col * ncols + t_col].max(0.0)).sqrt()
}
impl crate::estimator::Estimator<TabularData> for LinearAdjustmentAte {
type Fit = EffectEstimate;
fn prepare(
&self,
data: &TabularData,
estimand: &IdentifiedEstimand,
query: &AverageEffectQuery,
_ctx: &ExecutionContext,
) -> Result<PreparedEstimationProblem, EstimationError> {
Self::prepare(self, data, estimand, query)
}
fn fit(
&self,
problem: &PreparedEstimationProblem,
workspace: &mut EstimationWorkspace,
ctx: &ExecutionContext,
) -> Result<Self::Fit, EstimationError> {
Self::fit(self, problem, workspace, ctx, AssumptionSet::new())
}
}
impl crate::estimator::TabularAteEstimator for LinearAdjustmentAte {}
#[cfg(test)]
#[allow(clippy::cast_precision_loss, clippy::many_single_char_names)]
mod tests {
use std::sync::Arc;
use antecedent_core::{
AssumptionSet, AverageEffectQuery, CausalSchemaBuilder, ExecutionContext, MeasurementSpec,
RoleHint, SmallRoleSet, TargetPopulation, ValueType, VariableId,
};
use antecedent_data::{
Float64Column, OwnedColumn, OwnedColumnarStorage, TabularData, ValidityBitmap,
};
use antecedent_expr::ExprId;
use antecedent_expr::IdentifiedEstimand;
use super::*;
fn toy() -> (TabularData, IdentifiedEstimand) {
let n = 100usize;
let mut b = CausalSchemaBuilder::new();
b.add_variable(
"t",
ValueType::Continuous,
SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
None,
None,
MeasurementSpec::default(),
)
.unwrap();
b.add_variable(
"y",
ValueType::Continuous,
SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
None,
None,
MeasurementSpec::default(),
)
.unwrap();
b.add_variable(
"z",
ValueType::Continuous,
SmallRoleSet::from_hint(RoleHint::Context),
None,
None,
MeasurementSpec::default(),
)
.unwrap();
let schema = b.build().unwrap();
let t: Vec<f64> = (0..n).map(|i| (i % 2) as f64).collect();
let z: Vec<f64> = (0..n).map(|i| (i as f64) / n as f64).collect();
let y: Vec<f64> = (0..n).map(|i| 1.0 + 2.0 * t[i] + z[i]).collect();
let cols = vec![
OwnedColumn::Float64(
Float64Column::new(
VariableId::from_raw(0),
Arc::from(t),
ValidityBitmap::all_valid(n),
)
.unwrap(),
),
OwnedColumn::Float64(
Float64Column::new(
VariableId::from_raw(1),
Arc::from(y),
ValidityBitmap::all_valid(n),
)
.unwrap(),
),
OwnedColumn::Float64(
Float64Column::new(
VariableId::from_raw(2),
Arc::from(z),
ValidityBitmap::all_valid(n),
)
.unwrap(),
),
];
let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
let estimand = IdentifiedEstimand::backdoor(
"backdoor.adjustment",
Arc::from([VariableId::from_raw(2)]),
ExprId::from_raw(0),
);
(TabularData::new(storage), estimand)
}
#[test]
fn overlap_report_from_propensities() {
let ps = [0.1, 0.5, 0.9];
let ws = [10.0, 2.0, 1.111];
let report = OverlapReport::from_propensities(
&ps,
Some(&ws),
OverlapPolicy::RequireDiagnostics { clip: Some(0.05), trim: Some(0.05) },
Some(&[0.0, 0.0, 1.0]),
Some(crate::overlap::IpwTarget::Ate),
None,
);
assert!((report.propensity_min - 0.1).abs() < 1e-12);
assert!((report.propensity_max - 0.9).abs() < 1e-12);
assert_eq!(report.extreme_weight_count, 0); assert_eq!(report.clip, Some(0.05));
assert!((report.excluded_fraction - 0.0).abs() < 1e-12);
assert!((report.target_population_support - 1.0).abs() < 1e-12);
assert_eq!(report.excluded_regions.len(), 2);
assert!((report.excluded_regions[0].high - 0.05).abs() < 1e-12);
let sens = report.clip_sensitivity.as_ref().expect("clip sensitivity");
assert!(sens.thresholds.len() >= 2);
assert_eq!(sens.ess.len(), sens.thresholds.len());
}
#[test]
fn rejects_require_diagnostics_on_linear_path() {
let (data, estimand) = toy();
let est = LinearAdjustmentAte {
overlap: OverlapPolicy::require_diagnostics(),
..LinearAdjustmentAte::new()
};
let query =
AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
let err = est.prepare(&data, &estimand, &query).unwrap_err();
assert!(matches!(err, EstimationError::Overlap { .. }));
}
#[test]
fn recovers_ate_two() {
let (data, estimand) = toy();
let est = LinearAdjustmentAte { bootstrap_replicates: 50, ..LinearAdjustmentAte::new() };
let query =
AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
let prep = est.prepare(&data, &estimand, &query).unwrap();
let mut ws = EstimationWorkspace::default();
let ctx = ExecutionContext::for_tests(1);
let effect = est.fit(&prep, &mut ws, &ctx, AssumptionSet::new()).unwrap();
assert!((effect.ate - 2.0).abs() < 1e-8);
assert!(effect.se_bootstrap.is_some());
}
#[test]
fn scales_ate_by_level_delta() {
let (data, estimand) = toy();
let est = LinearAdjustmentAte { bootstrap_replicates: 0, ..LinearAdjustmentAte::new() };
let query = AverageEffectQuery::with_levels(
VariableId::from_raw(0),
VariableId::from_raw(1),
0.0,
2.0,
);
let prep = est.prepare(&data, &estimand, &query).unwrap();
let mut ws = EstimationWorkspace::default();
let ctx = ExecutionContext::for_tests(1);
let effect = est.fit(&prep, &mut ws, &ctx, AssumptionSet::new()).unwrap();
assert!((effect.ate - 4.0).abs() < 1e-8);
}
#[test]
fn recovers_att_via_gcomp() {
let (data, estimand) = toy();
let est = LinearAdjustmentAte { bootstrap_replicates: 0, ..LinearAdjustmentAte::new() };
let query =
AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1))
.with_target_population(TargetPopulation::Treated);
let prep = est.prepare(&data, &estimand, &query).unwrap();
let mut ws = EstimationWorkspace::default();
let ctx = ExecutionContext::for_tests(1);
let effect = est.fit(&prep, &mut ws, &ctx, AssumptionSet::new()).unwrap();
assert!((effect.ate - 2.0).abs() < 1e-8, "att={}", effect.ate);
}
#[test]
fn predicate_restricts_prepared_rows() {
use antecedent_core::PredicateExpr;
let (data, estimand) = toy();
let est = LinearAdjustmentAte { bootstrap_replicates: 0, ..LinearAdjustmentAte::new() };
let all = AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
let half = all.clone().with_target_population(TargetPopulation::Predicate(
PredicateExpr::rows((0..10).collect::<Vec<_>>()),
));
let prep_all = est.prepare(&data, &estimand, &all).unwrap();
let prep_half = est.prepare(&data, &estimand, &half).unwrap();
assert_eq!(prep_all.design.nrows, 100);
assert_eq!(prep_half.design.nrows, 10);
let named =
all.with_target_population(TargetPopulation::Predicate(PredicateExpr::named("cohort")));
let err = est.prepare(&data, &estimand, &named).unwrap_err();
assert!(
err.to_string().contains("PopulationRegistry") || err.to_string().contains("named")
);
}
#[test]
fn hc_sandwich_kinds_yield_finite_se() {
let (data, estimand) = toy();
let query =
AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
for kind in [
AnalyticSeKind::Hc0,
AnalyticSeKind::Hc2,
AnalyticSeKind::Hc3,
AnalyticSeKind::NeweyWest { lag: 2 },
] {
let est = LinearAdjustmentAte {
bootstrap_replicates: 0,
se_kind: kind,
..LinearAdjustmentAte::new()
};
let prep = est.prepare(&data, &estimand, &query).unwrap();
let mut ws = EstimationWorkspace::default();
let effect = est
.fit(&prep, &mut ws, &ExecutionContext::for_tests(1), AssumptionSet::new())
.unwrap();
assert!(effect.se_analytic.is_finite() && effect.se_analytic > 0.0, "{kind:?}");
}
}
#[test]
fn ridge_lasso_huber_fit_kinds_recover_ate() {
let (data, estimand) = toy();
let query =
AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
for kind in [
LinearFitKind::Ridge { lambda: 1e-3 },
LinearFitKind::Lasso { lambda: 1e-4 },
LinearFitKind::Huber { c: 1.345 },
] {
let est = LinearAdjustmentAte {
bootstrap_replicates: 0,
fit_kind: kind,
..LinearAdjustmentAte::new()
};
let prep = est.prepare(&data, &estimand, &query).unwrap();
let mut ws = EstimationWorkspace::default();
let effect = est
.fit(&prep, &mut ws, &ExecutionContext::for_tests(2), AssumptionSet::new())
.unwrap();
assert!(effect.ate.is_finite(), "{kind:?}");
assert!((effect.ate - 2.0).abs() < 0.05, "ate={} kind={kind:?}", effect.ate);
}
}
#[test]
fn lasso_analytic_se_nan_bootstrap_finite() {
let (data, estimand) = toy();
let query =
AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
let no_boot = LinearAdjustmentAte {
bootstrap_replicates: 0,
fit_kind: LinearFitKind::Lasso { lambda: 1e-4 },
..LinearAdjustmentAte::new()
};
let prep = no_boot.prepare(&data, &estimand, &query).unwrap();
let mut ws = EstimationWorkspace::default();
let effect = no_boot
.fit(&prep, &mut ws, &ExecutionContext::for_tests(3), AssumptionSet::new())
.unwrap();
assert!(effect.se_analytic.is_nan(), "lasso se_analytic={}", effect.se_analytic);
assert!(effect.se_bootstrap.is_none());
let with_boot = LinearAdjustmentAte {
bootstrap_replicates: 40,
fit_kind: LinearFitKind::Lasso { lambda: 1e-4 },
..LinearAdjustmentAte::new()
};
let effect = with_boot
.fit(&prep, &mut ws, &ExecutionContext::for_tests(3), AssumptionSet::new())
.unwrap();
assert!(effect.se_analytic.is_nan());
let boot = effect.se_bootstrap.expect("bootstrap SE");
assert!(boot.is_finite() && boot >= 0.0, "se_bootstrap={boot}");
}
}