# Build and label the diagnostic visualization for the Setup section.
from pathlib import Path
import os
import time
import warnings
PROJECT_ROOT = Path.cwd().resolve()
if PROJECT_ROOT.name == "doubleml":
PROJECT_ROOT = PROJECT_ROOT.parents[2]
OUTPUT_DIR = PROJECT_ROOT / "notebooks" / "tutorials" / "doubleml" / "outputs"
DATASET_DIR = OUTPUT_DIR / "datasets"
FIGURE_DIR = OUTPUT_DIR / "figures"
TABLE_DIR = OUTPUT_DIR / "tables"
MATPLOTLIB_CACHE_DIR = OUTPUT_DIR / "matplotlib_cache"
for directory in [DATASET_DIR, FIGURE_DIR, TABLE_DIR, MATPLOTLIB_CACHE_DIR]:
directory.mkdir(parents=True, exist_ok=True)
os.environ.setdefault("MPLCONFIGDIR", str(MATPLOTLIB_CACHE_DIR))
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", message="IProgress not found.*")
warnings.filterwarnings("ignore", message="X does not have valid feature names.*")
import numpy as np
import pandas as pd
pd.set_option("display.max_colwidth", None)
pd.set_option("display.max_columns", None)
pd.set_option("display.width", 0)
import matplotlib.pyplot as plt
import seaborn as sns
from IPython.display import Markdown, display
from scipy import stats
import doubleml as dml
from doubleml import DoubleMLData, DoubleMLPLR
from sklearn.base import clone
from sklearn.linear_model import Ridge, RidgeCV
from sklearn.metrics import mean_absolute_error, mean_squared_error
from sklearn.model_selection import KFold
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
NOTEBOOK_PREFIX = "12"
RANDOM_SEED = 42
TRUE_THETAS = {"d_main": 1.00, "d_secondary": 0.45, "d_null": 0.00}
TREATMENT_COLS = list(TRUE_THETAS.keys())
sns.set_theme(style="whitegrid", context="notebook")
plt.rcParams.update({"figure.dpi": 120, "savefig.dpi": 160})
print(f"DoubleML version: {dml.__version__}")12: Inference, Bootstrap, and Confidence Bands
This lesson is about uncertainty. DoubleML returns a treatment-effect point estimate; it also returns standard errors, confidence intervals, bootstrap-based joint intervals, and adjusted p-values for multiple testing.
The basic inference logic is asymptotic. Under regularity conditions, the orthogonal score behaves like an average of approximately mean-zero influence terms. That gives an approximate normal distribution for the estimator:
\[ \frac{\hat{\theta} - \theta_0}{\widehat{\mathrm{se}}(\hat{\theta})} \approx N(0, 1). \]
From that approximation, DoubleML reports standard errors, t-statistics, p-values, and pointwise confidence intervals. But many applied analyses estimate more than one treatment effect. Once several effects are examined together, pointwise intervals can become too optimistic if they are read as a family-level statement. That is where multiplier bootstrap, joint intervals, and multiple-testing adjustment enter.
We use a synthetic multi-treatment PLR design with three treatment variables:
d_main: a strong positive effect,d_secondary: a smaller positive effect,d_null: a true zero effect.
That mix lets us see how inference behaves for strong signals, moderate signals, and null effects. The key teaching goal is not to memorize one API call. The goal is to learn how to report uncertainty honestly:
- Use standard errors and pointwise confidence intervals for individual effects.
- Use bootstrap-based joint intervals when making simultaneous claims.
- Use adjusted p-values when testing several effects at once.
- Treat inference as conditional on the causal design and identifying assumptions.
Expected runtime: usually under one minute. The lesson runs one main multi-treatment DoubleML fit, several bootstrap calls, and a small coverage simulation.
Dataset and Experiment Setup
We use controlled semi-synthetic datasets with known nuisance functions or known treatment effects to study Inference, Bootstrap, And Confidence Bands. Double machine learning is easiest to understand when the data contain realistic nuisance structure but still give us a benchmark for the target parameter. That lets the lesson separate estimation error from identification failure.
Read each row as a unit with pre-treatment covariates, a treatment or instrument, and an outcome. The learner comparisons are experiments about orthogonalization, cross-fitting, overlap, sample splitting, and decision targets. The experiment treats uncertainty as an object to estimate and report directly. Resampling behavior is interpreted against the known design. The experiment treats uncertainty as an object to estimate and report directly. Intervals are judged by whether they communicate sampling variation around the target.
The estimators are causal only under the stated design assumptions. Flexible machine-learning nuisance models help with prediction bias, but they do not replace causal identification.
Mathematical Foundation
Inference usually relies on the asymptotic approximation
\[ \sqrt n(\widehat\theta-\theta_0) \Rightarrow N(0,\sigma^2). \]
A standard confidence interval is
\[ \widehat\theta \pm z_{1-\alpha/2}\widehat{se}(\widehat\theta). \]
Bootstrap and confidence-band tools estimate uncertainty around scalar effects or effect functions, not uncertainty in nuisance prediction alone.
Tutorial Workflow
Setup
The code below prepares the output folders, imports DoubleML and scientific Python tools, and applies a few narrow warning filters for known lesson-environment noise. The code remains visible so the workflow can be studied and rerun.
The setup confirms the installed DoubleML version and where generated outputs will be saved. Every table and figure in this lesson uses prefix 12.
This reproducibility check keeps the run auditable before causal interpretation begins. It makes the run consistent enough for later graphs, tables, and estimates to be checked against the same environment.
Helper Functions
The helper functions below keep the tutorial code compact. They save outputs, build a DoubleML backend, compute nuisance prediction metrics, and format confidence intervals.
The inference helpers use transparent normal-theory formulas. DoubleML already computes these quantities, but writing them explicitly makes the connection between standard errors, z critical values, and interval width easier to see.
# Define reusable helpers for the Helper Functions section.
def save_table(df, filename):
"""
Idea: Save a result table to the notebook table directory and return the same DataFrame for display.
Parameters
----------
df : pd.DataFrame
Rows used by this helper.
filename : str
Output filename used by the helper.
Returns
-------
pd.DataFrame
Same table after writing it to the tutorial table directory.
"""
path = TABLE_DIR / filename
df.to_csv(path, index=False)
return path
def save_dataset(df, filename):
"""
Idea: Write the constructed dataset to the tutorial output folder so later notebooks or cells can reuse it.
Parameters
----------
df : pd.DataFrame
Rows used by this helper.
filename : str
Output filename used by the helper.
Returns
-------
pd.DataFrame
Same dataset after writing it to the tutorial dataset directory.
"""
path = DATASET_DIR / filename
df.to_csv(path, index=False)
return path
def model_x_cols(df):
"""
Idea: Select the covariate columns used by the model while excluding treatment, outcome, and oracle columns.
Parameters
----------
df : pd.DataFrame
Rows used by this helper.
Returns
-------
list[str]
Covariate column names used as model inputs after excluding treatment, outcome, and oracle fields.
"""
excluded = {"unit_id", "outcome", "true_g", "true_m_main", "true_m_secondary", "true_m_null"}.union(TREATMENT_COLS)
return [col for col in df.columns if col not in excluded]
def make_dml_data(df):
"""
Idea: Construct the double machine learning data used in the Helper Functions section, including columns needed for later diagnostics.
Parameters
----------
df : pd.DataFrame
Rows used by this helper.
Returns
-------
DoubleMLData
DoubleMLData object configured with outcome, treatment, and covariate columns for the tutorial estimator.
"""
return DoubleMLData(df, y_col="outcome", d_cols=TREATMENT_COLS, x_cols=model_x_cols(df))
def rmse_metric(y_true, y_pred):
"""
Idea: Compute an RMSE value from a fitted learner and evaluation data.
Parameters
----------
y_true : array-like
Reference outcome, oracle value, or known target used for evaluation.
y_pred : array-like
Predicted outcome or estimated value being evaluated.
Returns
-------
float
Root mean squared prediction error for the fitted learner on evaluation data.
"""
mask = ~np.isnan(y_true)
return mean_squared_error(y_true[mask], y_pred[mask]) ** 0.5
def mae_metric(y_true, y_pred):
"""
Idea: Compute mean absolute error from a fitted learner and evaluation data.
Parameters
----------
y_true : array-like
Reference outcome, oracle value, or known target used for evaluation.
y_pred : array-like
Predicted outcome or estimated value being evaluated.
Returns
-------
float
Mean absolute prediction error for the fitted learner on evaluation data.
"""
mask = ~np.isnan(y_true)
return mean_absolute_error(y_true[mask], y_pred[mask])
def ci_from_coef_se(coef, se, level):
"""
Idea: Turn a coefficient and standard error into a confidence interval at the requested level.
Parameters
----------
coef : object
Estimated coefficient being converted into an interval or test statistic.
se : object
Standard error paired with the coefficient.
level : object
Confidence level used to construct the interval.
Returns
-------
tuple[float, float, float]
Lower bound, upper bound, and critical value for the requested confidence level.
"""
alpha = 1 - level
critical_value = stats.norm.ppf(1 - alpha / 2)
return coef - critical_value * se, coef + critical_value * se, critical_value
def flatten_boot_t_stats(boot_t_stat, treatment_cols):
"""
Idea: Flatten the flatten boot t stats output into a one-dimensional structure used by the inference display.
Parameters
----------
boot_t_stat : object
Bootstrap t-statistics used for simultaneous inference.
treatment_cols : list[str]
Column names selected for `treatment`.
Returns
-------
pd.DataFrame
Long-format table of bootstrap t-statistics by repetition and coefficient.
"""
rows = []
n_boot, n_coef, n_rep = boot_t_stat.shape
for boot_id in range(n_boot):
for coef_id, treatment in enumerate(treatment_cols):
for rep_id in range(n_rep):
rows.append(
{
"boot_id": boot_id,
"treatment": treatment,
"rep_id": rep_id + 1,
"boot_t_stat": boot_t_stat[boot_id, coef_id, rep_id],
}
)
return pd.DataFrame(rows)These helpers are reusable. The key one for this lesson is ci_from_coef_se(), which mirrors the pointwise normal interval logic that appears in the DoubleML summary.
Inference Vocabulary
The vocabulary table clarifies what the lesson means by standard error, confidence interval, joint interval, and adjusted p-value. These terms are closely related, but they answer different reporting questions.
| term | meaning | reporting question |
|---|---|---|
| Standard error | Estimated sampling variability of the treatment-effect estimator. | How noisy is this point estimate? |
| Pointwise confidence interval | Interval for one parameter using a marginal critical value. | What range is plausible for this one effect? |
| Multiplier bootstrap | Resamples score contributions through random multipliers to approximate estimator uncertainty. | How can we approximate the distribution of t-statistics without refitting nuisances many times? |
| Joint confidence interval | Simultaneous interval family designed to cover all selected parameters together. | What ranges are plausible if I discuss all effects as a family? |
| Adjusted p-value | P-value corrected for multiple testing across several hypotheses. | Which effects remain evidence-bearing after accounting for multiple looks? |
The distinction between pointwise and joint uncertainty is central. A pointwise 95% interval is not the same thing as a 95% statement about several intervals at once.
Synthetic Multi-Treatment PLR Data
We generate a linear nuisance design so the inference examples are clean. The outcome has three treatment variables: two have true nonzero effects and one has a true zero effect. The nuisance functions depend on observed controls, so adjustment is still necessary.
The data-generating process is simple enough for Ridge learners to perform well. This lesson is about inference mechanics, not learner complexity.
rng = np.random.default_rng(RANDOM_SEED)
n_obs = 900
n_controls = 12
x_array = rng.normal(size=(n_obs, n_controls))
inference_df = pd.DataFrame(x_array, columns=[f"x{i:02d}" for i in range(n_controls)])
inference_df.insert(0, "unit_id", np.arange(n_obs))
inference_df["true_m_main"] = 0.60 * inference_df["x00"] - 0.40 * inference_df["x01"] + 0.20 * inference_df["x04"]
inference_df["true_m_secondary"] = -0.30 * inference_df["x00"] + 0.50 * inference_df["x03"] + 0.25 * inference_df["x05"]
inference_df["true_m_null"] = 0.20 * inference_df["x06"] - 0.20 * inference_df["x08"] + 0.20 * inference_df["x09"]
inference_df["true_g"] = (
0.80 * inference_df["x00"]
+ 0.50 * inference_df["x01"]
- 0.40 * inference_df["x02"]
+ 0.30 * inference_df["x03"]
+ 0.25 * inference_df["x07"]
)
inference_df["d_main"] = inference_df["true_m_main"] + rng.normal(scale=1.00, size=n_obs)
inference_df["d_secondary"] = inference_df["true_m_secondary"] + rng.normal(scale=1.00, size=n_obs)
inference_df["d_null"] = inference_df["true_m_null"] + rng.normal(scale=1.00, size=n_obs)
inference_df["outcome"] = (
TRUE_THETAS["d_main"] * inference_df["d_main"]
+ TRUE_THETAS["d_secondary"] * inference_df["d_secondary"]
+ TRUE_THETAS["d_null"] * inference_df["d_null"]
+ inference_df["true_g"]
+ rng.normal(scale=1.00, size=n_obs)
)
save_dataset(inference_df, f"{NOTEBOOK_PREFIX}_synthetic_multitreatment_plr_data.csv")
inference_df.head()| unit_id | x00 | x01 | x02 | x03 | x04 | x05 | x06 | x07 | x08 | x09 | x10 | x11 | true_m_main | true_m_secondary | true_m_null | true_g | d_main | d_secondary | d_null | outcome | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0.304717 | -1.039984 | 0.750451 | 0.940565 | -1.951035 | -1.302180 | 0.127840 | -0.316243 | -0.016801 | -0.853044 | 0.879398 | 0.777792 | 0.208617 | 0.053322 | -0.141680 | -0.373290 | 0.235268 | -0.542936 | 1.576123 | -0.730007 |
| 1 | 1 | 0.066031 | 1.127241 | 0.467509 | -0.859292 | 0.368751 | -0.958883 | 0.878450 | -0.049926 | -0.184862 | -0.680930 | 1.222541 | -0.154529 | -0.337528 | -0.689176 | 0.076477 | 0.159172 | -0.893095 | 0.931589 | -1.362971 | 0.708280 |
| 2 | 2 | -0.428328 | -0.352134 | 0.532309 | 0.365444 | 0.412733 | 0.430821 | 2.141648 | -0.406415 | -0.512243 | -0.813773 | 0.615979 | 1.128972 | -0.033597 | 0.418926 | 0.368024 | -0.723623 | -0.551443 | -0.680935 | 0.923762 | -1.376645 |
| 3 | 3 | -0.113947 | -0.840156 | -0.824481 | 0.650593 | 0.743254 | 0.543154 | -0.665510 | 0.232161 | 0.116686 | 0.218689 | 0.871429 | 0.223596 | 0.416345 | 0.495269 | -0.112701 | 0.071774 | -0.146882 | 1.181661 | 0.686230 | 0.620283 |
| 4 | 4 | 0.678914 | 0.067579 | 0.289119 | 0.631288 | -1.457156 | -0.319671 | -0.470373 | -0.638878 | -0.275142 | 1.494941 | -0.865831 | 0.968278 | 0.088885 | 0.032052 | 0.259942 | 0.490940 | -0.384918 | 0.556281 | -0.077955 | 0.218237 |
The first rows show the observed controls, oracle nuisance columns, treatments, and outcome. The oracle columns are present only for simulation documentation and are excluded from the DoubleML control matrix.
Diagnostics and Interpretation
Data Dictionary and Audit
Inference should be reported after the design is clear. This audit records the treatment roles, true simulated effects, and basic confounding correlations.
field_dictionary = pd.DataFrame(
[
{"column": "unit_id", "role": "identifier", "description": "Synthetic row identifier; excluded from modeling."},
{"column": "x00-x11", "role": "observed controls", "description": "Numeric pre-treatment controls."},
{"column": "true_m_*", "role": "oracle only", "description": "True treatment nuisance functions used only for simulation documentation."},
{"column": "true_g", "role": "oracle only", "description": "True outcome nuisance component used only for simulation documentation."},
{"column": "d_main", "role": "treatment", "description": "Treatment with true effect 1.00."},
{"column": "d_secondary", "role": "treatment", "description": "Treatment with true effect 0.45."},
{"column": "d_null", "role": "treatment", "description": "Treatment with true effect 0.00."},
{"column": "outcome", "role": "outcome", "description": "Continuous outcome."},
]
)
audit_rows = []
for treatment in TREATMENT_COLS:
nuisance_col = f"true_m_{treatment.replace('d_', '')}"
audit_rows.append(
{
"treatment": treatment,
"true_theta": TRUE_THETAS[treatment],
"treatment_mean": inference_df[treatment].mean(),
"treatment_sd": inference_df[treatment].std(),
"corr_treatment_true_g": inference_df[treatment].corr(inference_df["true_g"]),
"corr_treatment_own_nuisance": inference_df[treatment].corr(inference_df[nuisance_col]),
}
)
data_audit = pd.DataFrame(audit_rows)
overall_audit = pd.DataFrame(
{
"n_rows": [len(inference_df)],
"n_controls": [len(model_x_cols(inference_df))],
"n_treatments": [len(TREATMENT_COLS)],
"missing_cells": [int(inference_df.isna().sum().sum())],
}
)
save_table(field_dictionary, f"{NOTEBOOK_PREFIX}_field_dictionary.csv")
save_table(data_audit, f"{NOTEBOOK_PREFIX}_treatment_audit.csv")
save_table(overall_audit, f"{NOTEBOOK_PREFIX}_overall_data_audit.csv")
display(field_dictionary)
display(overall_audit)
display(data_audit)| column | role | description | |
|---|---|---|---|
| 0 | unit_id | identifier | Synthetic row identifier; excluded from modeling. |
| 1 | x00-x11 | observed controls | Numeric pre-treatment controls. |
| 2 | true_m_* | oracle only | True treatment nuisance functions used only for simulation documentation. |
| 3 | true_g | oracle only | True outcome nuisance component used only for simulation documentation. |
| 4 | d_main | treatment | Treatment with true effect 1.00. |
| 5 | d_secondary | treatment | Treatment with true effect 0.45. |
| 6 | d_null | treatment | Treatment with true effect 0.00. |
| 7 | outcome | outcome | Continuous outcome. |
| n_rows | n_controls | n_treatments | missing_cells | |
|---|---|---|---|---|
| 0 | 900 | 12 | 3 | 0 |
| treatment | true_theta | treatment_mean | treatment_sd | corr_treatment_true_g | corr_treatment_own_nuisance | |
|---|---|---|---|---|---|---|
| 0 | d_main | 1.00 | 0.001838 | 1.232587 | 0.157730 | 0.566644 |
| 1 | d_secondary | 0.45 | -0.023836 | 1.149815 | -0.164968 | 0.548663 |
| 2 | d_null | 0.00 | -0.010946 | 1.066717 | 0.007058 | 0.312512 |
The audit shows that treatments are related to their nuisance functions and, for some treatments, to outcome-relevant structure. That is why causal inference needs adjustment before uncertainty reporting.
Inference Workflow Diagram
This diagram shows the inference stack. The point estimate and standard error come from the fitted orthogonal score. Bootstrap then approximates the distribution of score-based t-statistics, which supports joint intervals and Romano-Wolf-style p-value adjustment.
# Define reusable helpers for the Inference Workflow Diagram section.
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
nodes = {
"score": {"xy": (0.10, 0.58), "label": "Orthogonal\nscore", "color": "#dbeafe"},
"estimate": {"xy": (0.31, 0.58), "label": "Estimate\nand SE", "color": "#dcfce7"},
"pointwise": {"xy": (0.52, 0.72), "label": "Pointwise\nintervals", "color": "#fef3c7"},
"bootstrap": {"xy": (0.52, 0.42), "label": "Multiplier\nbootstrap", "color": "#e0f2fe"},
"joint": {"xy": (0.75, 0.72), "label": "Joint\nintervals", "color": "#fee2e2"},
"adjust": {"xy": (0.75, 0.42), "label": "Adjusted\np-values", "color": "#ede9fe"},
}
fig, ax = plt.subplots(figsize=(12, 5.4))
ax.set_axis_off()
ax.set_xlim(0.0, 0.90)
ax.set_ylim(0.20, 0.90)
box_w, box_h = 0.13, 0.13
def anchor(name, side):
"""
Idea: Return the plotting anchor point for a named node in the diagram.
Parameters
----------
name : str
Short name used for the scenario label.
side : object
Endpoint side used to place an arrow or edge on the correct part of a node.
Returns
-------
tuple[float, float] or np.ndarray
Coordinate of the requested side of a plotted node.
"""
x, y = nodes[name]["xy"]
offsets = {"left": (-box_w / 2, 0), "right": (box_w / 2, 0), "top": (0, box_h / 2), "bottom": (0, -box_h / 2)}
dx, dy = offsets[side]
return (x + dx, y + dy)
def draw_arrow(start, end, color="#334155", style="solid", rad=0.0):
"""
Idea: Draw a routed arrow between diagram nodes while keeping the arrowhead visible.
Parameters
----------
start : tuple[float, float]
Starting coordinate for a plotted element.
end : tuple[float, float]
Ending coordinate for a plotted element.
color : str
Plot color used to identify this element.
style : object
Plotting style that determines how the curve, marker, or annotation is drawn.
rad : float
Curvature parameter for the plotted arrow.
Returns
-------
None
Adds an arrow annotation directly to the supplied Matplotlib axes.
"""
arrow = FancyArrowPatch(
start,
end,
arrowstyle="-|>",
mutation_scale=18,
linewidth=1.7,
color=color,
linestyle=style,
connectionstyle=f"arc3,rad={rad}",
shrinkA=8,
shrinkB=8,
zorder=5,
)
ax.add_patch(arrow)
for spec in nodes.values():
x, y = spec["xy"]
rect = FancyBboxPatch(
(x - box_w / 2, y - box_h / 2),
box_w,
box_h,
boxstyle="round,pad=0.018",
facecolor=spec["color"],
edgecolor="#334155",
linewidth=1.2,
zorder=3,
)
ax.add_patch(rect)
ax.text(x, y, spec["label"], ha="center", va="center", fontsize=10.5, fontweight="bold", zorder=4)
draw_arrow(anchor("score", "right"), anchor("estimate", "left"))
draw_arrow(anchor("estimate", "right"), anchor("pointwise", "left"), rad=0.10)
draw_arrow(anchor("estimate", "right"), anchor("bootstrap", "left"), rad=-0.10)
draw_arrow(anchor("bootstrap", "right"), anchor("joint", "bottom"), color="#475569", style="dashed", rad=0.12)
draw_arrow(anchor("bootstrap", "right"), anchor("adjust", "left"), color="#475569", style="dashed")
ax.text(
0.45,
0.24,
"Bootstrap-based inference is about uncertainty for selected parameters, not about validating identification assumptions.",
ha="center",
va="center",
fontsize=10,
color="#475569",
)
ax.set_title("DoubleML Inference Workflow", pad=14)
plt.tight_layout()
fig.savefig(FIGURE_DIR / f"{NOTEBOOK_PREFIX}_inference_workflow.png", bbox_inches="tight")
plt.show()
The workflow separates marginal inference from simultaneous inference. Both are useful, but they answer different questions.
For the broader lesson, the estimate should be interpreted together with the identifying assumptions and diagnostics that make it credible.
Fit a Multi-Treatment DoubleMLPLR Model
We fit one PLR model with three treatment columns. DoubleML estimates one coefficient per treatment and returns a summary table with standard errors, t-statistics, p-values, and pointwise intervals.
The learner is a scaled RidgeCV pipeline. Scaling is inside the pipeline so preprocessing is done inside the cross-fitting folds rather than globally leaking information.
base_learner = Pipeline(
[
("scale", StandardScaler()),
("model", RidgeCV(alphas=np.logspace(-3, 3, 15))),
]
)
dml_data = make_dml_data(inference_df)
inference_model = DoubleMLPLR(
dml_data,
ml_l=clone(base_learner),
ml_m=clone(base_learner),
n_folds=5,
n_rep=3,
)
fit_start = time.perf_counter()
inference_model.fit()
fit_runtime = time.perf_counter() - fit_start
print(f"Fit runtime: {fit_runtime:.2f} seconds")
display(inference_model.summary)Fit runtime: 0.28 seconds
| coef | std err | t | P>|t| | 2.5 % | 97.5 % | |
|---|---|---|---|---|---|---|
| d_main | 0.993459 | 0.033845 | 29.353621 | 2.148287e-189 | 0.927125 | 1.059793 |
| d_secondary | 0.464160 | 0.032633 | 14.223840 | 6.517647e-46 | 0.399850 | 0.528119 |
| d_null | -0.022710 | 0.033728 | -0.673319 | 5.007441e-01 | -0.088816 | 0.043396 |
The summary table is the default inference view. It is pointwise: each row is interpreted as a separate treatment-effect estimate unless we add joint inference or p-value adjustment.
Standard Error and Pointwise Inference Table
The code below converts the DoubleML summary into an explicit reporting table and adds the known true effect from the simulation. In real data, the true effect column would not exist; it is included here only for learning.
standard_inference = inference_model.summary.reset_index().rename(columns={"index": "treatment"})
standard_inference["true_theta"] = standard_inference["treatment"].map(TRUE_THETAS)
standard_inference["bias_vs_true"] = standard_inference["coef"] - standard_inference["true_theta"]
standard_inference["covers_true_pointwise_95"] = (
(standard_inference["2.5 %"] <= standard_inference["true_theta"])
& (standard_inference["97.5 %"] >= standard_inference["true_theta"])
)
nuisance_rmse = inference_model.evaluate_learners(metric=rmse_metric)
nuisance_mae = inference_model.evaluate_learners(metric=mae_metric)
nuisance_quality = pd.DataFrame(
[
{
"nuisance": name,
"rmse_mean_across_treatments": float(np.nanmean(values)),
"mae_mean_across_treatments": float(np.nanmean(nuisance_mae[name])),
}
for name, values in nuisance_rmse.items()
]
)
save_table(standard_inference, f"{NOTEBOOK_PREFIX}_standard_inference_summary.csv")
save_table(nuisance_quality, f"{NOTEBOOK_PREFIX}_nuisance_quality.csv")
display(standard_inference)
display(nuisance_quality)| treatment | coef | std err | t | P>|t| | 2.5 % | 97.5 % | true_theta | bias_vs_true | covers_true_pointwise_95 | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | d_main | 0.993459 | 0.033845 | 29.353621 | 2.148287e-189 | 0.927125 | 1.059793 | 1.00 | -0.006541 | True |
| 1 | d_secondary | 0.464160 | 0.032633 | 14.223840 | 6.517647e-46 | 0.399850 | 0.528119 | 0.45 | 0.014160 | True |
| 2 | d_null | -0.022710 | 0.033728 | -0.673319 | 5.007441e-01 | -0.088816 | 0.043396 | 0.00 | -0.022710 | True |
| nuisance | rmse_mean_across_treatments | mae_mean_across_treatments | |
|---|---|---|---|
| 0 | ml_l | 1.208594 | 0.961556 |
| 1 | ml_m | 1.008989 | 0.801563 |
The strong and moderate effects are clearly separated from zero, while the null effect is not. The nuisance-quality table records that inference was built from cross-fitted nuisance predictions rather than in-sample fits.
Confidence Levels and Interval Widths
Confidence intervals widen as the confidence level increases. The code below computes 90%, 95%, and 99% pointwise intervals manually from the coefficient and standard error.
ci_level_rows = []
for treatment, coef, se in zip(TREATMENT_COLS, inference_model.coef, inference_model.se):
for level in [0.90, 0.95, 0.99]:
lower, upper, critical_value = ci_from_coef_se(coef, se, level)
ci_level_rows.append(
{
"treatment": treatment,
"level": level,
"critical_value": critical_value,
"coef": coef,
"se": se,
"ci_lower": lower,
"ci_upper": upper,
"ci_width": upper - lower,
"true_theta": TRUE_THETAS[treatment],
"covers_true": lower <= TRUE_THETAS[treatment] <= upper,
}
)
ci_levels = pd.DataFrame(ci_level_rows)
save_table(ci_levels, f"{NOTEBOOK_PREFIX}_pointwise_confidence_levels.csv")
display(ci_levels)| treatment | level | critical_value | coef | se | ci_lower | ci_upper | ci_width | true_theta | covers_true | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | d_main | 0.90 | 1.644854 | 0.993459 | 0.033845 | 0.937790 | 1.049128 | 0.111339 | 1.00 | True |
| 1 | d_main | 0.95 | 1.959964 | 0.993459 | 0.033845 | 0.927125 | 1.059793 | 0.132668 | 1.00 | True |
| 2 | d_main | 0.99 | 2.575829 | 0.993459 | 0.033845 | 0.906281 | 1.080637 | 0.174355 | 1.00 | True |
| 3 | d_secondary | 0.90 | 1.644854 | 0.464160 | 0.032633 | 0.410484 | 0.517836 | 0.107352 | 0.45 | True |
| 4 | d_secondary | 0.95 | 1.959964 | 0.464160 | 0.032633 | 0.400202 | 0.528119 | 0.127917 | 0.45 | True |
| 5 | d_secondary | 0.99 | 2.575829 | 0.464160 | 0.032633 | 0.380104 | 0.548216 | 0.168112 | 0.45 | True |
| 6 | d_null | 0.90 | 1.644854 | -0.022710 | 0.033728 | -0.078188 | 0.032768 | 0.110956 | 0.00 | True |
| 7 | d_null | 0.95 | 1.959964 | -0.022710 | 0.033728 | -0.088816 | 0.043396 | 0.132212 | 0.00 | True |
| 8 | d_null | 0.99 | 2.575829 | -0.022710 | 0.033728 | -0.109588 | 0.064168 | 0.173756 | 0.00 | True |
The table shows the mechanical relationship between confidence level and interval width. Higher confidence requires a larger critical value, so the interval becomes wider.
Confidence Level Plot
The plot below shows the same idea visually. Each treatment receives three intervals, one per confidence level.
# Build and label the diagnostic visualization for the Confidence Level Plot section.
fig, ax = plt.subplots(figsize=(11, 6))
level_offsets = {0.90: -0.18, 0.95: 0.00, 0.99: 0.18}
y_positions = {treatment: idx for idx, treatment in enumerate(TREATMENT_COLS)}
colors = {0.90: "#2563eb", 0.95: "#059669", 0.99: "#b45309"}
for _, row in ci_levels.iterrows():
y = y_positions[row["treatment"]] + level_offsets[row["level"]]
ax.errorbar(
x=row["coef"],
y=y,
xerr=[[row["coef"] - row["ci_lower"]], [row["ci_upper"] - row["coef"]]],
fmt="o",
color=colors[row["level"]],
ecolor=colors[row["level"]],
capsize=3,
label=f"{int(row['level'] * 100)}%" if row["treatment"] == TREATMENT_COLS[0] else None,
)
for treatment, true_value in TRUE_THETAS.items():
ax.scatter(true_value, y_positions[treatment], marker="x", s=90, color="#111827", zorder=5)
ax.set_yticks(list(y_positions.values()))
ax.set_yticklabels(TREATMENT_COLS)
ax.set_title("Pointwise Confidence Intervals By Confidence Level")
ax.set_xlabel("Treatment effect")
ax.set_ylabel("")
ax.legend(title="Level", loc="best")
plt.tight_layout()
fig.savefig(FIGURE_DIR / f"{NOTEBOOK_PREFIX}_confidence_levels.png", bbox_inches="tight")
plt.show()
The x markers are the known true effects from the simulation. In applied work those markers do not exist, so the interval itself must be interpreted as an uncertainty statement under the design assumptions.
Multiplier Bootstrap
DoubleML’s bootstrap() method draws random multipliers for the estimated score contributions. This approximates the distribution of t-statistics without refitting all nuisance models for every bootstrap draw.
We use 800 bootstrap replications here. That is enough for a stable teaching plot while remaining fast.
np.random.seed(RANDOM_SEED)
bootstrap_start = time.perf_counter()
inference_model.bootstrap(method="normal", n_rep_boot=800)
bootstrap_runtime = time.perf_counter() - bootstrap_start
boot_stats = flatten_boot_t_stats(inference_model.boot_t_stat, TREATMENT_COLS)
boot_max_abs = (
boot_stats.groupby(["boot_id", "rep_id"])["boot_t_stat"]
.apply(lambda values: np.max(np.abs(values)))
.reset_index(name="max_abs_t")
)
joint_critical_95 = boot_max_abs["max_abs_t"].quantile(0.95)
bootstrap_summary = pd.DataFrame(
{
"method": ["normal"],
"n_rep_boot": [800],
"bootstrap_runtime_seconds": [bootstrap_runtime],
"joint_critical_95": [joint_critical_95],
}
)
save_table(bootstrap_summary, f"{NOTEBOOK_PREFIX}_bootstrap_summary.csv")
save_table(boot_stats, f"{NOTEBOOK_PREFIX}_bootstrap_t_statistics.csv")
save_table(boot_max_abs, f"{NOTEBOOK_PREFIX}_bootstrap_max_abs_t.csv")
display(bootstrap_summary)
display(boot_stats.head())| method | n_rep_boot | bootstrap_runtime_seconds | joint_critical_95 | |
|---|---|---|---|---|
| 0 | normal | 800 | 0.039017 | 2.346278 |
| boot_id | treatment | rep_id | boot_t_stat | |
|---|---|---|---|---|
| 0 | 0 | d_main | 1 | 0.214724 |
| 1 | 0 | d_main | 2 | 1.429037 |
| 2 | 0 | d_main | 3 | 1.054318 |
| 3 | 0 | d_secondary | 1 | -0.558725 |
| 4 | 0 | d_secondary | 2 | 1.150842 |
The bootstrap stores t-statistics for each treatment and each repeated sample split. The maximum absolute t-statistic distribution is what makes simultaneous inference wider than pointwise inference.
Bootstrap T-Statistic Distribution
This figure shows the bootstrap t-statistic distribution by treatment, plus the distribution of the maximum absolute t-statistic used for joint intervals.
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
sns.kdeplot(
data=boot_stats,
x="boot_t_stat",
hue="treatment",
common_norm=False,
linewidth=2,
ax=axes[0],
)
axes[0].axvline(0, color="#111827", linestyle="--", linewidth=1.2)
axes[0].set_title("Bootstrap T-Statistic Distributions")
axes[0].set_xlabel("Bootstrap t-statistic")
axes[0].set_ylabel("Density")
sns.histplot(data=boot_max_abs, x="max_abs_t", bins=35, color="#2563eb", alpha=0.70, ax=axes[1])
axes[1].axvline(joint_critical_95, color="#dc2626", linestyle="--", linewidth=1.5, label=f"95% joint critical = {joint_critical_95:.2f}")
axes[1].set_title("Max Absolute Bootstrap T-Statistic")
axes[1].set_xlabel("max |t| across treatments")
axes[1].set_ylabel("Count")
axes[1].legend(loc="best")
plt.tight_layout()
fig.savefig(FIGURE_DIR / f"{NOTEBOOK_PREFIX}_bootstrap_t_distributions.png", bbox_inches="tight")
plt.show()
The max-statistic distribution has a larger critical value than the usual 1.96 pointwise normal cutoff. That is why joint intervals are wider.
The lesson is robustness: a publishable conclusion should not hinge on one arbitrary threshold, one lucky sample split, or one narrow tuning choice.
Pointwise versus Joint Confidence Intervals
Now we compare pointwise 95% confidence intervals with bootstrap-based joint 95% confidence intervals. Joint intervals are appropriate when reporting several effects as a family and wanting simultaneous coverage.
pointwise_ci = inference_model.confint(joint=False, level=0.95).reset_index().rename(columns={"index": "treatment", "2.5 %": "pointwise_lower", "97.5 %": "pointwise_upper"})
joint_ci = inference_model.confint(joint=True, level=0.95).reset_index().rename(columns={"index": "treatment", "2.5 %": "joint_lower", "97.5 %": "joint_upper"})
ci_comparison = standard_inference[["treatment", "coef", "std err", "true_theta"]].merge(pointwise_ci, on="treatment").merge(joint_ci, on="treatment")
ci_comparison["pointwise_width"] = ci_comparison["pointwise_upper"] - ci_comparison["pointwise_lower"]
ci_comparison["joint_width"] = ci_comparison["joint_upper"] - ci_comparison["joint_lower"]
ci_comparison["joint_width_ratio"] = ci_comparison["joint_width"] / ci_comparison["pointwise_width"]
ci_comparison["pointwise_covers_true"] = (ci_comparison["pointwise_lower"] <= ci_comparison["true_theta"]) & (ci_comparison["pointwise_upper"] >= ci_comparison["true_theta"])
ci_comparison["joint_covers_true"] = (ci_comparison["joint_lower"] <= ci_comparison["true_theta"]) & (ci_comparison["joint_upper"] >= ci_comparison["true_theta"])
save_table(ci_comparison, f"{NOTEBOOK_PREFIX}_pointwise_vs_joint_ci.csv")
display(ci_comparison)| treatment | coef | std err | true_theta | pointwise_lower | pointwise_upper | joint_lower | joint_upper | pointwise_width | joint_width | joint_width_ratio | pointwise_covers_true | joint_covers_true | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | d_main | 0.993459 | 0.033845 | 1.00 | 0.927125 | 1.059793 | 0.911843 | 1.074356 | 0.132668 | 0.162513 | 1.224960 | True | True |
| 1 | d_secondary | 0.464160 | 0.032633 | 0.45 | 0.399850 | 0.528119 | 0.387054 | 0.540507 | 0.128269 | 0.153453 | 1.196335 | True | True |
| 2 | d_null | -0.022710 | 0.033728 | 0.00 | -0.088816 | 0.043396 | -0.099680 | 0.058226 | 0.132212 | 0.157906 | 1.194334 | True | True |
The joint intervals are wider because they are calibrated to cover the collection of effects together. This is the right trade-off when the write-up makes simultaneous claims.
Pointwise versus Joint Interval Plot
The plot below overlays the two interval types. Pointwise intervals are blue; joint intervals are orange. The black x marks the known true effect.
# Build and label the diagnostic visualization for the Pointwise Versus Joint Interval Plot section.
fig, ax = plt.subplots(figsize=(11, 6))
y_positions = {treatment: idx for idx, treatment in enumerate(TREATMENT_COLS)}
for _, row in ci_comparison.iterrows():
y_base = y_positions[row["treatment"]]
ax.errorbar(
x=row["coef"],
y=y_base - 0.12,
xerr=[[row["coef"] - row["pointwise_lower"]], [row["pointwise_upper"] - row["coef"]]],
fmt="o",
color="#2563eb",
ecolor="#2563eb",
capsize=3,
label="Pointwise 95%" if row["treatment"] == TREATMENT_COLS[0] else None,
)
ax.errorbar(
x=row["coef"],
y=y_base + 0.12,
xerr=[[row["coef"] - row["joint_lower"]], [row["joint_upper"] - row["coef"]]],
fmt="o",
color="#b45309",
ecolor="#b45309",
capsize=3,
label="Joint 95%" if row["treatment"] == TREATMENT_COLS[0] else None,
)
ax.scatter(row["true_theta"], y_base, marker="x", s=90, color="#111827", zorder=6)
ax.axvline(0, color="#9ca3af", linestyle=":", linewidth=1.2)
ax.set_yticks(list(y_positions.values()))
ax.set_yticklabels(TREATMENT_COLS)
ax.set_title("Pointwise And Joint Confidence Intervals")
ax.set_xlabel("Treatment effect")
ax.set_ylabel("")
ax.legend(loc="best")
plt.tight_layout()
fig.savefig(FIGURE_DIR / f"{NOTEBOOK_PREFIX}_pointwise_vs_joint_ci.png", bbox_inches="tight")
plt.show()
The null treatment interval includes zero under both approaches. The nonzero treatments remain clearly positive, even after joint calibration.
For the broader lesson, the estimate should be interpreted together with the identifying assumptions and diagnostics that make it credible.
Bootstrap Method Comparison
DoubleML supports several multiplier bootstrap methods: normal, Bayes, and wild. In many well-behaved examples they give similar results, but comparing them is a useful robustness check.
bootstrap_method_rows = []
for method in ["normal", "Bayes", "wild"]:
np.random.seed(RANDOM_SEED)
inference_model.bootstrap(method=method, n_rep_boot=500)
method_joint_ci = inference_model.confint(joint=True, level=0.95).reset_index().rename(columns={"index": "treatment", "2.5 %": "joint_lower", "97.5 %": "joint_upper"})
method_joint_ci["method"] = method
method_joint_ci["joint_width"] = method_joint_ci["joint_upper"] - method_joint_ci["joint_lower"]
bootstrap_method_rows.append(method_joint_ci)
bootstrap_method_comparison = pd.concat(bootstrap_method_rows, ignore_index=True)
save_table(bootstrap_method_comparison, f"{NOTEBOOK_PREFIX}_bootstrap_method_comparison.csv")
display(bootstrap_method_comparison)
# Restore the normal bootstrap for the p-value adjustment sections below.
np.random.seed(RANDOM_SEED)
inference_model.bootstrap(method="normal", n_rep_boot=800)| treatment | joint_lower | joint_upper | method | joint_width | |
|---|---|---|---|---|---|
| 0 | d_main | 0.914653 | 1.072265 | normal | 0.157612 |
| 1 | d_secondary | 0.386598 | 0.537878 | normal | 0.151280 |
| 2 | d_null | -0.099003 | 0.055425 | normal | 0.154428 |
| 3 | d_main | 0.912398 | 1.074520 | Bayes | 0.162123 |
| 4 | d_secondary | 0.386434 | 0.540265 | Bayes | 0.153831 |
| 5 | d_null | -0.103896 | 0.059080 | Bayes | 0.162976 |
| 6 | d_main | 0.912760 | 1.074159 | wild | 0.161399 |
| 7 | d_secondary | 0.386619 | 0.539921 | wild | 0.153303 |
| 8 | d_null | -0.101136 | 0.057312 | wild | 0.158448 |
<doubleml.plm.plr.DoubleMLPLR at 0x71fb4d6f7390>
The methods produce similar joint intervals in this clean synthetic design. Bigger differences would be a signal to inspect sample size, score behavior, and model stability.
Multiple Testing Adjustment
When testing several effects, unadjusted p-values answer each test separately. Adjusted p-values account for the fact that several hypotheses are being examined. DoubleML supports Romano-Wolf adjustment through the bootstrap, and also methods such as Holm and Bonferroni through statsmodels.
romano_wolf = inference_model.p_adjust(method="romano-wolf").reset_index().rename(columns={"index": "treatment", "pval": "romano_wolf_pval"})
holm = inference_model.p_adjust(method="holm").reset_index().rename(columns={"index": "treatment", "pval": "holm_pval"})
bonferroni = inference_model.p_adjust(method="bonferroni").reset_index().rename(columns={"index": "treatment", "pval": "bonferroni_pval"})
pvalue_comparison = standard_inference[["treatment", "coef", "P>|t|", "true_theta"]].rename(columns={"P>|t|": "pointwise_pval"})
pvalue_comparison = pvalue_comparison.merge(romano_wolf[["treatment", "romano_wolf_pval"]], on="treatment")
pvalue_comparison = pvalue_comparison.merge(holm[["treatment", "holm_pval"]], on="treatment")
pvalue_comparison = pvalue_comparison.merge(bonferroni[["treatment", "bonferroni_pval"]], on="treatment")
save_table(pvalue_comparison, f"{NOTEBOOK_PREFIX}_adjusted_pvalue_comparison.csv")
display(pvalue_comparison)| treatment | coef | pointwise_pval | true_theta | romano_wolf_pval | holm_pval | bonferroni_pval | |
|---|---|---|---|---|---|---|---|
| 0 | d_main | 0.993459 | 2.148287e-189 | 1.00 | 0.000 | 0.000000 | 0.0 |
| 1 | d_secondary | 0.464160 | 6.517647e-46 | 0.45 | 0.000 | 0.000000 | 0.0 |
| 2 | d_null | -0.022710 | 5.007441e-01 | 0.00 | 0.505 | 0.500744 | 1.0 |
The two true nonzero effects remain significant after adjustment, while the null treatment remains non-significant. That is the behavior we hope to see in this teaching simulation.
Adjusted P-Value Plot
The plot below shows pointwise and adjusted p-values on a log scale. The dashed line marks 0.05.
# Build and label the diagnostic visualization for the Adjusted P-Value Plot section.
pvalue_plot = pvalue_comparison.melt(
id_vars="treatment",
value_vars=["pointwise_pval", "romano_wolf_pval", "holm_pval", "bonferroni_pval"],
var_name="pvalue_type",
value_name="pvalue",
)
pvalue_plot["pvalue_for_plot"] = pvalue_plot["pvalue"].clip(lower=1e-12)
pvalue_offsets = {
"pointwise_pval": -0.24,
"romano_wolf_pval": -0.08,
"holm_pval": 0.08,
"bonferroni_pval": 0.24,
}
pvalue_colors = {
"pointwise_pval": "#2563eb",
"romano_wolf_pval": "#b45309",
"holm_pval": "#059669",
"bonferroni_pval": "#991b1b",
}
y_positions = {treatment: idx for idx, treatment in enumerate(TREATMENT_COLS)}
fig, ax = plt.subplots(figsize=(11.5, 5.6))
for pvalue_type, group in pvalue_plot.groupby("pvalue_type"):
ax.scatter(
group["pvalue_for_plot"],
[y_positions[treatment] + pvalue_offsets[pvalue_type] for treatment in group["treatment"]],
s=90,
color=pvalue_colors[pvalue_type],
label=pvalue_type.replace("_", " "),
)
ax.axvline(0.05, color="#dc2626", linestyle="--", linewidth=1.5, label="0.05 threshold")
ax.set_xscale("log")
ax.set_yticks(list(y_positions.values()))
ax.set_yticklabels(TREATMENT_COLS)
ax.set_title("Pointwise And Adjusted P-Values")
ax.set_xlabel("p-value, log scale")
ax.set_ylabel("")
ax.legend(title="P-value type", bbox_to_anchor=(1.02, 1), loc="upper left", borderaxespad=0)
plt.tight_layout()
fig.savefig(FIGURE_DIR / f"{NOTEBOOK_PREFIX}_adjusted_pvalues.png", bbox_inches="tight")
plt.show()
The log scale makes tiny p-values visible while still showing the null treatment near the non-significant region. Adjustment matters most when there are several borderline effects.
Small Coverage Simulation
This short simulation repeats a simple single-treatment PLR design several times and checks whether the pointwise 95% confidence interval covers the known true effect. It is small and should be read as a teaching sketch, not a formal Monte Carlo study.
A real simulation study would use many more repetitions, multiple sample sizes, and a fixed analysis plan.
# Define reusable helpers for the Small Coverage Simulation section.
def simulate_single_effect_dataset(seed, n_obs=350, n_controls=8, true_theta=0.80):
"""
Idea: Simulate the single effect dataset scenario so the notebook can study a known causal or statistical failure mode.
Parameters
----------
seed : int
Random seed used to make generation or resampling reproducible.
n_obs : int
Number of observations generated for the experiment.
n_controls : object
Number of control covariates included in the simulated design.
true_theta : object
Oracle treatment-effect value used as the synthetic benchmark.
Returns
-------
pd.DataFrame or dict
Named df object assembled by simulate single effect dataset.
"""
rng_local = np.random.default_rng(seed)
x = rng_local.normal(size=(n_obs, n_controls))
df = pd.DataFrame(x, columns=[f"x{i}" for i in range(n_controls)])
treatment_nuisance = 0.50 * df["x0"] - 0.30 * df["x1"] + 0.25 * df["x2"]
outcome_nuisance = 0.60 * df["x0"] + 0.35 * df["x3"] - 0.25 * df["x4"]
df["treatment"] = treatment_nuisance + rng_local.normal(size=n_obs)
df["outcome"] = true_theta * df["treatment"] + outcome_nuisance + rng_local.normal(size=n_obs)
return df
coverage_rows = []
coverage_true_theta = 0.80
coverage_learner = Ridge(alpha=1.0)
for sim_id in range(30):
sim_df = simulate_single_effect_dataset(seed=1_000 + sim_id, true_theta=coverage_true_theta)
sim_data = DoubleMLData(sim_df, y_col="outcome", d_cols="treatment")
sim_model = DoubleMLPLR(
sim_data,
ml_l=clone(coverage_learner),
ml_m=clone(coverage_learner),
n_folds=3,
)
sim_model.fit()
sim_ci = sim_model.confint(level=0.95).iloc[0]
coverage_rows.append(
{
"sim_id": sim_id,
"theta_hat": float(sim_model.coef[0]),
"se": float(sim_model.se[0]),
"ci_lower": float(sim_ci.iloc[0]),
"ci_upper": float(sim_ci.iloc[1]),
"ci_width": float(sim_ci.iloc[1] - sim_ci.iloc[0]),
"covers_true": bool(sim_ci.iloc[0] <= coverage_true_theta <= sim_ci.iloc[1]),
}
)
coverage_simulation = pd.DataFrame(coverage_rows)
coverage_summary = pd.DataFrame(
{
"n_simulations": [len(coverage_simulation)],
"true_theta": [coverage_true_theta],
"coverage_rate": [coverage_simulation["covers_true"].mean()],
"mean_theta_hat": [coverage_simulation["theta_hat"].mean()],
"sd_theta_hat": [coverage_simulation["theta_hat"].std()],
"mean_reported_se": [coverage_simulation["se"].mean()],
"mean_ci_width": [coverage_simulation["ci_width"].mean()],
}
)
save_table(coverage_simulation, f"{NOTEBOOK_PREFIX}_coverage_simulation.csv")
save_table(coverage_summary, f"{NOTEBOOK_PREFIX}_coverage_summary.csv")
display(coverage_summary)
display(coverage_simulation.head())| n_simulations | true_theta | coverage_rate | mean_theta_hat | sd_theta_hat | mean_reported_se | mean_ci_width | |
|---|---|---|---|---|---|---|---|
| 0 | 30 | 0.8 | 1.0 | 0.812886 | 0.04525 | 0.052825 | 0.207069 |
| sim_id | theta_hat | se | ci_lower | ci_upper | ci_width | covers_true | |
|---|---|---|---|---|---|---|---|
| 0 | 0 | 0.884287 | 0.043571 | 0.798890 | 0.969685 | 0.170795 | True |
| 1 | 1 | 0.774099 | 0.045587 | 0.684750 | 0.863447 | 0.178698 | True |
| 2 | 2 | 0.755067 | 0.055617 | 0.646060 | 0.864073 | 0.218013 | True |
| 3 | 3 | 0.860735 | 0.052665 | 0.757514 | 0.963956 | 0.206442 | True |
| 4 | 4 | 0.803388 | 0.051312 | 0.702819 | 0.903958 | 0.201139 | True |
The empirical coverage rate will not be exactly 95% with only 30 simulations. The point is to connect the interval formula to repeated-sampling behavior: over many hypothetical datasets, valid 95% intervals should cover the true effect about 95% of the time.
Coverage Simulation Plot
Each horizontal line is one simulated 95% confidence interval. Blue intervals cover the true effect; red intervals miss it.
plot_coverage = coverage_simulation.sort_values("theta_hat").reset_index(drop=True)
plot_coverage["ordered_sim"] = np.arange(len(plot_coverage))
plot_coverage["coverage_label"] = np.where(plot_coverage["covers_true"], "covers", "misses")
fig, ax = plt.subplots(figsize=(11, 6))
for _, row in plot_coverage.iterrows():
color = "#2563eb" if row["covers_true"] else "#dc2626"
ax.plot([row["ci_lower"], row["ci_upper"]], [row["ordered_sim"], row["ordered_sim"]], color=color, linewidth=1.6)
ax.scatter(row["theta_hat"], row["ordered_sim"], color=color, s=24)
ax.axvline(coverage_true_theta, color="#111827", linestyle="--", linewidth=1.5, label="True effect")
ax.set_title("Small Coverage Simulation For Pointwise 95% Intervals")
ax.set_xlabel("Treatment effect")
ax.set_ylabel("Simulation, sorted by estimate")
ax.legend(loc="best")
plt.tight_layout()
fig.savefig(FIGURE_DIR / f"{NOTEBOOK_PREFIX}_coverage_simulation.png", bbox_inches="tight")
plt.show()
Coverage is a repeated-sampling idea. For one applied interval, the true effect is either inside or outside the interval, although we never observe that status directly. The simulation is here to make the concept concrete.
Reporting and Takeaways
Reporting Checklist
This checklist summarizes what should be reported when presenting DoubleML inference results. The emphasis is on clarity: point estimate, uncertainty type, bootstrap settings, and multiple-testing choices should all be visible.
| item | why |
|---|---|
| State the estimand and treatment columns | Inference is only meaningful after the target is clear. |
| Report coefficient, standard error, and confidence interval | A point estimate without uncertainty is incomplete. |
| Say whether intervals are pointwise or joint | These intervals answer different reporting questions. |
| Document bootstrap method and number of replications | Joint intervals and Romano-Wolf adjustment depend on bootstrap settings. |
| Use adjusted p-values for multiple treatment tests | Several tests increase the chance of false discoveries. |
| Report nuisance model and cross-fitting choices | Uncertainty is conditional on the fitted DoubleML design. |
| Separate statistical uncertainty from identification uncertainty | Bootstrap cannot fix omitted variables, bad timing, or invalid instruments. |
The last row is the most important. Statistical inference quantifies sampling uncertainty under the design; it does not prove the design is correct.
For the broader lesson, the estimate should be interpreted together with the identifying assumptions and diagnostics that make it credible.
Reporting and Takeaways
A finished analysis should be easy for another careful reader to review. It should state the causal question, target estimand, identification assumptions, main estimate, diagnostics, sensitivity checks, and remaining limitations in plain language. The goal is a clear analytical conclusion supported by the tables and figures already shown in the lesson.
The lesson now covers the core DoubleML inference workflow: standard errors, pointwise intervals, multiplier bootstrap, joint intervals, adjusted p-values, and reporting discipline.
The implication is practical: the analysis leaves an audit trail, so readers can connect the final claims to the exact tables and figures that support them.
What Comes Next
The sequence next moves to sensitivity analysis for unobserved confounding, showing how DoubleML represents robustness to omitted confounders and how those sensitivity parameters can be explained clearly.