statspai.metalearners¶
metalearners ¶
Meta-Learners for heterogeneous treatment effect estimation.
Provides S/T/X/R/DR-Learner implementations that decompose CATE estimation into standard supervised-learning sub-problems. All learners accept any scikit-learn compatible estimator.
References
Kunzel et al. (2019). Metalearners for estimating heterogeneous treatment effects using machine learning. PNAS, 116(10), 4156-4165. [@kunzel2019metalearners]
Nie & Wager (2021). Quasi-oracle estimation of heterogeneous treatment effects. Biometrika, 108(2), 299-319. [@nie2021quasi]
Kennedy (2023). Towards optimal doubly robust estimation of heterogeneous causal effects. Electronic Journal of Statistics, 17(2), 3008-3049. [@kennedy2023towards]
AutoCATEResult
dataclass
¶
Bases: ResultProtocolMixin
Leaderboard + winner from sp.auto_cate.
Attributes:
| Name | Type | Description |
|---|---|---|
leaderboard |
DataFrame
|
One row per learner with ATE, SE, CI, R-loss, BLP columns and
CATE dispersion, sorted by R-loss ascending. |
best_learner |
str
|
Full name of the chosen winner (e.g. |
best_result |
CausalResult
|
Full fitted result for the winner — supports |
results |
dict[str, CausalResult]
|
All fitted learners keyed by short code ( |
agreement |
DataFrame
|
Pearson-rho matrix of CATE vectors across learners (in-sample). High agreement suggests stable heterogeneity; low agreement suggests model dependence. |
selection_rule |
str
|
Human-readable description of the rule that picked the winner. |
n_obs |
int
|
Sample size (after dropping NA on modelled columns). |
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> age = rng.normal(size=n)
>>> edu = rng.normal(size=n)
>>> training = (rng.uniform(size=n) < 0.5).astype(int)
>>> wage = (1.0 + 0.5 * age + edu + training * (1.0 + age)
... + rng.normal(size=n))
>>> df = pd.DataFrame({"wage": wage, "training": training,
... "age": age, "edu": edu})
>>> result = sp.auto_cate(
... df, y="wage", treat="training", covariates=["age", "edu"],
... learners=("t", "dr"), n_folds=2, n_bootstrap=50, random_state=0,
... )
>>> type(result).__name__
'AutoCATEResult'
>>> result.n_obs
200
>>> sorted(result.results)
['dr', 't']
>>> bool(result.best_learner in set(result.leaderboard["learner"]))
True
CATEGainCurveResult
dataclass
¶
Bases: ResultProtocolMixin
Output of :func:cate_gain_curve.
Attributes:
| Name | Type | Description |
|---|---|---|
auc |
float
|
Sum of the (normalized) cumulative gain curve over its steps: zero in expectation for a ranking unrelated to the effect, positive when the units ranked first respond more. |
ate |
float
|
Slope of the outcome on the treatment in the whole sample. |
curve |
DataFrame
|
One row per step: |
by_quantile |
DataFrame
|
Slope within each quantile group of the ranking, lowest first. |
auc_se, auc_ci |
float and (float, float), or None
|
Bootstrap standard error and percentile interval of |
Examples:
>>> import numpy as np, pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> df = pd.DataFrame({"x": rng.uniform(0, 1, 2000),
... "dose": rng.uniform(0, 10, 2000)})
>>> df["y"] = (1 + 2 * df["x"]) * df["dose"] + rng.normal(size=2000)
>>> res = sp.cate_gain_curve(df, cate="x", y="y", treat="dose")
>>> type(res).__name__
'CATEGainCurveResult'
>>> list(res.curve.columns)
['share', 'n', 'cumulative_effect', 'cumulative_gain']
CATEEvalResult
dataclass
¶
Bases: ResultProtocolMixin
Output of :func:cate_eval.
Attributes:
| Name | Type | Description |
|---|---|---|
autoc |
float
|
|
autoc_se |
float
|
|
autoc_ci |
(float, float)
|
|
qini |
float
|
|
qini_se |
float
|
|
qini_ci |
(float, float)
|
|
toc_curve |
DataFrame
|
One row per quantile grid point: |
n_obs |
int
|
|
target |
str
|
|
method |
str
|
Always |
Examples:
>>> import numpy as np
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 400
>>> X = rng.normal(size=(n, 3))
>>> T = rng.integers(0, 2, n)
>>> tau = 1.0 + X[:, 0]
>>> Y = X[:, 1] + tau * T + rng.normal(scale=0.5, size=n)
>>> cate = 1.0 + X[:, 0] # priority score to evaluate
>>> res = sp.cate_eval(cate, Y, T, X=X, n_folds=3, q_grid=20, random_state=0)
>>> isinstance(res, sp.CATEEvalResult)
True
>>> res.toc_curve.columns.tolist()[:3]
['q', 'toc', 'se']
>>> bool(res.n_obs == n)
True
plot ¶
plot(ax: Any = None, target: Optional[str] = None, figsize: Tuple[float, float] = (6.0, 4.0)) -> tuple[Any, Any]
Plot the TOC curve plus a dashed zero line.
target defaults to self.target; pass "both" to overlay
AUTOC's curve with a marker for the QINI weighted area.
ClusterCATEResult
dataclass
¶
Bases: ResultProtocolMixin
Per-cluster CATE table.
Returned by :func:cluster_cate; holds the within-cluster CATE table
(one row per retained K-means cluster) plus cluster / observation counts.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 400
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> d = rng.integers(0, 2, size=n)
>>> tau = 1.0 + 0.8 * x1 # heterogeneous effect
>>> y = 2.0 + x2 + tau * d + rng.normal(scale=0.5, size=n)
>>> df = pd.DataFrame({"y": y, "d": d, "x1": x1, "x2": x2})
>>> res = sp.cluster_cate(
... df, y="y", treat="d", covariates=["x1", "x2"],
... n_clusters=3, seed=0)
>>> isinstance(res, sp.ClusterCATEResult)
True
>>> res.cluster_table.columns.tolist()
['cluster', 'n', 'cate', 'se', 'ci_low', 'ci_high']
>>> bool(res.n_obs == 400)
True
FunctionalCATEResult
dataclass
¶
Bases: ResultProtocolMixin
Output of FOCaL functional CATE estimator.
Returned by :func:focal_cate. Holds the estimated CATE surface
cate_grid of shape (n_test, n_function_points) together with
se_grid, the HC1 standard error of each linear CATE prediction
(nuisances treated as known), and the function-evaluation grid.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 300
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> d = rng.integers(0, 2, size=n)
>>> tau = 1.0 + 0.5 * x1
>>> y0 = 2.0 + x2 + rng.normal(scale=0.5, size=n)
>>> y1 = y0 + tau
>>> df = pd.DataFrame({
... "y_t0": np.where(d == 1, y1, y0),
... "y_t1": np.where(d == 1, y1 + 0.3, y0),
... "d": d, "x1": x1, "x2": x2,
... })
>>> res = sp.focal_cate(
... df, y_columns=["y_t0", "y_t1"], treat="d",
... covariates=["x1", "x2"], seed=0,
... )
>>> isinstance(res, sp.FunctionalCATEResult)
True
>>> res.cate_grid.shape # (n_test, n_function_points)
(300, 2)
DRLearner ¶
DR-Learner (Kennedy 2023): doubly robust CATE estimation.
Constructs the doubly robust pseudo-outcome: phi(X) = mu_1(X) - mu_0(X) + D(Y - mu_1(X)) / e(X) - (1-D)(Y - mu_0(X)) / (1-e(X))
Then regresses phi on X to obtain tau(X).
Achieves oracle rates and is robust to mis-specification of either the outcome or propensity model (but not both).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
outcome_model
|
sklearn estimator
|
Model for mu_d(X) = E[Y|X, D=d]. |
None
|
propensity_model
|
sklearn estimator
|
Model for e(X) = P(D=1|X). |
None
|
cate_model
|
sklearn estimator
|
Final-stage model for tau(X). Default: gradient boosting with at least 20 observations per leaf. |
None
|
n_folds
|
int
|
Cross-fitting folds for nuisance estimation. |
5
|
fold_indices
|
array-like of int
|
Explicit cross-fitting partition, one label in |
None
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> X = rng.normal(size=(n, 3))
>>> e = 1 / (1 + np.exp(-X[:, 0]))
>>> D = (rng.random(n) < e).astype(float)
>>> Y = X[:, 0] + (1.0 + X[:, 1]) * D + rng.normal(size=n)
>>> learner = sp.DRLearner(n_folds=3).fit(X, Y, D)
>>> cate = learner.effect(X)
>>> bool(cate.shape == (n,))
True
References
kennedy2023towards
RLearner ¶
R-Learner (Nie & Wager 2021).
Based on the Robinson (1988) decomposition: Y - m(X) = tau(X) * (D - e(X)) + epsilon
Estimates nuisance functions m(X) = E[Y|X] and e(X) = E[D|X] via cross-fitting, then minimises the R-loss: L(tau) = sum_i [ (Y_i - m_hat(X_i)) - tau(X_i)*(D_i - e_hat(X_i)) ]^2
Achieves quasi-oracle rates under mild conditions.
The decomposition does not need a binary treatment. With a continuous
D the same loss is minimised with e(X) = E[D|X] fitted by a
regressor, and tau(X) is the effect of one more unit of D at
X under an outcome that is linear in D given X: the
conditional version of the partially linear model of double machine
learning.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
outcome_model
|
sklearn estimator
|
Model for m(X) = E[Y|X]. |
None
|
propensity_model
|
sklearn estimator
|
Model for e(X) = E[D|X]: a classifier for a 0/1 treatment (default gradient-boosted classifier), a regressor for a continuous one (default: the outcome model's default regressor). |
None
|
cate_model
|
sklearn estimator
|
Model for tau(X), fitted to the pseudo-outcome with weights
|
None
|
n_folds
|
int
|
Cross-fitting folds for nuisance estimation. |
5
|
fold_indices
|
array-like of int
|
Explicit cross-fitting partition, one label in |
None
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> X = rng.normal(size=(n, 3))
>>> e = 1 / (1 + np.exp(-X[:, 0]))
>>> D = (rng.random(n) < e).astype(float)
>>> Y = X[:, 0] + (1.0 + X[:, 1]) * D + rng.normal(size=n)
>>> learner = sp.RLearner(n_folds=3).fit(X, Y, D)
>>> cate = learner.effect(X)
>>> bool(cate.shape == (n,))
True
References
nie2021quasi
SLearner ¶
S-Learner: single model, treatment as a feature.
Fits one model mu(X, D) and estimates CATE as: tau(x) = mu(x, 1) - mu(x, 0)
Simple but may under-regularise the treatment effect when the treatment variable is just one of many features.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
sklearn estimator
|
Outcome model mu(X, D). Default: GradientBoostingRegressor. |
None
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> X = rng.normal(size=(n, 3))
>>> D = (rng.random(n) < 0.5).astype(float)
>>> Y = X[:, 0] + (1.0 + X[:, 1]) * D + rng.normal(size=n)
>>> learner = sp.SLearner().fit(X, Y, D)
>>> cate = learner.effect(X)
>>> bool(cate.shape == (n,))
True
References
kunzel2019metalearners
TLearner ¶
T-Learner: separate models for each treatment arm.
Fits mu_1(X) on treated units and mu_0(X) on controls: tau(x) = mu_1(x) - mu_0(x)
Simple and flexible but can suffer from regularisation imbalance when treatment/control group sizes differ substantially.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model_0
|
sklearn estimator
|
Control outcome model. Default: GradientBoostingRegressor. |
None
|
model_1
|
sklearn estimator
|
Treated outcome model. Default: same type as model_0. |
None
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> X = rng.normal(size=(n, 3))
>>> D = (rng.random(n) < 0.5).astype(float)
>>> Y = X[:, 0] + (1.0 + X[:, 1]) * D + rng.normal(size=n)
>>> learner = sp.TLearner().fit(X, Y, D)
>>> cate = learner.effect(X)
>>> bool(cate.shape == (n,))
True
References
kunzel2019metalearners
XLearner ¶
X-Learner (Künzel et al. 2019).
Two-stage procedure: 1. Fit mu_0, mu_1 (T-Learner first stage). 2. Impute individual treatment effects: - For treated: D1_i = Y_i - mu_0(X_i) - For controls: D0_i = mu_1(X_i) - Y_i 3. Fit CATE models tau_1(X) on D1 and tau_0(X) on D0. 4. Combine: tau(x) = e(x)tau_0(x) + (1-e(x))tau_1(x) where e(x) is the propensity score.
Particularly effective when treatment/control groups are very unbalanced.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model_0
|
sklearn estimator
|
Control outcome model. |
None
|
model_1
|
sklearn estimator
|
Treated outcome model. |
None
|
cate_model_0
|
sklearn estimator
|
CATE model for control-side imputed effects. |
None
|
cate_model_1
|
sklearn estimator
|
CATE model for treated-side imputed effects. |
None
|
propensity_model
|
sklearn estimator
|
Model for e(x) = P(D=1|X). |
None
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> X = rng.normal(size=(n, 3))
>>> e = 1 / (1 + np.exp(-X[:, 0]))
>>> D = (rng.random(n) < e).astype(float)
>>> Y = X[:, 0] + (1.0 + X[:, 1]) * D + rng.normal(size=n)
>>> learner = sp.XLearner().fit(X, Y, D)
>>> cate = learner.effect(X)
>>> bool(cate.shape == (n,))
True
References
kunzel2019metalearners
cate_gain_curve ¶
cate_gain_curve(data: DataFrame, cate: Any, y: str, treat: str, n_steps: int = 100, n_quantiles: int = 10, normalize: bool = True, ascending: bool = False, n_boot: int = 0, cluster: Optional[str] = None, alpha: float = 0.05, seed: Optional[int] = None) -> CATEGainCurveResult
Cumulative gain curve of a CATE ranking on randomized data.
The sample is sorted by the prediction, largest first. At each step the effect among the top-ranked units is the OLS slope of the outcome on the treatment in that subset (a difference in means when the treatment is 0/1), and the gain is that slope times the share of the sample used. A ranking that puts responsive units first has a curve above the straight line from zero to the average effect. The normalized curve subtracts that line, and its sum over the steps is the reported area.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Evaluation data in which |
required |
cate
|
str or array - like
|
Predicted effects (or any score that ranks units by effect): a
column name or one value per row of |
required |
y
|
str
|
Outcome column. |
required |
treat
|
str
|
Treatment column, binary or continuous. |
required |
n_steps
|
int
|
Number of points on the curve. |
100
|
n_quantiles
|
int
|
Number of groups in the effect-by-quantile table. |
10
|
normalize
|
bool
|
Subtract the random-ranking line from the gain curve. |
True
|
ascending
|
bool
|
Rank the smallest predictions first (for an effect where lower is better). |
False
|
n_boot
|
int
|
Bootstrap replications for the standard error and the percentile
interval of the area. |
0
|
cluster
|
str
|
Column whose groups are resampled whole (repeated days of one store, say). Default: rows are resampled. |
None
|
alpha
|
float
|
Level of the bootstrap interval. |
0.05
|
seed
|
int
|
Seed for the bootstrap. |
None
|
Returns:
| Type | Description |
|---|---|
CATEGainCurveResult
|
|
Notes
The slope identifies the effect in a subset only if the treatment is
independent of the potential outcomes there, which randomization
delivers and observational data does not. On observational data the
curve ranks units by a confounded association. The slope also averages
a nonlinear dose response with weights that depend on the distribution
of the dose in the subset. The bootstrap of n_boot resamples the
evaluation data with the predictions held fixed, so it describes the
sampling error of the evaluation, not that of the model behind the
predictions.
Examples:
>>> import numpy as np, pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 4000
>>> x = rng.uniform(0, 1, n)
>>> dose = rng.uniform(0, 10, n)
>>> df = pd.DataFrame({"x": x, "dose": dose,
... "y": (1 + 2 * x) * dose + rng.normal(size=n)})
>>> good = sp.cate_gain_curve(df, cate="x", y="y", treat="dose")
>>> noise = sp.cate_gain_curve(df, cate=rng.normal(size=n), y="y",
... treat="dose")
>>> bool(good.auc > abs(noise.auc))
True
blp_test ¶
blp_test(result: CausalResult, data: DataFrame, y: str, treat: str, covariates: List[str], n_folds: int = 5, alpha: float = 0.05, *, propensity: Any = None, baseline: Any = None, proxy: Any = 'cross_fit', vce: str = 'HC1', seed: int = 42) -> Dict[str, Any]
Best Linear Predictor (BLP) of the CATE, Chernozhukov-Demirer-Duflo-Fernandez-Val.
Weighted least squares with weights w = 1 / (p(Z)(1 - p(Z)))::
Y = a0 + a1 B(Z) + beta_1 (D - p(Z))
+ beta_2 (D - p(Z)) (S(Z) - mean(S)) + e
where S(Z) is the CATE proxy, p(Z) the propensity score and
B(Z) an optional baseline proxy (e.g. a prediction of E[Y | Z,
D = 0]). beta_1 is the ATE; beta_2 is the slope of the true
CATE on the proxy -- beta_2 = 0 means the proxy carries no
heterogeneity signal, beta_2 = 1 a perfectly calibrated one. This
is GenericML::BLP and, fed the same Y, D, p, B, S, reproduces
it to the floating-point floor.
The proxy must not be fit on the outcomes it is evaluated against:
in-sample predictions of a flexible learner correlate with the noise in
Y and push beta_2 up even when the effect is constant. By
default (proxy='cross_fit') the metalearner behind result is
refit out of fold (n_folds, seed) to produce S.
.. versionchanged:: 1.30.0
Now the CDDF weighted regression with an out-of-fold proxy.
Earlier releases ran unweighted OLS on the in-sample CATE
predictions: with no true heterogeneity the default T-learner then
reported beta_2 near 1.4 with p < 1e-50. The docstring's
claim of equivalence to grf::test_calibration was also wrong;
that test is :func:sp.calibration_test.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult, fitted CATE model, or array
|
A |
required |
data
|
DataFrame
|
|
required |
y
|
str
|
Outcome and binary treatment columns. |
required |
treat
|
str
|
Outcome and binary treatment columns. |
required |
covariates
|
list of str
|
Covariates |
required |
n_folds
|
int
|
Folds for the propensity and proxy cross-fits. |
5
|
alpha
|
float
|
|
0.05
|
propensity
|
array or column name
|
Known or estimated |
None
|
baseline
|
array or column name
|
Baseline proxy |
None
|
proxy
|
(cross_fit, in_sample)
|
Source of |
'cross_fit'
|
vce
|
(HC1, const, HC0, HC2, HC3)
|
Covariance of the WLS fit ( |
'HC1'
|
seed
|
int
|
Fold seed for the proxy refit. |
42
|
Returns:
| Type | Description |
|---|---|
dict
|
|
References
Chernozhukov, Demirer, Duflo and Fernandez-Val (2025), Econometrica 93(4), 1121-1164. [@chernozhukov2025generic]
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 800
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> d = rng.integers(0, 2, size=n)
>>> y = 0.5 * x1 + 0.3 * x2 + (1.0 + x1) * d + rng.normal(size=n)
>>> df = pd.DataFrame({"y": y, "d": d, "x1": x1, "x2": x2,
... "p": np.full(n, 0.5)})
>>> res = sp.metalearner(df, y="y", treat="d",
... covariates=["x1", "x2"], learner="t")
>>> blp = sp.blp_test(res, df, y="y", treat="d",
... covariates=["x1", "x2"], propensity="p")
>>> blp["heterogeneity_significant"]
True
>>> blp["proxy_source"]
'cross_fit'
cate_by_group ¶
cate_by_group(result: Any, data: DataFrame, by: str, n_groups: int = 4, alpha: float = 0.05) -> DataFrame
Group-level average treatment effects.
Splits the CATE distribution by a covariate (or by CATE quartiles
if by='cate') and reports group means with confidence intervals.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult, CausalForest, or array-like
|
Result from |
required |
data
|
DataFrame
|
Original data (same rows as the estimation sample). |
required |
by
|
str
|
Column name to group by, or 'cate' to group by CATE quartiles. |
required |
n_groups
|
int
|
Number of quantile groups when |
4
|
alpha
|
float
|
Significance level for CIs. |
0.05
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Columns: group, n, mean_cate, se, ci_lower, ci_upper. |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 400
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> d = rng.integers(0, 2, size=n)
>>> y = 0.5 * x1 + 0.3 * x2 + (1.0 + x1) * d + rng.normal(size=n)
>>> df = pd.DataFrame({"y": y, "d": d, "x1": x1, "x2": x2})
>>> res = sp.metalearner(df, y="y", treat="d",
... covariates=["x1", "x2"], learner="t")
>>> groups = sp.cate_by_group(res, df, by="cate", n_groups=4)
>>> groups["n"].tolist()
[100, 100, 100, 100]
>>> round(float(groups["mean_cate"].iloc[-1]), 2)
2.7
cate_group_plot ¶
cate_group_plot(group_df: DataFrame, ax: Any = None, figsize: tuple = (8, 5), color: str = '#2C3E50', title: Optional[str] = None) -> Tuple[Any, Any]
Plot group-level CATEs with confidence intervals.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
group_df
|
DataFrame
|
Output from |
required |
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(8, 5)
|
color
|
str
|
|
'#2C3E50'
|
title
|
str
|
|
None
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 400
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> d = rng.integers(0, 2, size=n)
>>> y = 0.5 * x1 + 0.3 * x2 + (1.0 + x1) * d + rng.normal(size=n)
>>> df = pd.DataFrame({"y": y, "d": d, "x1": x1, "x2": x2})
>>> res = sp.metalearner(df, y="y", treat="d",
... covariates=["x1", "x2"], learner="t")
>>> groups = sp.cate_by_group(res, df, by="cate", n_groups=4)
>>> fig, ax = sp.cate_group_plot(groups)
cate_plot ¶
cate_plot(result: CausalResult, kind: str = 'hist', ax: Any = None, figsize: tuple = (8, 5), color: str = '#2C3E50', title: Optional[str] = None, **kwargs: Any) -> Tuple[Any, Any]
Plot the CATE distribution.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Result from |
required |
kind
|
str
|
'hist' for histogram, 'kde' for kernel density, 'both'. |
'hist'
|
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(8, 5)
|
color
|
str
|
|
'#2C3E50'
|
title
|
str
|
|
None
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 400
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> d = rng.integers(0, 2, size=n)
>>> y = 0.5 * x1 + 0.3 * x2 + (1.0 + x1) * d + rng.normal(size=n)
>>> df = pd.DataFrame({"y": y, "d": d, "x1": x1, "x2": x2})
>>> res = sp.metalearner(df, y="y", treat="d",
... covariates=["x1", "x2"], learner="t")
>>> fig, ax = sp.cate_plot(res, kind="hist")
cate_summary ¶
Descriptive statistics of the CATE distribution.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Result from |
required |
Returns:
| Type | Description |
|---|---|
DataFrame
|
Summary statistics of the fitted conditional effects: mean, sd,
min, q25, median, q75, max, fraction positive, IQR, N. The
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 400
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> d = rng.integers(0, 2, size=n)
>>> y = 0.5 * x1 + 0.3 * x2 + (1.0 + x1) * d + rng.normal(size=n)
>>> df = pd.DataFrame({"y": y, "d": d, "x1": x1, "x2": x2})
>>> res = sp.metalearner(df, y="y", treat="d",
... covariates=["x1", "x2"], learner="t")
>>> tab = sp.cate_summary(res)
>>> int(tab.loc["N", "CATE"])
400
>>> round(float(tab.loc["Frac. Positive", "CATE"]), 2)
0.78
compare_metalearners ¶
compare_metalearners(data: DataFrame, y: str, treat: str, covariates: List[str], learners: Optional[List[str]] = None, **kwargs: Any) -> DataFrame
Fit multiple meta-learners and compare their ATE estimates.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Input data. |
required |
y
|
str
|
Outcome variable. |
required |
treat
|
str
|
Binary treatment variable (0/1). |
required |
covariates
|
list of str
|
Covariate / effect modifier variables. |
required |
learners
|
list of str
|
Which learners to compare. Default: all five ('s','t','x','r','dr'). |
None
|
**kwargs
|
Any
|
Additional arguments passed to |
{}
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Comparison table with columns: learner, ate, se, ci_lower, ci_upper, pvalue, cate_std, cate_iqr. |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 400
>>> age = rng.normal(size=n)
>>> edu = rng.normal(size=n)
>>> training = rng.integers(0, 2, size=n)
>>> wage = 0.5 * age + 0.3 * edu + (1.0 + age) * training + rng.normal(size=n)
>>> df = pd.DataFrame({"wage": wage, "training": training,
... "age": age, "edu": edu})
>>> comp = sp.compare_metalearners(df, y='wage', treat='training',
... covariates=['age', 'edu'])
>>> "ate" in comp.columns
True
>>> int(comp.shape[0])
5
gate_test ¶
gate_test(result: CausalResult, data: DataFrame, by: str, n_groups: int = 4, alpha: float = 0.05, *, y: Optional[str] = None, treat: Optional[str] = None, covariates: Optional[List[str]] = None, propensity: Any = None, baseline: Any = None, proxy: Any = 'cross_fit', vce: str = 'HC1', n_folds: int = 5, seed: int = 42, clan: Optional[List[str]] = None) -> Dict[str, Any]
Sorted group average treatment effects (GATES) and heterogeneity tests.
With y, treat and covariates this is the
Chernozhukov-Demirer-Duflo-Fernandez-Val GATES regression
(GenericML::GATES with monotonize = FALSE): weighted least
squares with weights 1 / (p(1 - p)) of Y on an intercept, the
optional baseline proxy B(Z) and (D - p) 1{G_k} for groups
G_1..G_K. Groups are quantile groups of the CATE proxy when
by='cate' (GenericML::quantile_group: type-7 cut points,
left-closed) and of the column by otherwise (its values are used
directly when it has at most n_groups levels). Each gamma_k
is the ATE in group k, estimated from outcomes;
top_vs_bottom is gamma_K - gamma_1 with the covariance term;
the omnibus test is the Wald test that all gamma_k are equal
(chi-square, K - 1 df). The proxy defaults to an out-of-fold refit
of the result's learner, as in :func:blp_test.
Without them the function falls back to the pre-1.30 descriptive
summary: group means of the predicted CATEs with sd / sqrt(n)
SEs. Those p-values treat the model's own predictions as data -- with
by='cate' the groups are formed from the same predictions -- so
they are not a test of heterogeneity; a warning says so.
.. versionchanged:: 1.30.0
Added the GATES regression (y / treat / covariates).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult, fitted CATE model, or array
|
|
required |
data
|
DataFrame
|
|
required |
by
|
str
|
|
required |
n_groups
|
int
|
|
4
|
alpha
|
float
|
|
0.05
|
y
|
optional
|
Outcome, binary treatment and covariates; required for GATES. |
None
|
treat
|
optional
|
Outcome, binary treatment and covariates; required for GATES. |
None
|
covariates
|
optional
|
Outcome, binary treatment and covariates; required for GATES. |
None
|
propensity
|
Any
|
As in :func: |
None
|
baseline
|
Any
|
As in :func: |
None
|
proxy
|
Any
|
As in :func: |
None
|
vce
|
Any
|
As in :func: |
None
|
n_folds
|
Any
|
As in :func: |
None
|
seed
|
Any
|
As in :func: |
None
|
clan
|
list of str
|
Classification analysis (CLAN; R |
None
|
Returns:
| Type | Description |
|---|---|
dict
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 800
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> d = rng.integers(0, 2, size=n)
>>> y = 0.5 * x1 + 0.3 * x2 + (1.0 + x1) * d + rng.normal(size=n)
>>> df = pd.DataFrame({"y": y, "d": d, "x1": x1, "x2": x2,
... "p": np.full(n, 0.5)})
>>> res = sp.metalearner(df, y="y", treat="d",
... covariates=["x1", "x2"], learner="t")
>>> gt = sp.gate_test(res, df, by="cate", y="y", treat="d",
... covariates=["x1", "x2"], propensity="p")
>>> gt["method"]
'gates'
>>> bool(gt["top_vs_bottom_pvalue"] < 0.05)
True
predict_cate ¶
Predict CATE on new (out-of-sample) data.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Result from |
required |
new_data
|
DataFrame
|
New data with the same covariate columns used in estimation. |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
Predicted CATE for each row of new_data. |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 400
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> d = rng.integers(0, 2, size=n)
>>> y = 0.5 * x1 + 0.3 * x2 + (1.0 + x1) * d + rng.normal(size=n)
>>> df = pd.DataFrame({"y": y, "d": d, "x1": x1, "x2": x2})
>>> res = sp.metalearner(df, y="y", treat="d",
... covariates=["x1", "x2"], learner="t")
>>> new_data = pd.DataFrame({"x1": [-1.0, 0.0, 1.0],
... "x2": [0.0, 0.0, 0.0]})
>>> tau_hat = sp.predict_cate(res, new_data)
>>> int(tau_hat.shape[0])
3
>>> bool(tau_hat[2] > tau_hat[0])
True
focal_cate ¶
focal_cate(data: DataFrame, y_columns: List[str], treat: str, covariates: List[str], test_data: Optional[DataFrame] = None, seed: int = 0) -> FunctionalCATEResult
Functional doubly-robust CATE estimator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Training data with outcome columns y_columns (one per function evaluation point t). |
required |
y_columns
|
list of str
|
Outcome columns; len = number of function points. |
required |
treat
|
str
|
|
required |
covariates
|
list of str
|
|
required |
test_data
|
DataFrame
|
Defaults to |
None
|
seed
|
int
|
|
0
|
Returns:
| Type | Description |
|---|---|
FunctionalCATEResult
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 400
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> d = rng.integers(0, 2, size=n)
>>> tau = 1.0 + 0.5 * x1 # heterogeneous effect
>>> y0 = 2.0 + x2 + rng.normal(scale=0.5, size=n)
>>> y1 = y0 + tau
>>> df = pd.DataFrame({
... "y_t0": np.where(d == 1, y1, y0),
... "y_t1": np.where(d == 1, y1 + 0.3, y0),
... "d": d, "x1": x1, "x2": x2,
... })
>>> res = sp.focal_cate(
... df, y_columns=["y_t0", "y_t1"], treat="d",
... covariates=["x1", "x2"], seed=0,
... )
>>> res.cate_grid.shape # (n_test, n_function_points)
(400, 2)
metalearner ¶
metalearner(data: DataFrame, y: str, treat: Optional[str] = None, covariates: Optional[List[str]] = None, learner: str = 'dr', outcome_model: Optional[Any] = None, propensity_model: Optional[Any] = None, cate_model: Optional[Any] = None, n_folds: int = 5, n_bootstrap: int = 200, alpha: float = 0.05, d: Optional[str] = None, x: Optional[List[str]] = None, fold_indices: Optional[Any] = None, weights: Optional[str] = None, cluster: Optional[str] = None) -> CausalResult
Estimate heterogeneous treatment effects using meta-learners.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Input data. |
required |
y
|
str
|
Outcome variable. |
required |
treat
|
str
|
Binary treatment variable (0/1). |
None
|
covariates
|
list of str
|
Covariate / effect modifier variables. A text or |
None
|
learner
|
str
|
Meta-learner type: 's', 't', 'x', 'r', or 'dr'. |
'dr'
|
outcome_model
|
sklearn estimator or (control, treated) pair
|
Custom ML model for outcome nuisance. For |
None
|
propensity_model
|
sklearn estimator
|
Custom propensity score model (used by X/R/DR learners). |
None
|
cate_model
|
sklearn estimator or (control, treated) pair
|
Custom model for final CATE stage (X/R/DR learners). The default
of the R- and DR-learners is gradient boosting with at least 20
observations per leaf, because the pseudo-outcomes they regress on
have heavy tails. For |
None
|
n_folds
|
int
|
Cross-fitting folds for nuisance estimation (used by R/DR learners and the unified AIPW SE path; see Notes). |
5
|
n_bootstrap
|
int
|
Deprecated and ignored as of v1.11.4. Previously the SE for
S/T/X/R-Learner came from a re-sampling bootstrap of the fitted
CATE values, which treats τ̂ as fixed and severely
under-estimates uncertainty. The function now uses the AIPW
influence function for SE regardless of |
200
|
alpha
|
float
|
Significance level. |
0.05
|
fold_indices
|
array-like of int
|
Explicit cross-fitting partition: one label per row of |
None
|
weights
|
str
|
Sampling-weight column. The AIPW score behind |
None
|
cluster
|
str
|
Cluster column. Cross-fitting folds are formed over whole clusters
and the SE sums the influence function within clusters, centred,
with |
None
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
Result with ATE estimate, SE, CI, p-value, and individual
CATE predictions accessible via |
Notes
ATE / SE convention (v1.11.4+). Regardless of which CATE
estimator the user selects via learner=, the population ATE and
its SE are computed via the AIPW (DR) pseudo-outcome:
.. math::
\varphi_i = \hat\mu_1(X_i) - \hat\mu_0(X_i) + \frac{D_i (Y_i - \hat\mu_1(X_i))}{\hat e(X_i)} - \frac{(1-D_i)(Y_i - \hat\mu_0(X_i))}{1 - \hat e(X_i)}
with :math:\hat{\rm ATE} = \bar\varphi,
:math:\widehat{\rm SE} = \sigma(\varphi)/\sqrt n. AIPW is
the semiparametric-efficient estimating function for
:math:E[Y(1) - Y(0)] (van der Laan & Robins 2003; Kennedy 2023),
so the SE is valid for any CATE estimator. The chosen
learner= determines τ̂(X) (heterogeneity); ATE inference is
learner-independent.
Prior to v1.11.4, S/T/X/R-Learner used mean(τ̂) as ATE and a
re-sampling bootstrap of τ̂ as SE. The bootstrap silently treated
τ̂ as fixed → systematically too small SEs and severe under-
coverage. ⚠️ This is a correctness fix; numerical results will
change for non-DR learners.
What se does and does not describe. A consequence of the
convention above is that result.estimate and result.se are
numerically identical across learner='s', 't', 'x' and
'dr' when the same outcome and propensity models are supplied:
they describe the AIPW average, not the chosen learner. The
learner-specific quantity is the fitted effect function
result.model_info['cate'] and its mean
result.model_info['cate_mean'], for which no standard error is
provided (model_info['cate_mean_se'] is None). Pairing
cate_mean with se produces an interval for the wrong
functional; the learner-controlled Monte Carlo in the ML4CI companion
paper shows the resulting coverage number measures the mismatch
rather than the estimator. To compare learners at the average,
compare cate_mean across calls and bootstrap the whole procedure
for its uncertainty.
Continuous treatment (learner='r'). The R-loss
:math:\sum_i [(Y_i - \hat m(X_i)) - \tau(X_i)(D_i - \hat e(X_i))]^2
is defined for any real-valued treatment, with
:math:\hat e(X) = \hat E[D \mid X] fitted by a regressor
(propensity_model, default the outcome model's default). Then
model_info['cate'] is the effect of one more unit of the treatment
at each X, under an outcome linear in the treatment given X.
estimate is the partially linear coefficient
:math:\sum_i \tilde D_i \tilde Y_i / \sum_i \tilde D_i^2 on the
cross-fitted residuals (estimand='APE'): the average of
:math:\tau(X) weighted by the conditional variance of the treatment,
with the standard error of its influence function. There are no
propensity scores to clip, so the overlap diagnostics are empty.
References
Künzel, S. R., Sekhon, J. S., Bickel, P. J. and Yu, B. (2019). Metalearners for estimating heterogeneous treatment effects using machine learning. Proceedings of the National Academy of Sciences, 116(10), 4156-4165. [@kunzel2019metalearners]
Kennedy, E. H. (2023). Towards optimal doubly robust estimation of heterogeneous causal effects. Electronic Journal of Statistics, 17(2), 3008-3049. [@kennedy2023towards]
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 300
>>> age = rng.normal(40, 8, n)
>>> edu = rng.normal(13, 2, n)
>>> training = (rng.random(n) < 0.5).astype(int)
>>> wage = 0.05 * age + 0.3 * edu + (2.0 + 0.1 * edu) * training \
... + rng.normal(size=n)
>>> df = pd.DataFrame({"wage": wage, "training": training,
... "age": age, "edu": edu})
>>> result = sp.metalearner(df, y='wage', treat='training',
... covariates=['age', 'edu'],
... learner='dr', n_folds=3)
>>> bool(isinstance(result.estimate, float))
True
>>> cate = result.model_info['cate'] # per-unit effects
>>> bool(cate.shape == (n,))
True