causal-forge / causal.py
ARotting's picture
Publish Interactive association-versus-causation laboratory
11f3fff verified
Raw
History Blame Contribute Delete
4.71 kB
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from sklearn.linear_model import LogisticRegression, Ridge
from sklearn.model_selection import KFold
def sigmoid(values: np.ndarray) -> np.ndarray:
return 1.0 / (1.0 + np.exp(-np.clip(values, -30, 30)))
@dataclass(frozen=True)
class CausalSample:
x: np.ndarray
treatment: np.ndarray
outcome: np.ndarray
propensity: np.ndarray
y0: np.ndarray
y1: np.ndarray
ite: np.ndarray
@property
def ate(self) -> float:
return float(self.ite.mean())
def generate_scm(
samples: int,
seed: int,
confounding: float = 1.0,
noise: float = 1.0,
) -> CausalSample:
rng = np.random.default_rng(seed)
x = rng.normal(size=(samples, 3))
x1, x2, x3 = x.T
treatment_logit = -0.2 + confounding * (
1.05 * x1 - 0.85 * x2 + 0.60 * x1 * x2 + 0.45 * np.sin(x3)
)
propensity = np.clip(sigmoid(treatment_logit), 0.025, 0.975)
treatment = rng.binomial(1, propensity).astype(np.int8)
baseline = (
1.0
+ 1.35 * x1
+ 0.60 * x2
+ 0.75 * np.sin(x3)
+ 0.55 * x1 * x2
+ 0.30 * x3**2
)
ite = 2.0 + 0.50 * np.tanh(x1) - 0.35 * x2 + 0.20 * np.sin(x3)
disturbance = rng.normal(scale=noise, size=samples)
y0 = baseline + disturbance
y1 = baseline + ite + disturbance
outcome = np.where(treatment == 1, y1, y0)
return CausalSample(x, treatment, outcome, propensity, y0, y1, ite)
def nuisance_features(x: np.ndarray, correct: bool) -> np.ndarray:
if not correct:
return x.copy()
x1, x2, x3 = x.T
return np.column_stack(
[x1, x2, x3, x1 * x2, np.sin(x3), x3**2, np.tanh(x1)]
)
def fit_nuisance(
x: np.ndarray,
treatment: np.ndarray,
outcome: np.ndarray,
*,
propensity_correct: bool,
outcome_correct: bool,
) -> dict:
propensity_model = LogisticRegression(C=10.0, max_iter=1_000)
propensity_model.fit(nuisance_features(x, propensity_correct), treatment)
outcome_x = nuisance_features(x, outcome_correct)
control_model = Ridge(alpha=0.5).fit(
outcome_x[treatment == 0], outcome[treatment == 0]
)
treated_model = Ridge(alpha=0.5).fit(
outcome_x[treatment == 1], outcome[treatment == 1]
)
return {
"propensity": propensity_model,
"control": control_model,
"treated": treated_model,
"propensity_correct": propensity_correct,
"outcome_correct": outcome_correct,
}
def predict_nuisance(models: dict, x: np.ndarray) -> tuple[np.ndarray, ...]:
propensity = models["propensity"].predict_proba(
nuisance_features(x, models["propensity_correct"])
)[:, 1]
outcome_x = nuisance_features(x, models["outcome_correct"])
mu0 = models["control"].predict(outcome_x)
mu1 = models["treated"].predict(outcome_x)
return np.clip(propensity, 0.03, 0.97), mu0, mu1
def estimate_effects(
sample: CausalSample,
*,
propensity_correct: bool,
outcome_correct: bool,
folds: int = 5,
seed: int = 0,
) -> dict[str, float]:
n = len(sample.outcome)
propensity = np.zeros(n)
mu0 = np.zeros(n)
mu1 = np.zeros(n)
splitter = KFold(n_splits=folds, shuffle=True, random_state=seed)
for train, validation in splitter.split(sample.x):
models = fit_nuisance(
sample.x[train],
sample.treatment[train],
sample.outcome[train],
propensity_correct=propensity_correct,
outcome_correct=outcome_correct,
)
propensity[validation], mu0[validation], mu1[validation] = predict_nuisance(
models, sample.x[validation]
)
treatment = sample.treatment
outcome = sample.outcome
naive = outcome[treatment == 1].mean() - outcome[treatment == 0].mean()
treated_weighted = treatment * outcome / propensity
control_weighted = (1 - treatment) * outcome / (1 - propensity)
ipw = treated_weighted.mean() - control_weighted.mean()
outcome_regression = np.mean(mu1 - mu0)
influence = (
mu1
- mu0
+ treatment * (outcome - mu1) / propensity
- (1 - treatment) * (outcome - mu0) / (1 - propensity)
)
aipw = influence.mean()
standard_error = influence.std(ddof=1) / np.sqrt(n)
return {
"truth": sample.ate,
"naive": float(naive),
"ipw": float(ipw),
"outcome_regression": float(outcome_regression),
"aipw": float(aipw),
"aipw_standard_error": float(standard_error),
"aipw_ci_low": float(aipw - 1.96 * standard_error),
"aipw_ci_high": float(aipw + 1.96 * standard_error),
}