///|
/// Data container for `DoubleMLIIVM`. Same shape as `DoubleMLData`
/// but adds a single instrumental variable `z`. The treatment `d`
/// and the instrument `z` are both binary.
pub struct DoubleMLIIVMData {
x : Matrix
y : Array[Double]
d : Array[Double]
z : Array[Double]
cluster_vars : Array[Int]
} derive(Debug)
///|
pub extend DoubleMLIIVMData with @moonbitlang/core/debug.Debug::{to_repr}
///|
pub fn DoubleMLIIVMData::new(
x : Matrix,
y : Array[Double],
d : Array[Double],
z : Array[Double],
cluster_vars? : Array[Int] = [],
) -> DoubleMLIIVMData {
try {
require(x.nrows == y.length())
require(x.nrows == d.length())
require(x.nrows == z.length())
if cluster_vars.length() > 0 {
require(cluster_vars.length() == x.nrows)
}
{ x, y, d, z, cluster_vars, }
} catch {
PreconditionError::Violated(loc) =>
abort("precondition failed at " + loc.to_string())
}
}
///|
pub fn DoubleMLIIVMData::n_obs(self : DoubleMLIIVMData) -> Int {
self.x.rows()
}
///|
pub fn DoubleMLIIVMData::n_features(self : DoubleMLIIVMData) -> Int {
self.x.cols()
}
///|
/// True iff the data is set up for clustered inference (a
/// non-empty `cluster_vars` vector was passed to `new`).
pub fn DoubleMLIIVMData::is_cluster_data(self : DoubleMLIIVMData) -> Bool {
self.cluster_vars.length() > 0
}
///|
/// Length of the cluster_vars vector (0 when not clustered).
pub fn DoubleMLIIVMData::n_cluster_vars(self : DoubleMLIIVMData) -> Int {
self.cluster_vars.length()
}
///|
/// Double / debiased machine learning estimator for the *interactive
/// IV regression model* (IIVM) of Chernozhukov et al. (2018) with the
/// *LATE* score, identifying the Local Average Treatment Effect on
/// the "compliers":
///
/// Y = theta * D + g_0(D, X) + U, E[U | D, X] = 0
/// D = m_0(X, Z) + V, E[V | X, Z] = 0
///
/// where the binary instrument `Z` satisfies the relevance and
/// exclusion restrictions. Five cross-fitted nuisance functions are
/// needed (each estimated out-of-fold via K-fold):
///
/// g0(X) = E[Y | Z = 0, X] (trained only on Z = 0)
/// g1(X) = E[Y | Z = 1, X] (trained only on Z = 1)
/// m(X) = E[Z | X] (trained on all obs, then
/// clipped to [eps, 1 - eps])
/// r0(X) = E[D | Z = 0, X] (trained only on Z = 0)
/// r1(X) = E[D | Z = 1, X] (trained only on Z = 1)
///
/// Residuals:
///
/// u_hat0 = Y - g0, u_hat1 = Y - g1
/// w_hat0 = D - r0, w_hat1 = D - r1
///
/// *LATE* score:
///
/// psi_b = (g1 - g0) + Z u_hat1 / m - (1 - Z) u_hat0 / (1 - m)
/// psi_a = -(r1 - r0) - Z w_hat1 / m + (1 - Z) w_hat0 / (1 - m)
/// psi(theta) = theta * psi_a + psi_b
///
/// Point estimate and variance (same `_var_est` formula as the
/// other DML models):
///
/// theta_hat = -mean(psi_b) / mean(psi_a)
/// J = mean(psi_a)
/// gamma = mean(psi(theta_hat)^2)
/// sigma2 = gamma / (J^2 * n)
/// se = sqrt(sigma2).
pub struct DoubleMLIIVM {
data : DoubleMLIIVMData
n_folds : Int
n_rep : Int
seed : Int
propensity_clip : Double
// v0.59.0+: injected nuisance learners (replaces the v0.57.0
// hardcoded `LinearRegression`). Defaults to OLS so v0.57.0
// callers see byte-identical results.
ml_g : LearnerDispatch
ml_m : LearnerDispatch
ml_r : LearnerDispatch
g0_hat : Array[Double]
g1_hat : Array[Double]
m_hat : Array[Double]
r0_hat : Array[Double]
r1_hat : Array[Double]
coef : Double
se : Double
fitted : Bool
// v0.61.0+: per-observation influence function components
// for the multiplier bootstrap. IIVM LATE score:
// psi_a[i] = -(r1 - r0)[i] - Z[i]*w1[i]/m[i]
// + (1-Z[i])*w0[i]/(1-m[i])
// psi_b[i] = (g1 - g0)[i] + Z[i]*u1[i]/m[i]
// - (1-Z[i])*u0[i]/(1-m[i])
// where `w0/1 = D - r0/1`, `u0/1 = Y - g0/1`, `m = clip(m_hat)`.
// Length `n_obs`. Populated by `fit(...)` from the last
// rep's nuisances.
psi_a : Array[Double]
psi_b : Array[Double]
// v0.61.0+: multiplier bootstrap state. `boot_t_stat` is a
// length-`n_rep_boot` array of t-statistics for `coef`.
// Populated by `bootstrap(...)`; empty until then.
boot_t_stat : Array[Double]
boot_method : String
n_rep_boot : Int
boot_seed : Int
} derive(Debug)
///|
pub extend DoubleMLIIVM with @moonbitlang/core/debug.Debug::{to_repr}
///|
pub fn DoubleMLIIVM::new(
data : DoubleMLIIVMData,
n_folds? : Int = 2,
n_rep? : Int = 1,
seed? : Int = 3141,
propensity_clip? : Double = 1.0e-6,
ml_g? : LearnerDispatch = LearnerDispatch::linear_regression(),
ml_m? : LearnerDispatch = LearnerDispatch::linear_regression(),
ml_r? : LearnerDispatch = LearnerDispatch::linear_regression(),
) -> DoubleMLIIVM {
try {
require(n_folds >= 2)
require(n_folds <= data.n_obs())
require(n_rep >= 1)
require(propensity_clip > 0.0)
require(propensity_clip < 0.5)
{
data,
n_folds,
n_rep,
seed,
propensity_clip,
ml_g,
ml_m,
ml_r,
g0_hat: Array::make(data.n_obs(), 0.0),
g1_hat: Array::make(data.n_obs(), 0.0),
m_hat: Array::make(data.n_obs(), 0.0),
r0_hat: Array::make(data.n_obs(), 0.0),
r1_hat: Array::make(data.n_obs(), 0.0),
coef: 0.0,
se: 0.0,
fitted: false,
psi_a: Array::make(data.n_obs(), 0.0),
psi_b: Array::make(data.n_obs(), 0.0),
boot_t_stat: [],
boot_method: "",
n_rep_boot: 0,
boot_seed: 0,
}
} catch {
PreconditionError::Violated(loc) =>
abort("precondition failed at " + loc.to_string())
}
}
///|
pub fn DoubleMLIIVM::n_obs(self : DoubleMLIIVM) -> Int {
self.data.n_obs()
}
///|
pub fn DoubleMLIIVM::coef(self : DoubleMLIIVM) -> Double {
try {
require(self.fitted)
self.coef
} catch {
PreconditionError::Violated(loc) =>
abort("precondition failed at " + loc.to_string())
}
}
///|
pub fn DoubleMLIIVM::se(self : DoubleMLIIVM) -> Double {
try {
require(self.fitted)
self.se
} catch {
PreconditionError::Violated(loc) =>
abort("precondition failed at " + loc.to_string())
}
}
///|
pub fn DoubleMLIIVM::confint(self : DoubleMLIIVM) -> (Double, Double) {
try {
require(self.fitted)
let lo = self.coef - 1.96 * self.se
let hi = self.coef + 1.96 * self.se
(lo, hi)
} catch {
PreconditionError::Violated(loc) =>
abort("precondition failed at " + loc.to_string())
}
}
///|
pub fn DoubleMLIIVM::predictions_g0(self : DoubleMLIIVM) -> Array[Double] {
self.g0_hat
}
///|
pub fn DoubleMLIIVM::predictions_g1(self : DoubleMLIIVM) -> Array[Double] {
self.g1_hat
}
///|
pub fn DoubleMLIIVM::predictions_m(self : DoubleMLIIVM) -> Array[Double] {
self.m_hat
}
///|
pub fn DoubleMLIIVM::predictions_r0(self : DoubleMLIIVM) -> Array[Double] {
self.r0_hat
}
///|
pub fn DoubleMLIIVM::predictions_r1(self : DoubleMLIIVM) -> Array[Double] {
self.r1_hat
}
///|
/// Filter `idx` to keep only entries `i` for which `cond[i]` matches
/// the desired value `target`. Used to build the conditional sample
/// splits for `g0/g1/r0/r1`.
pub fn filter_by_value(
idx : Array[Int],
cond : Array[Double],
target : Double,
) -> Array[Int] {
let out : Array[Int] = []
for i in idx {
if cond[i] == target {
out.push(i)
}
}
out
}
///|
/// Cross-fit the five nuisance functions of the IIVM model. For each
/// fold we train:
///
/// - `ml_g` on `(x[train_z0], y[train_z0])` -> `g0`, predict on
/// `x[test]`
/// - `ml_g` on `(x[train_z1], y[train_z1])` -> `g1`, predict on
/// `x[test]`
/// - `ml_m` on `(x[train], z[train])` -> `m`, predict on `x[test]`,
/// clipped to `[eps, 1 - eps]`
/// - `ml_r` on `(x[train_z0], d[train_z0])` -> `r0`, predict on
/// `x[test]`
/// - `ml_r` on `(x[train_z1], d[train_z1])` -> `r1`, predict on
/// `x[test]`
///
/// Returns `(g0, g1, m, r0, r1)`, each of length `n_obs`. If a
/// conditional training subset is empty (extreme Z imbalance falls
/// into one half of a 2-fold split), the call aborts via
/// `require(...)` rather than silently writing zero predictions —
/// silently-zero nuisance predictions would corrupt the LATE score.
fn cross_fit_iivm(
ml_g : LearnerDispatch,
ml_m : LearnerDispatch,
ml_r : LearnerDispatch,
x : Matrix,
y : Array[Double],
d : Array[Double],
z : Array[Double],
folds : Array[Fold],
propensity_clip : Double,
) -> (Array[Double], Array[Double], Array[Double], Array[Double], Array[Double]) {
try {
let n_obs = x.rows()
let g0 = Array::make(n_obs, 0.0)
let g1 = Array::make(n_obs, 0.0)
let m = Array::make(n_obs, 0.0)
let r0 = Array::make(n_obs, 0.0)
let r1 = Array::make(n_obs, 0.0)
for fold in folds {
let train_idx = fold.train_indices()
let test_idx = fold.test_indices()
let train_z0 = filter_by_value(train_idx, z, 0.0)
let train_z1 = filter_by_value(train_idx, z, 1.0)
require(train_z0.length() > 0)
require(train_z1.length() > 0)
// g0: train on z == 0 subset
let p0 = cross_fit_predict_dispatch(ml_g, x, y, [
Fold::new(train_z0, test_idx),
])
for k = 0; k < test_idx.length(); k = k + 1 {
let row = test_idx[k]
g0[row] = p0[row]
}
// g1: train on z == 1 subset
let p1 = cross_fit_predict_dispatch(ml_g, x, y, [
Fold::new(train_z1, test_idx),
])
for k = 0; k < test_idx.length(); k = k + 1 {
let row = test_idx[k]
g1[row] = p1[row]
}
// m: train on all rows (instrument as y)
let pm = cross_fit_predict_dispatch(ml_m, x, z, [
Fold::new(train_idx, test_idx),
])
for k = 0; k < test_idx.length(); k = k + 1 {
let row = test_idx[k]
m[row] = pm[row]
}
// r0: train on z == 0 subset (treatment as y)
let pr0 = cross_fit_predict_dispatch(ml_r, x, d, [
Fold::new(train_z0, test_idx),
])
for k = 0; k < test_idx.length(); k = k + 1 {
let row = test_idx[k]
r0[row] = pr0[row]
}
// r1: train on z == 1 subset (treatment as y)
let pr1 = cross_fit_predict_dispatch(ml_r, x, d, [
Fold::new(train_z1, test_idx),
])
for k = 0; k < test_idx.length(); k = k + 1 {
let row = test_idx[k]
r1[row] = pr1[row]
}
}
let m_clipped = clip_vec(m, propensity_clip, 1.0 - propensity_clip)
(g0, g1, m_clipped, r0, r1)
} catch {
PreconditionError::Violated(loc) =>
abort("precondition failed at " + loc.to_string())
}
}
///|
/// Run the IIVM estimation.
///
/// Per-repetition behaviour: each repetition `r` cross-fits the
/// `g0 / g1 / m / r0 / r1` nuisances from its own folds (seed
/// `self.seed + r`), computes its own `(theta_r, se_r)` from the
/// LATE score, and the two arrays are then aggregated by
/// `aggregate_coef_se` (median of thetas, then SE from the median of
/// `(theta_r + 1.96 * se_r)`). For `n_rep == 1` the aggregator
/// returns the single `(theta_1, se_1)` exactly, so the byte-equality
/// with the previous "average then estimate" implementation is
/// preserved. The `predictions_g0 / g1 / m / r0 / r1` accessors
/// return the nuisances from the *last* repetition (the conventional
/// choice in upstream `doubleml`), not a cross-rep average.
pub fn DoubleMLIIVM::fit(
self : DoubleMLIIVM,
ml_g? : LearnerDispatch = self.ml_g,
ml_m? : LearnerDispatch = self.ml_m,
ml_r? : LearnerDispatch = self.ml_r,
max_attempts? : Int = 1,
) -> DoubleMLIIVM {
try {
require(max_attempts >= 1)
if self.data.is_cluster_data() {
return self.fit_cluster(ml_g~, ml_m~, ml_r~, max_attempts~)
}
ignore(ml_g)
ignore(ml_m)
ignore(ml_r)
let n = self.n_obs()
let nrep = self.n_rep
let coefs : Array[Double] = Array::make(nrep, 0.0)
let ses : Array[Double] = Array::make(nrep, 0.0)
// hold the last rep's predictions; final values land in *_hat fields
let mut g0 : Array[Double] = Array::make(n, 0.0)
let mut g1 : Array[Double] = Array::make(n, 0.0)
let mut m : Array[Double] = Array::make(n, 0.0)
let mut r0 : Array[Double] = Array::make(n, 0.0)
let mut r1 : Array[Double] = Array::make(n, 0.0)
for r = 0; r < nrep; r = r + 1 {
let folds = kfold(n, self.n_folds, self.seed + r)
let (g0_r, g1_r, m_r, r0_r, r1_r) = cross_fit_iivm(
ml_g,
ml_m,
ml_r,
self.data.x,
self.data.y,
self.data.d,
self.data.z,
folds,
self.propensity_clip,
)
g0 = g0_r
g1 = g1_r
m = m_r
r0 = r0_r
r1 = r1_r
// LATE score for THIS rep's nuisances only
let y = self.data.y
let d = self.data.d
let z = self.data.z
let psi_a : Array[Double] = Array::make(n, 0.0)
let psi_b : Array[Double] = Array::make(n, 0.0)
for i = 0; i < n; i = i + 1 {
let u0 = y[i] - g0[i]
let u1 = y[i] - g1[i]
let w0 = d[i] - r0[i]
let w1 = d[i] - r1[i]
let m_i = m[i]
let one_minus_m = 1.0 - m_i
psi_b[i] = g1[i] -
g0[i] +
z[i] * u1 / m_i -
(1.0 - z[i]) * u0 / one_minus_m
psi_a[i] = -(r1[i] - r0[i]) -
z[i] * w1 / m_i +
(1.0 - z[i]) * w0 / one_minus_m
}
let (coef_r, se_r) = var_est(psi_a, psi_b)
coefs[r] = coef_r
ses[r] = se_r
}
// last iteration's predictions are now in g0 / g1 / m / r0 / r1
let (coef, se) = aggregate_coef_se(coefs, ses)
// v0.61.0: per-observation influence function for the
// multiplier bootstrap. Recompute `psi_a / psi_b` (LATE
// score) from the last rep's nuisances so the stored
// arrays align with `g0_hat` / `g1_hat` / `m_hat` /
// `r0_hat` / `r1_hat` and `coef` (matches the v0.20.0+
// DID convention).
let psi_a : Array[Double] = Array::make(n, 0.0)
let psi_b : Array[Double] = Array::make(n, 0.0)
let y_last = self.data.y
let d_last = self.data.d
let z_last = self.data.z
for i = 0; i < n; i = i + 1 {
let u0 = y_last[i] - g0[i]
let u1 = y_last[i] - g1[i]
let w0 = d_last[i] - r0[i]
let w1 = d_last[i] - r1[i]
let m_i = m[i]
let one_minus_m = 1.0 - m_i
psi_b[i] = g1[i] -
g0[i] +
z_last[i] * u1 / m_i -
(1.0 - z_last[i]) * u0 / one_minus_m
psi_a[i] = -(r1[i] - r0[i]) -
z_last[i] * w1 / m_i +
(1.0 - z_last[i]) * w0 / one_minus_m
}
{
data: self.data,
n_folds: self.n_folds,
n_rep: self.n_rep,
seed: self.seed,
propensity_clip: self.propensity_clip,
ml_g,
ml_m,
ml_r,
g0_hat: g0,
g1_hat: g1,
m_hat: m,
r0_hat: r0,
r1_hat: r1,
coef,
se,
fitted: true,
psi_a,
psi_b,
boot_t_stat: [],
boot_method: "",
n_rep_boot: 0,
boot_seed: 0,
}
} catch {
PreconditionError::Violated(loc) =>
abort("precondition failed at " + loc.to_string())
}
}
///|
/// Clustered-DML path for `DoubleMLIIVM`. Same shape as the
/// other `fit_cluster` helpers: folds are drawn over the
/// unique unit ids, expanded to row folds via
/// `expand_unit_folds_to_rows`; all five nuisances
/// (`g0`, `g1`, `m`, `r0`, `r1`) are cross-fitted with
/// cluster-respecting folds; the LATE coefficient is the
/// fold-weighted ratio of cluster score sums
/// (`est_coef_cluster`); the SE is unit-level cluster-robust
/// (`var_est_cluster`). The per-row score elements are the
/// same as the row-level path
/// (`psi_a = -(r1 - r0) - z w1/m + (1 - z) w0/(1 - m)`,
/// `psi_b = g1 - g0 + z u1/m - (1 - z) u0/(1 - m)`).
fn DoubleMLIIVM::fit_cluster(
self : DoubleMLIIVM,
ml_g~ : LearnerDispatch,
ml_m~ : LearnerDispatch,
ml_r~ : LearnerDispatch,
max_attempts? : Int = 1,
) -> DoubleMLIIVM {
try {
require(max_attempts >= 1)
let cluster = self.data.cluster_vars
let n = self.n_obs()
let nrep = self.n_rep
let uniq = unique_units(cluster)
let n_units = uniq.length()
require(self.n_folds <= n_units)
// v0.36.0: build_row_unit_map raises ClusterDataError on
// malformed cluster vector; catch and re-abort to preserve
// pre-v0.36.0 behavior.
let row_unit = build_row_unit_map(cluster, uniq) catch {
ClusterDataError::MissingUnit(g) =>
abort(
"expand_unit_folds_to_rows: row without a unit id (unit_id=" +
g.to_string() +
")",
)
}
let unit_rows : Array[Array[Int]] = Array::makei(n_units, fn(_) {
let rows : Array[Int] = []
rows
})
for i = 0; i < n; i = i + 1 {
unit_rows[row_unit[i]].push(i)
}
let coefs : Array[Double] = Array::make(nrep, 0.0)
let ses : Array[Double] = Array::make(nrep, 0.0)
let mut g0 : Array[Double] = Array::make(n, 0.0)
let mut g1 : Array[Double] = Array::make(n, 0.0)
let mut m : Array[Double] = Array::make(n, 0.0)
let mut r0 : Array[Double] = Array::make(n, 0.0)
let mut r1 : Array[Double] = Array::make(n, 0.0)
for r = 0; r < nrep; r = r + 1 {
// v0.40.0: retry loop on J-floor (see plr.mbt::fit_cluster).
let mut theta_r = 0.0
let mut se_r = 0.0
let mut attempt = 0
let mut succeeded = false
while attempt < max_attempts && !succeeded {
let rep_seed = self.seed + r + attempt * nrep
let folds_u = kfold(n_units, self.n_folds, rep_seed)
let (folds_row, unit_fold, fold_n_units) = expand_unit_folds_to_rows(
cluster, folds_u, row_unit,
)
let (g0_r, g1_r, m_r, r0_r, r1_r) = cross_fit_iivm(
ml_g,
ml_m,
ml_r,
self.data.x,
self.data.y,
self.data.d,
self.data.z,
folds_row,
self.propensity_clip,
)
g0 = g0_r
g1 = g1_r
m = m_r
r0 = r0_r
r1 = r1_r
let y = self.data.y
let d = self.data.d
let z = self.data.z
let psi_a : Array[Double] = Array::make(n, 0.0)
let psi_b : Array[Double] = Array::make(n, 0.0)
for i = 0; i < n; i = i + 1 {
let u0 = y[i] - g0[i]
let u1 = y[i] - g1[i]
let w0 = d[i] - r0[i]
let w1 = d[i] - r1[i]
let m_i = m[i]
let one_minus_m = 1.0 - m_i
psi_b[i] = g1[i] -
g0[i] +
z[i] * u1 / m_i -
(1.0 - z[i]) * u0 / one_minus_m
psi_a[i] = -(r1[i] - r0[i]) -
z[i] * w1 / m_i +
(1.0 - z[i]) * w0 / one_minus_m
}
let (t, s) = cluster_causal_param_and_se(
psi_a,
psi_b,
folds_row,
fold_n_units,
unit_rows,
unit_fold,
folds_u.length(),
self.n_folds,
) catch {
_ => {
attempt = attempt + 1
(0.0, 0.0)
}
}
theta_r = t
se_r = s
succeeded = true
}
if !succeeded {
abort(
"var_est_cluster: J-floor fired " +
max_attempts.to_string() +
" times for rep=" +
r.to_string() +
" (cluster SE numerically unstable across multiple fold splits, try a different seed or larger n_units)",
)
}
coefs[r] = theta_r
ses[r] = se_r
}
let (coef, se) = aggregate_coef_se(coefs, ses)
// v0.61.0: per-observation influence function for the
// multiplier bootstrap. Same convention as `fit()`:
// recompute from the last rep's nuisances so the stored
// arrays align with `g0_hat` / `g1_hat` / `m_hat` /
// `r0_hat` / `r1_hat` and `coef`.
let psi_a : Array[Double] = Array::make(n, 0.0)
let psi_b : Array[Double] = Array::make(n, 0.0)
for i = 0; i < n; i = i + 1 {
let u0 = self.data.y[i] - g0[i]
let u1 = self.data.y[i] - g1[i]
let w0 = self.data.d[i] - r0[i]
let w1 = self.data.d[i] - r1[i]
let m_i = m[i]
let one_minus_m = 1.0 - m_i
psi_b[i] = g1[i] -
g0[i] +
self.data.z[i] * u1 / m_i -
(1.0 - self.data.z[i]) * u0 / one_minus_m
psi_a[i] = -(r1[i] - r0[i]) -
self.data.z[i] * w1 / m_i +
(1.0 - self.data.z[i]) * w0 / one_minus_m
}
{
data: self.data,
n_folds: self.n_folds,
n_rep: self.n_rep,
seed: self.seed,
propensity_clip: self.propensity_clip,
ml_g,
ml_m,
ml_r,
g0_hat: g0,
g1_hat: g1,
m_hat: m,
r0_hat: r0,
r1_hat: r1,
coef,
se,
fitted: true,
psi_a,
psi_b,
boot_t_stat: [],
boot_method: "",
n_rep_boot: 0,
boot_seed: 0,
}
} catch {
PreconditionError::Violated(loc) =>
abort("precondition failed at " + loc.to_string())
}
}
///|
/// v0.61.0+: multiplier bootstrap for `DoubleMLIIVM`. The
/// per-observation influence function is
///
/// psi[i] = psi_a[i] + theta * psi_b[i]
///
/// where `psi_a` and `psi_b` are the LATE score elements
/// (defined in the module doc) computed at the fitted `coef`
/// from the last rep's cross-fitted nuisances `g0_hat` /
/// `g1_hat` / `m_hat` / `r0_hat` / `r1_hat`. Draws
/// `n_rep_boot` weight vectors of length `n_obs` from the
/// chosen multiplier distribution, and returns a fitted model
/// with `boot_t_stat[b] = sum_i w[b, i] * psi[i] /
/// (sqrt(n) * se_psi)` populated where
/// `se_psi = sqrt(mean(psi^2))`.
///
/// `method_name` selects the multiplier distribution:
/// - `"normal"` (default): `w[i] ~ N(0, 1)`.
/// - `"Bayes"`: `w[i] = exp(1) - 1` (mean 0, var 1).
/// - `"wild"`: `w[i] = x[i] / sqrt(2) + (y[i]^2 - 1) / 2`
/// with `x, y ~ N(0, 1)`.
///
/// Calling `bootstrap` requires the model to be fitted; calling
/// on an un-fit model aborts with `PreconditionError`. The
/// helper is `did_bootstrap_t_stat` (v0.55.0 extracted from
/// `DoubleMLDIDCrossSection::bootstrap`); IIVM is the
/// `n_thetas=1` case.
pub fn DoubleMLIIVM::bootstrap(
self : DoubleMLIIVM,
method_name? : String = "normal",
n_rep_boot? : Int = 500,
seed? : Int = 2024,
) -> DoubleMLIIVM {
try {
require(self.fitted)
require(
method_name == "normal" || method_name == "Bayes" || method_name == "wild",
)
require(n_rep_boot >= 2)
let n = self.n_obs()
// Draw weights. Shape: (n_rep_boot, n_obs).
let weights = draw_bootstrap_weights(method_name, n_rep_boot, n, seed) catch {
BootstrapMethodError::UnknownMethod(m) =>
abort(
"draw_bootstrap_weights: unknown method (set in DoubleMLIIVM::bootstrap): " +
m,
)
}
// Compute psi[i] = psi_a[i] + coef * psi_b[i] and
// ss_psi = sum(psi[i]^2) once. Both `psi_a` and `psi_b`
// were populated by `fit(...)` from the last rep's
// nuisances.
let psi : Array[Double] = Array::make(n, 0.0)
let mut ss_psi = 0.0
for i = 0; i < n; i = i + 1 {
let psi_i = self.psi_a[i] + self.coef * self.psi_b[i]
psi[i] = psi_i
ss_psi = ss_psi + psi_i * psi_i
}
let n_d = n.to_double()
let se_psi = (ss_psi / n_d).sqrt()
if se_psi <= 0.0 {
// Degenerate: psi sums to 0. Cannot divide.
let boot_t_stat_zero : Array[Double] = Array::make(n_rep_boot, 0.0)
return {
..self,
boot_t_stat: boot_t_stat_zero,
boot_method: method_name,
n_rep_boot,
boot_seed: seed,
}
}
// n_thetas=1 case.
let se_flat : Array[Double] = [se_psi]
let boot_t_stat = did_bootstrap_t_stat(
weights, psi, se_flat, n_rep_boot, n, 1,
)
{
..self,
boot_t_stat,
boot_method: method_name,
n_rep_boot,
boot_seed: seed,
}
} catch {
PreconditionError::Violated(loc) =>
abort("precondition failed at " + loc.to_string())
}
}