ergodicDocs
Products: switch from ergodic to another product
Get started

GuidesEstimate heterogeneous effects

Estimate heterogeneous effects

Estimate the effect of a treatment for each unit, then test whether the effect really varies and describe who gains most. The example uses a simulation, so you can compare every estimate with the truth.

You'll need

  • ergodic, installed with pip install "ergodic".
  • A binary treatment, and a graph in which the covariates that change the effect are causes of the outcome.

Run the program#

The simulated treatment helps patients with a high need score more. Older patients are treated more often, so age confounds the comparison.

heterogeneous_effects.pyDownload
import numpy as np
import pandas as pd
from ergodic import cate, dag, tabular

# 1. Simulate 2,000 patients. The true effect of the drug is 1 + 2 * need.
rng = np.random.default_rng(0)
n = 2000
age = rng.normal(0, 1, n)
need = rng.normal(0, 1, n)
drug = rng.binomial(1, 1 / (1 + np.exp(-age)))
true_effect = 1 + 2 * need
score = age + need + true_effect * drug + rng.normal(0, 1, n)
frame = pd.DataFrame({"age": age, "need": need, "drug": drug, "score": score})
data = tabular(frame, types={"drug": "categorical"})

# 2. Draw the graph: age confounds; need only changes the outcome.
graph = dag(["age -> drug", "age -> score", "need -> score", "drug -> score"])

# 3. Estimate one effect per patient, with an interval for each.
effects = cate(graph, data, "drug", "score", random_state=0)
print(effects.source, effects.kind, effects.covariates)
print(effects.to_frame().head(3).round(2))
error = np.sqrt(np.mean((effects.values - true_effect) ** 2))
print(f"mean effect {effects.values.mean():.2f}, error against the truth {error:.2f}")

# 4. Test for heterogeneity, then describe it.
summary = effects.explain()
print(f"heterogeneity p-value: {summary.calibration_test().heterogeneity_pvalue:.1e}")
for name, row in summary.blp().frame().items():
    print(f"{name:12} {row['coef']:5.2f}  [{row['ci_low']:.2f}, {row['ci_high']:.2f}]")
print("effect by predicted fifth:", summary.gates().group_effect.round(2))
Recorded output
dr_learner analytic ('age', 'need')
      effect  std_error  ci_low  ci_high
unit
0       1.91       0.07    1.80     2.02
1       0.06       0.07   -0.06     0.17
2      -0.57       0.08   -0.71    -0.43
mean effect 1.06, error against the truth 0.12
heterogeneity p-value: 2.8e-193
(intercept)   1.06  [0.97, 1.16]
age           0.10  [-0.03, 0.23]
need          1.98  [1.87, 2.09]
effect by predicted fifth: [-1.63 -0.07  0.95  2.12  3.92]

Read the summaries#

effects.explain() returns a Heterogeneity record. Its methods summarize the doubly robust scores, not the per-unit points, so their intervals are valid even when the per-unit model is wrong (Semenova and Chernozhukov, 2021; Chernozhukov, Demirer, Duflo and Fernández-Val, 2025; Yadlowsky and others, 2024).

Method Question it answers
calibration_test() Is there heterogeneity, and is it calibrated? heterogeneity_pvalue is one-sided.
blp(level=0.9) Which covariates go with a bigger effect? The intercept is the effect where every covariate is 0; it equals the average effect only when the covariates are centred.
gates(n_groups=5) Does the measured effect rise across groups of predicted effect? difference is top minus bottom.
clan(n_groups=5) How do the most and least affected groups differ in their covariates?
rate("autoc") Does ranking by predicted effect put the biggest gains first? Also "qini".
policy_tree(depth=2) Whom should you treat? See Target a policy.

Covariates come from the graph

cate uses the adjustment set of the identified effect as its covariates. A column that changes the effect but is missing from the graph is never used. Draw it as a cause of the outcome, as need is here.

Choose a method#

method= picks the learner. The default "dr_learner" is doubly robust. Other names: "r_learner", "x_learner", "t_learner", "s_learner", "dml" and "causal_forest". learner= fills the nuisance models, as in estimate_effect. The same S/T names work with estimate_effect(strategy=...): t_learner fits separate outcome models for each arm, and s_learner fits one pooled model. Passing a contradictory per_arm= flag raises an error in either facade.

Limits
  • cate needs a binary treatment and an effect identified by adjustment. Front-door, instrumental-variable and general-formula identifications raise an error.
  • explain() describes the units the model was fit on. To score new rows, fit an estimator such as DRLearner with fit(identification, data), then call its cate(new_rows). That result has no scores, so its explain() raises.
  • New rows use the covariate types and category vocabulary saved during fit. Column order and categorical metadata order can change, and a batch can omit known category levels. Unknown values, missing required columns, changed covariate types and missing covariate values raise an error. Treatment and outcome columns are unnecessary when scoring new rows.
  • If a fold fails while building the out-of-fold effect proxy, explain().blp() still projects the cross-fit scores on covariates. GATES, CLAN, RATE and calibration refuse to return intervals or p-values and report the ranking failure. They never substitute the fitted in-sample effects. select_cate also refuses a candidate without an out-of-fold proxy. DML supplies constant effects with no out-of-fold CATE ranking, so it also refuses these summaries while retaining BLP.
  • effects.ate() returns the average effect. With the DR-learner it is the mean of the doubly robust scores, whose standard error is valid (the per-unit standard errors share one fitted model, so they are never combined as if independent); for the default linear final model its point equals the mean per-unit effect. A result without scores (one scored on new rows) returns the mean as a point, with no interval; use estimate_effect then.

Next steps#

Documentation

Search ergodic 0.3.2

Search ergodic by task, function or error.