Spaces:
Running
Running
| 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))) | |
| class CausalSample: | |
| x: np.ndarray | |
| treatment: np.ndarray | |
| outcome: np.ndarray | |
| propensity: np.ndarray | |
| y0: np.ndarray | |
| y1: np.ndarray | |
| ite: np.ndarray | |
| 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), | |
| } | |