DoubleML Pipeline
A robust Python library for automated causal inference utilizing Double Machine Learning (DoubleML) and FLAML AutoML.
By Google's APAC Marketing Effectiveness Taskforce
Note: doubleml-pipeline refers to and builds upon the underlying DoubleML
Python package published by doubleml.org.
Why DoubleML Pipeline?
With DoubleML Pipeline, you can estimate the true incremental impact and ROI of your marketing interventions including pricing and promotions by controlling for complex, high-dimensional confounders.
Causal inference is challenging due to confounding bias, where standard regression models often fail to isolate true causal relationships. Furthermore, unlike standard supervised learning, Causal Machine Learning (CausalML) lacks observational ground truth data for unobserved counterfactuals, making verifiability a critical requirement for decision-making.
Double Machine Learning (DML) was selected as our base framework because it solves these challenges through rigorous theoretical and statistical guarantees—such as Neyman orthogonality and cross-fitting to eliminate bias—allowing causal estimates to be mathematically verified and trusted. DoubleML Pipeline operationalizes this framework by combining DML with automated machine learning (AutoML) to make causal estimation both scientifically robust and highly automated.
Key Features:
- Automated Nuisance Modeling: Automates nuisance parameter estimation using FLAML AutoML. It dynamically searches and tunes candidates (like LightGBM, XGBoost) under a specified time budget, removing the guesswork of model selection.
- Safe Business Unit Scaling: Normalizes and scales geographical and population data securely, ensuring that descaled results represent accurate real-world business metrics (KPI, ROI) without introducing mathematical singularities.
- Treatment Effect Heterogeneity (CATE): Automatically fits continuous splines or binary bases to estimate how treatment effects vary across different contexts (Conditional Average Treatment Effects).
- Sensitivity Analysis (OVB): Runs robust omitted variable bias (OVB)
checks to evaluate how sensitive your causal estimates are to unobserved
confounders. The bias bound is
rho_U * scale * sqrt(cf_y * cf_d / (1 - cf_d))with the residual-basedscale = sqrt(sigma2 * nu2)(DoubleML'ssensitivity_elements; recomputed from the out-of-fold residuals when unavailable; per-group moments for geo / item GATE rows) and the robustness value isRV = (sqrt(f^4 + 4 f^2) - f^2) / 2withf = (|theta| - 1.96 se) / scale(Cinelli & Hazlett 2020), so the RV sits on the zero contour of the 95% lower bound;sensitivity_metrics.csvcarriesn_obs,f_q,sigma2,nu2,scaleso the numbers can be verified. With a saturation family adopted in Phase 0 the analysis runs on the same PLR fitted withD = fbar(T; alpha_hat)(no extra fit), the benchmark covariate is chosen by correlation withfbar(T), and the bounds are conditional onalpha_hat(treatment_variable = "f_bar(T)"in the CSV). - Bayesian MMM Prior Calibration: Extracts fitted distribution parameters (Normal, LogNormal, Beta) for treatment contribution (%) and ROI (e.g., discount) as prior knowledge. Because CATE estimated by DML fluctuates depending on conditions (e.g., seasonality), using only the mean of the distribution as input to Bayesian MMM priors is a consideration. Additionally, it is important to be aware that there may be a gap between ATE MMM estimates and CATE DML estimates due to differences in the estimands.
- Publication-Ready Visualizations: Generates comprehensive time series line plots, stacked contribution bar charts, cross-sectional ROI comparisons, and prior distribution shapes.
Getting Started
Prerequisites
Before running the pipeline, ensure you have:
- Python 3.10 or later.
Installation
You can install the library directly from PyPI:
pip install doubleml-pipeline
Alternatively, to install from source:
git clone https://github.com/google/doubleml-pipeline.git
cd doubleml-pipeline
pip install .
Quick Start Example
Here is how to initialize and run the causal pipeline in a standard Python environment, explicitly configuring all scaling, modeling, and financial parameters.
import pandas as pd
# Note: Module name uses underscores (_) instead of hyphens (-)
from doubleml_pipeline.preprocessing.scalers import CausalDataScaler
from doubleml_pipeline.workflow import CausalWorkflowOrchestrator
# 1. Load your dataset
df = pd.read_csv("path/to/your/marketing_data.csv")
# 2. Initialize the scaler with explicit scaling strategies per column
scaler = CausalDataScaler(
standardize_cols=["holiday_index", "seasonality_factor"],
population_standardize_cols=["competitor_spend"],
population_median_normalize_cols=["sales_revenue", "tv_spend"],
min_max_cols=["store_count"],
median_normalize_cols=["discount_rate"],
population_col="geo_population",
)
# 3. Initialize the orchestrator with all explicit configuration parameters
orchestrator = CausalWorkflowOrchestrator(
# Primary target and treatments (single treatment to support optimization)
y_col="sales_revenue",
d_cols=["discount_rate"],
x_cols_list=[
["holiday_index", "seasonality_factor", "competitor_spend"],
],
# Nuisance estimation models & cross-validation setup
ml_models_y=["lgbm", "xgboost"],
ml_models_t=["lgbm", "xgboost"],
n_folds_list=[5],
output_dir="./causal_results",
n_jobs=-1,
# Conditional Average Treatment Effect (CATE) specification
covariates_for_cate_list=[
["seasonality_factor"],
],
cate_structure_list=[
{"type": "auto_additive", "df": 3, "degree": 2, "include_intercept": False},
],
time_budget=30,
# Panel data dimensions
date_col="date",
geo_col="geo",
item_col="item",
geo_name_col="geo_name",
item_name_col="item_name",
# Sensitivity analysis (Omitted Variable Bias)
run_sensitivity=True,
sensitivity_scope=["total", "geo", "item"],
# Ground truth validation (set True if ground truth effect columns exist)
ground_truth_existence=False,
ground_truth_effect_column=None,
# Treatment type classification & monetary spend mapping
treatment_types={
"discount_rate": "percentage",
},
treatment_spend_cols={
"discount_rate": "discount_cost",
},
sales_col="sales_revenue",
)
# 4. Execute the full 4-phase pipeline (Grid Search -> Shortlist -> Ensemble -> Consolidation)
orchestrator.run_full_pipeline(
df=df,
scaler=scaler,
top_n=3,
n_reps=10,
)
The orchestrator will execute the models in parallel, select the best
estimators, run repetitions to calculate robust confidence bounds, perform
sensitivity checks, and write all dataframes and plot artifacts to the specified
./causal_results directory.
Pipeline Execution Flow (4 Phases)
- Grid Search Exploration: Fits nuisance models for outcome
Yand treatmentTacross all candidate AutoML models and fold combinations in parallel. - Shortlisting: Ranks and selects top-performing models (
top_n) based on combined error metrics. - Robust Ensemble Estimation: Re-estimates shortlisted models across
n_repsiterations to build a weighted ensemble with empirical confidence bounds (2.5%–97.5%). - Consolidation & Reporting: Aggregates causal effects (CATE, incremental KPI, ROI), runs OVB sensitivity checks, and outputs charts.
Output Directory Structure
When the pipeline runs, it saves all intermediate evaluation metrics, candidate
model repetitions, consolidated datasets, and visualization plots within the
specified output_dir (e.g., ./causal_results/) as shown below:
Important Notes on Outputs & Optimization
-
ROI Charts: The ROI chart folders under
roi_output/<treatment>/(003_roi_by_month,004_roi_by_geo,005_roi_by_item) are conditionally generated depending on your configuredtreatment_types(default is"spend"), along withtreatment_spend_colsandsales_col, as summarized below:treatment_typestreatment_spend_cols&sales_colROI Charts Pipeline Behavior & Reason "spend"(default)Not required Not Generated Not generated specifically because they are redundant with the output generated in Phase 4. "percentage","impressions", or"price"Provided Generated Explicitly generates the ROI charts ( 003_roi_by_month,004_roi_by_geo,005_roi_by_item)."percentage","impressions", or"price"Missing Skipped (Warning) Pipeline logs a warning and skips ROI charts because monetary spend mapping is missing. -
Optimization: Optimization is limited to a single treatment. Two modes are available:
optimizesimple_optimizationsaturationblockBehavior TrueTrue(derived when no family is set)Not required Linear (simple) optimization, unchanged from previous releases. TrueFalse(derived when a family is set)Required Saturation-aware optimization (see below). TrueFalse(passed explicitly)Missing ValueErrorat construction. Detailed optimization is only defined under a saturation model.TrueTrue(forced for non-spendtreatment types)Optional Simple optimization. The saturation-aware optimizer is only implemented for treatment_types = "spend". -
Cluster DML keys (chosen automatically, every run): The cross-fitting folds and the cluster-robust standard errors use a 2-way Cluster DML whose keys are picked by the cluster-key determination rule — this runs whether or not
saturation_configis given. The candidates areitem_col,geo_col(each needs at least two distinct values) and a contiguous date block derived fromdate_col(never raw dates); the two candidates with the most clusters are adopted, one if only one is usable, none → observation-level inference.date_col,item_colandgeo_colkeep their visualization role in every chart and summary CSV regardless of which keys were adopted. The choice is logged (at the start of Phase 1 in a normal run, in the Phase 0 header otherwise):Cluster DML keys (determination rule applied automatically; no saturation_config needed): 2-way: cluster_date_block (7 clusters), geo (6 clusters); not adopted: item (2 clusters); date block = monthly blocks (weekly data)and recorded in
metadata.json["cluster_dml"](keys,n_way,cluster_counts,not_adopted,date_block_granularity,visualization_cols,keys_used, …); the HTML report shows the same information in its header. Seedocs/output_csv_schema.md. -
Hyperparameter policy (
hparam_policy, default"global"): Phases 1–3 fit a nuisance learner in every cross-fitting fold of every candidate and every repetition. Withhparam_policy="global"the FLAML search (time_budgetseconds,eval_method="auto",early_stop=True) runs once per (treatment, learner) at the start of Phase 1 — on the sample Phases 1–3 are fitted on (final_fit), with targetYfor the Y-side learners and the PLR treatmentD(f̄(T; α̂)when Phase 0 adopted a saturation family,Totherwise) for the T-side learners — and every fold then fits the winner's concrete estimator with those fixed hyperparameters (models.fixed_hparam_regressor.FixedHparamRegressor). The DML cross-fitting, CATE, repetitions, median aggregation and the combined error (now fed by the same validation RMSE measured inside each fold's training data with the method FLAML chose) are unchanged. The searches are persisted tostep1_exploration_<d_col>/hparams_<d_col>_<learner>.json(one file per learner; a learner used on both sides has aYand aTentry).hparam_policy="per_fold"restores the previous behaviour (a FLAML search inside every fold). Independently of the policy, Phase 1 reuses its results as repetition 0 of Phase 3, Phase 3 runs all(candidate, repetition)jobs as one parallel job list, and the parallel sections allocateworkers = min(tasks, cores)processes withcores // workerslearner threads each (logged asworkers = W, threads/worker = h). The sensitivity analysis fits the benchmark's short model once per treatment and reports group-specific benchmark points (benchmark_cf_d,benchmark_cf_y, plus the newbenchmark_rhoandbenchmark_scopecolumns ofsensitivity_metrics.csv). Phase 0 is not affected byhparam_policy.
Saturation DML (optional)
By default the pipeline is unchanged: omit the saturation_config argument
and Phases 1–4 behave exactly as before. Setting
saturation_config['saturation']['family'] enables
Phase 0, which estimates a diminishing-returns parameter alpha for each
treatment and then feeds the transformed treatment f(T; alpha) into the
existing DML pipeline.
Response families
family |
f(T; alpha) |
Interpretation of alpha |
|---|---|---|
"tanh" (default) |
tanh(alpha * T / 2) |
Rate; T_1/2 = ln 3 / alpha is the half-saturation point. |
"exp" |
1 - exp(-alpha * T) |
Rate; 1/alpha is the saturation scale. |
"log" |
log(1 + T / alpha) |
Saturation scale (smaller = returns diminish sooner). |
"power" |
T ** alpha |
Elasticity; must satisfy 0 < alpha < 1. |
Configuration
Every saturation setting lives in a single saturation_config dict, and
the presence of the saturation block is all that is required
(family defaults to "tanh") — everything else defaults.
Turning it on is therefore a one-key change:
orchestrator = workflow.CausalWorkflowOrchestrator(
# ... all existing arguments ...
optimize=True,
saturation_config={"saturation": {"family": "power"}},
)
simple_optimization is derived: it becomes False automatically when a
family is set, and True otherwise. Pass it explicitly to override.
The full set of keys, all shown at their defaults:
orchestrator = workflow.CausalWorkflowOrchestrator(
# ... all existing arguments ...
optimize=True,
saturation_config={
"saturation": {
"family": "tanh", # "tanh" (default) | "exp" | "log" | "power"
"grid": { # alpha grid searched in Phase 0
"mode": "auto", # "auto" | "manual"
"num": 25,
"c_min": 0.25, # auto: T_1/2 in [c_min*q50, c_max*q95]
"c_max": 4.0,
"max_extensions": 2, # auto: x4 extension at an edge argmin
"min": None, # manual only (alpha units)
"max": None, # manual only (alpha units)
"scale": "log", # "log" | "linear" (manual spacing)
},
"basis_dim_T": "auto", # "auto" | int (B-spline T-basis size J)
"basis_tol": 1e-3, # auto: rel. RMS approximation error
"basis_dim_range": [6, 20], # auto: search range for J
"ridge": 1e-4, # ridge penalty in the closed form (absolute)
"t_ref_quantile": 0.95, # T_ref = q95(T>0): all candidates pass through (T_ref, 1)
},
"alpha_selection": {
"sel_fraction": 0.20, # D_sel : D_inf split
"n_seeds": 10, # S: repetitions of R-loss profiling
"k_folds": 5, # K for the alpha-selection stage
"refine": "brent", # "brent" | "none" (grid + Brent only)
"linear_test_k": 1.0, # adopt saturation iff d_bar < -crit * se (se = two-way cluster-robust se_obs)
"linear_test_t_critical": False, # crit = k (False) | Student-t value t_{G-1}^{-1}(Phi(k)) (True)
"fallback_half_saturation_T": None, # half-saturation point (units of T) used when Diag1/Diag2 fires
"flatness_ratio": 10.0, # Diag2: fires when max/min T_1/2 over the 1-SE set exceeds this
"t_ref_tolerance": 1.0e-9, # Diag6: rel. error tolerated between Phase 0 and Phase 1 T_ref
"learners": { # Stage B nuisance learners (fixed)
"learner_selection": False, # True: legacy FLAML candidate search
"y_model": {"class": "xgboost.XGBRegressor",
"params": {...}, "early_stopping_rounds": 30},
"t_model": {"class": "xgboost.XGBRegressor",
"params": {"multi_strategy": "multi_output_tree",
...},
"early_stopping_rounds": 30},
"small_sample_threshold": 2000, # n_sel below -> RidgeCV
"small_sample_model": "ridge_cv",
},
"output_dir": "step0_saturation", # under output_dir
# "time_budget": 30, # learner_selection only
},
"final_fit": {"data": "d_inf"}, # "d_inf" (default) | "full_sample"
# Saturation-aware optimization. Note this lives
# under `optimization`, not `saturation`.
"optimization": {
"budget_mode": "fixed", # "fixed" (budget K) | "roi_target" (no budget; ROI >= r)
"budget_increment": 0.0, # fixed only: K = (1 + x) * actual total of the target units
"budget_reallocate": True, # fixed only: redistribute what the ROI cap / negative units free up
"cap_quantile": 1.0, # support cap T_bar_t = max(Q_q(cell, T>0), T_actual); 1.0 = cell maximum
"reallocate_within": "all", # "all" (one KKT problem; spend moves across days) | "date" (one per date)
},
},
# top-level optimizer scope (None = all rows of the column).
opt_geos=None,
opt_items=None,
opt_threshold_roi=1.0, # r >= 0 (default 1.0 across all optimizer paths); < 0 raises at initialization
)
The keys in detail:
| Key | Default | Meaning |
|---|---|---|
saturation.family |
"tanh" |
Used when the saturation block is present but family is omitted. family: None in the block still means a linear run. |
saturation.grid.mode |
"auto" |
auto builds a dimensionless half-saturation grid T_1/2 in [c_min*q50(T>0), c_max*q95(T>0)], log-spaced, for tanh/exp/log. power always uses a linear alpha grid. manual uses min/max/scale in alpha units. A legacy grid with min/max but no mode is treated as manual. |
saturation.grid.c_min, c_max |
0.25, 4.0 |
Auto-grid range multipliers. |
saturation.grid.max_extensions |
2 |
When the argmin is at an auto-grid edge, extend that side x4 in T_1/2 (matrix algebra only, no ML refits). |
saturation.basis_dim_T |
"auto" |
auto scans J in basis_dim_range and picks the smallest J whose worst-case relative RMS error of projecting fbar(.;alpha) is below basis_tol. The projection is a least-squares fit (SVD pseudo-inverse, never B'B) on a fixed grid over [0, T_max] made of 500 equally spaced points, 201 quantiles of T>0 and the Greville abscissae of the basis, so the basis matrix B has full column rank however skewed T is; T_max is the 99.9 % quantile of T>0 on the full fitting data (rows above it are clipped by the basis only, never by Phases 1-4). Bases with condition number cond(B) >= 1e8 are skipped; if no J meets basis_tol, the J with the smallest error is used (Diag1 warns). An integer pins J. A pinned or only-available basis with cond(B) >= 1e8 makes Stage B adopt the linear model (decision_reason = "basis_ill_conditioned") instead of trusting it. |
saturation.basis_tol, basis_dim_range |
1e-3, [6, 20] |
See above. |
saturation.t_ref_quantile |
0.95 |
Quantile of T>0, computed once on the full fitting data before the split, that defines T_ref. Every candidate is normalized to fbar(T; alpha) = f(T; alpha) / f(T_ref; alpha), so all curves pass through (T_ref, 1) and the identity candidate is fbar_T = T / T_ref. This removes the alpha-dependent magnitude of f that let the ridge solve shrink near-identity candidates (large T_1/2) to zero. Always on. |
alpha_selection.linear_test_k |
1.0 |
Saturation is adopted only if d_bar < -crit*se, where d_s = L_s(alpha_hat) - L_s,T is the paired difference against the identity candidate fbar_T = T / T_ref and se is the cluster-robust se_obs of the per-observation differences d_i. With two adopted cluster keys (e.g. geo × date block) the se is two-way (Cameron–Gelbach–Miller: V = V_key1 + V_key2 - V_cell), which covers dependence along both keys; with a single key it is one-way on that key (there is no across-seed se). The one-way se on the fewer-cluster key is always reported next to it (se_one_way), and the decision line notes when it would have decided differently. crit = k by default (see the next row). Otherwise selected_family = None (linear). The adoption rate is tuned with k alone: k = 1.0 is the 1-SE rule, 2.0 a conservative choice, smaller values adopt saturation more readily. Independently of this test, an alpha_hat still on the upper grid edge after max_extensions extensions forces selected_family = None with decision_reason = "grid_edge_upper" (the data cannot distinguish the curve from a line); a lower-edge hit only warns. |
alpha_selection.linear_test_t_critical |
False |
True replaces crit = k by the Student-t value with the same one-sided tail probability, crit = t_{G-1}^{-1}(Phi(k)), G = number of clusters (the smaller of the two counts for the two-way se). It compensates the noisy se of few clusters (k = 1: G = 30 -> 1.02, 10 -> 1.06, 4 -> 1.20) and can only make linear decisions more frequent; it is not applied to the Diag2 1-SE set. Off by default. |
alpha_selection.fallback_half_saturation_T |
None |
A fixed half-saturation point in units of T (T_knee for log, alpha itself for power; same scale as half_saturation_T / T_ref in summary_alpha_selection.json, i.e. T / c). When Diag1 or Diag2 fires it replaces the estimate downstream and keeps the family; otherwise the 1-SE decision stands. |
alpha_selection.flatness_ratio |
10.0 |
Diag2 fires when T_1/2^max / T_1/2^min over the 1-SE set A_1SE = {alpha : L_bar(alpha) - L_bar(alpha_hat) <= se(alpha)} exceeds this (T_knee for log; for power the fixed rule alpha^max - alpha^min > 0.4). Warning only when a family is adopted, INFO otherwise. |
alpha_selection.t_ref_tolerance |
1e-9 |
Diag6: at the start of Phase 1 T_ref is recomputed from the treatment column Phase 1 receives; a relative error at or above this stops the run with an error (unit / hand-over bug). |
optimization.budget_mode |
"fixed" |
fixed: a budget K is given and the allocation is solved in one KKT pass. roi_target: no budget; every unit receives min(T_bar_t, T_t^r) (requires opt_threshold_roi > 0). |
optimization.budget_reallocate |
True |
fixed only. True (default): K' = K, solve once with the effective caps min(T_bar_t, T_t^r) (spend freed by negative-ROI units and the ROI cap is redistributed). False: K' = K_+ (negative-ROI spend removed), solve with the caps T_bar_t, then cut to T_t^r without redistribution (unspent_budget). |
optimization.cap_quantile |
1.0 |
q of the support cap T_bar_t = max(Q_q(cell_t), T_t^actual): Q_q is the q-quantile of the positive actual treatment within the unit's geo × item cell (consistent with T_ref = q95(T>0); zero-spend rows do not shift it), floored at the unit's own actual spend. 1.0 (default) is the cell maximum — today's behaviour, where one outlier day lifts the cap of every unit in its cell. With e.g. 0.95 the other units are capped at the 95 % point while the outlier day keeps its own spend as its cap: it is never increased, but may still be reduced by the KKT condition or the ROI cap like any unit. Must be in (0, 1]. |
optimization.reallocate_within |
"all" |
Scope of the KKT problem. "all": one problem over every allocation unit (spend moves across days, geos and items; today's behaviour). "date": one problem per date with the per-date budget K_d = (1 + budget_increment) * sum_{t in d} T_t^actual — no cross-day movement; mu* becomes per date (median with marginal_roi_at_optimum_min / _max in optimization_metrics.csv). See the no-carryover note under "Saturation-aware optimization". |
opt_threshold_roi (top level) |
1.0 |
ROI floor r >= 0, applied per row across both simple_optimization=True and False (including budget_mode='roi_target'). |
opt_geos, opt_items (top level) |
None |
Restrict the allocation units of the saturation-aware optimizer to these geos / items (None = all rows of the column). Rows outside keep their actual values in the optimized_* columns. |
alpha_selection.learners |
see above | Fixed Stage B learners. learner_selection: True restores the legacy FLAML search. Without XGBoost >= 2.0 a warning is emitted and scikit-learn stand-ins are used: HistGradientBoostingRegressor for Y and one native multi-output RandomForestRegressor for T. |
alpha_selection.output_dir |
"step0_saturation" |
Sub-directory of output_dir that receives the Phase 0 files. |
What Phase 0 adds
- Top-level split into
D_sel(alpha selection) andD_inf(estimation). With two cluster keys, the split unit is their intersection cell (e.g.geo × date block). No cell straddles the two halves, and every level of each key appears in both halves and, when it has at least K cells inD_sel, in every Stage B fold (others_split_coverage.csv). - Alpha selection by minimizing the profiled R-loss over the
half-saturation grid, with optional Brent refinement. The same CATE basis
p(X)is used as in Phases 1–3. Every nuisance prediction is out-of-fold (cross-fitting: each(seed, fold)model predicts only its held-out rows) and the inner CATE coefficients of foldkare fitted on the out-of-fold residuals of the other folds, so no in-sample residual enters the loss. The intercept-only curve is drawn for visualization only. - Identity-candidate comparison. All candidates are normalized
through the reference point
(T_ref, 1),T_ref = q95(T>0):fbar(T; alpha) = f(T; alpha) / f(T_ref; alpha). The saturated fit must beat the identity candidatefbar_T = T / T_refby more thancrit = linear_test_kcluster-robust standard errors (two-way on the two adopted keys when both are known; optionally a Student-t critical value withlinear_test_t_critical). Otherwiseselected_family = None. Analpha_hatleft on the upper grid edge after all extensions also forcesNone(decision_reason = "grid_edge_upper"). 3b. Diagnostics Diag1–Diag6. Diag1: basis approximation erroreps(J)on the final grid (warn if>= basis_tol;Jis fixed after the initial grid) and basis conditioningcond(B)(warn if>= 1e8; the basis is then not trusted and the linear model is adopted,decision_reason = "basis_ill_conditioned"); the single Diag1 line also showsT_max(99.9 % quantile ofT>0) and the number ofD_selrows the basis clips at it. Diag2: 1-SE flatness, the ratio of the largest to the smallest half-saturation point in the 1-SE set (warn if> flatness_ratioand a family is adopted; INFO otherwise). Diag3:r_T = sd(T~)/sd(T/T_ref)and Diag4: the shape quantilesfbar(q_p(T>0); alpha_hat)with their linear referencesq_p / T_ref(supporting values, no rule). Diag5:kappa(alpha_hat) = f(T_ref; alpha_hat)fortanh/exp(no rule). Diag6: unit consistency ofT_refat the start of Phase 1 (error stop). Diag1 / Diag2 warn and continue; the fixedfallback_half_saturation_T, if set, is used downstream when they fire. - If saturation is adopted,
alphais treated as a constant and Phases 1–3 run unmodified onfbar(T; alpha)(so the estimatedthetais the effect of oneT_refworth of treatment). Otherwise they run on the raw treatment, and Phase 4 falls back to the linear optimizer. - Every row is scored. With the default
final_fit.data = "d_inf", Phases 1–3 are fitted onD_infonly, but the fitted nuisance models and the CATE function are then applied to the held-outD_selrows as well. Everything understep4_consolidated_results/— the consolidated CSV, the CATE / incremental-KPI / ROI summaries, the charts and the Phase 4 optimization — therefore covers all rows, not justD_inf. The consolidated CSV carries anestimation_samplecolumn (D_inf= fitted row /D_sel= scored row), the 1c metrics reportnumber_of_fitted_rowsandnumber_of_scored_rows, and the log states e.g.Consolidated frame covers all 180 rows: 142 fitted (D_inf) + 38 scored with the fitted CATE (D_sel). The OVB sensitivity analysis keeps using the fitted rows only.
Phase 0 outputs
All written to output_dir/step0_saturation/. With several
treatments each one gets its own step0_saturation/<treatment>/ directory.
Every file name carries a prefix that says what it belongs to: diag1_ …
diag6_ for the six identification diagnostics, summary_ for the result of the alpha
selection, others_ for supporting material without a diagnostic rule. Every
PNG ends with a footnote that explains its annotations, states the rule /
status of its diagnostic for this run and names the CSV columns behind it.
| File | Contents |
|---|---|
summary_alpha_selection.json |
family, selected_family, alpha_hat, half_saturation_T (T_knee for log), alpha_grid_argmin, rloss_min (L_bar(alpha_hat)), refine_method, d_bar, se (cluster-robust se_obs), linear_test_k, decision, decision_reason (1se_pass / 1se_fail / grid_edge_upper), diag1_fired, J, basis_approx_error, diag2_fired, diag2_ratio, r_T, fbar_q25 … fbar_q90, linear_ref_q25 … linear_ref_q90, saturation_at_T_ref (null for log / power), grid_mode, grid_extensions, grid_edge_hit (none / upper / lower), T_ref, T_max, cluster_keys, cluster_fallback_tier, n_sel, n_inf, n_seeds, k_folds, learners, plus extras (for example phase4_optimization, diag6_status, flatness_ratio, basis_tol, fallback_half_saturation_T_used, t_ref_quantile, T_ref_raw, grid_variable, and the divisor of the normalized treatment: normalization, divisor_constant, divisor_population_col, divisor_formula, divisor_description). NaN is written as null. Phases 1–3 and Phase 4 read T_ref from this file. |
summary_alpha_grid.csv |
One row per grid point: grid_index, half_saturation_T, alpha, mean_r_loss (L_bar(alpha)), se_alpha (the Diag2 se(alpha)), in_1se_set (membership in A_1SE), fbar_q25, fbar_q50, fbar_q75, fbar_q90 (phi_p(alpha) = fbar(q_p(T>0); alpha)), basis_approx_error_alpha (the Diag1 eps(J) per alpha). This is the complete data behind the R-loss profile charts (it replaces the former alpha_profile_<treatment>.csv). |
summary_rloss_curves.csv |
seed, grid_index, alpha, rloss_cate, rloss_ate, rloss_identity. |
summary_alpha_profile_<treatment>.png |
The one-page Phase 0 summary, 2 + 2 + 1 panels from top to bottom: Grid construction (histogram of T>0 with q50, q95 = T_ref, the automatic grid range and every grid point) and Seed-wise alpha distribution (per-seed alpha_hat_s histogram); Profiled R-loss curve with A_1SE and Fitted saturation curve (normalized through (T_ref, 1)) — fbar(T; alpha_hat), the identity candidate T / T_ref, (T_ref, 1), the shapes of the smallest / median / largest grid alpha and the curve Phases 1–4 actually use labelled FINAL; and the full-width Key stats & process metrics table whose diagnostics column tags each row with diag1 … diag6. The footnotes define T_ref, q05 / q50 / q95, the grid ends 0.25*q50 (fastest saturation) / 4*q95 (mildest, practically linear) and list every file of the folder. |
summary_alpha_seed-wise_alpha_distribution.csv |
The data behind the seed-wise alpha distribution panel: seed, alpha_hat_s, rloss_at_alpha_hat_s, rloss_identity_s, d_s (visualization only, no rule). |
summary_alpha_selection.csv, summary_phase0_diagnostics.json |
Run-level: one row per treatment (decision, alpha_hat, Diag1–Diag5 flags, compute accounting) and the Stage A split / cluster-key plan with every Phase 0 warning. |
diag1_basis_approx_error.png |
Diag1: eps(J)(alpha) per grid point against basis_tol, with J, max eps(J) and the fired status in the title. |
diag2_rloss_profile.png |
Diag2: R-loss vs T_1/2: CATE curve (±1 se), intercept curve (visualization only), identity candidate L_bar_T (±k se), the 1-SE set A_1SE, alpha_hat, decision; the footnote states the flatness ratio against flatness_ratio and whether Diag2 fired. |
diag3_identification_diagnostics.csv |
Diag3: r_T, sd_T, sd_T_resid, share_T_zero, q50_T_pos, q95_T_pos, T_max, T_ref. |
diag4_f_transform_quantiles.png |
Diag4: the four shape quantiles phi_25, phi_50, phi_75, phi_90 over alpha with the linear references q_p / T_ref. |
diag5_saturation_at_T_ref.png |
Diag5 (tanh / exp only): kappa(alpha) = f(T_ref; alpha), the share of the ceiling reached at T_ref, over the grid with kappa(alpha_hat) marked. |
diag6_t_ref_consistency.csv |
Diag6, written at the start of Phase 1: treatment, status (pass / fail / skipped when selected_family = None), T_ref_phase0, T_ref_phase1, rel_error, t_ref_tolerance, t_ref_quantile, n_rows_phase1, note. |
others_split_coverage.csv, others_split_coverage_detail.csv, others_split_coverage.png |
Stage A / Stage B bookkeeping: per key level n_cells_sel, n_cells_inf, covered, n_folds_covered_min, fold_covered (the summary CSV, drawn as the bar chart of n_cells_sel / n_cells_inf per level with uncovered levels in red); and the per-cell detail seed, cluster_id, key1_value, key2_value, fold (-1 for D_inf), split (sel / inf). |
The HTML report gains a 000_saturation tab, which embeds
diag2_rloss_profile.png and summary_alpha_profile_<treatment>.png and shows
decision, decision_reason, alpha_hat, half_saturation_T, T_ref,
d_bar, se, diag2_ratio, saturation_at_T_ref and r_T.
Saturation-aware optimization
With optimize=True, simple_optimization=False, one treatment of type
spend and selected_family != None, Phase 4 solves
max sum_t theta_t fbar(T_t) s.t. sum_t T_t <= K', 0 <= T_t <= T_bar_t^eff
in one KKT pass: one allocation unit is one row (item × geo × date) inside
opt_periods ∩ opt_geos × opt_items; theta_t = p(X_t)' beta_hat; the
support cap T_bar_t = max(Q_q(cell_t), T_t^actual) is the
optimization.cap_quantile-quantile q of the positive actual treatment
within the unit's geo × item cell, floored at the unit's own actual spend (the
default q = 1.0 is the cell maximum, i.e. the historical cap; with
q = 0.95 an outlier day keeps its own actual spend as its cap — it can be cut
by the KKT condition or the ROI cap but no unit is ever pushed above its cap —
while every other unit is capped at the cell's 95 % point); the ROI threshold
r = opt_threshold_roi is a per-unit cap T_t^r (the root of
theta_t fbar(T)/T = r; 0 when theta_t fbar'(0) <= r; inf when
r = 0), T_bar_t^eff = min(T_bar_t, T_t^r); units with theta_t <= 0
receive 0. T_t*(mu) = clip((fbar')^-1(mu / theta_t), 0, T_bar_t^eff) with
the closed forms of all four families and a single bisection on mu so that
sum_t T_t*(mu) = K' (mu* = 0 when the caps absorb the budget). The former
"exclude below the threshold → re-solve → iterate" loop no longer exists.
budget_mode = roi_target skips the budget and sets T_t* = T_bar_t^eff.
Any other combination (several treatments or selected_family = None) falls
back to the existing optimizer with a warning; a non-spend treatment type
forces simple_optimization=True at construction (see above);
simple_optimization=False is otherwise never overridden when
saturation_config is given, and opt_threshold_roi < 0 raises at
initialization.
The saturation optimizer additionally writes optimized_incremental_KPI_<t> and
optimized_roi_<t> into the consolidated CSV and into
007_optimization/optimization_metrics.csv, where every geo / item / all
row also carries budget_mode, budget_reallocate, reallocate_within,
cap_quantile, assumptions (no_carryover), K, K_out,
unspent_budget, marginal_roi_at_optimum (mu*; with
marginal_roi_at_optimum_min / _max, equal to it under
reallocate_within = "all"), n_units, n_units_excluded_negative_roi,
n_units_capped_by_roi and n_units_at_support_cap (K, K_out,
unspent_budget and the counts are aggregated over the units of that row, so
a geo row can show a negative unspent_budget when budget was reallocated
into it; budget_mode, the scope and mu* describe the whole problem). The
same values appear in the HTML descriptive block of the 007_optimization
tab. When treatment_type="spend" it also writes, per geo, per item and for
the total, the two-line chart <prefix>_original_vs_optimal_treatment.png
(purple original vs green optimal treatment); the incremental effect is covered
by the before / after chart below.
Both optimizers write, per geo, per item and for the total, the outcome chart
<prefix>_<outcome>.png: the red observed outcome (Original KPI) against the
blue projected outcome under the optimized spend (Estimated total KPI with optimal treatment, Y + (KPI_opt - KPI) summed per date — for the simple
optimizer the KPI of the cut rows leaves the total). The HTML tabs
007_optimization and roi_output_007_optimization (<treatment>) show it in
the same position whatever simple_optimization is: right after the
optimizer's own chart and above the before / after charts.
Both optimizers (simple and saturation-aware) write, per geo, per item and for
the total, three before / after charts — two line charts stacked vertically on
an identical vertical scale, "before optimization" on top and "after
optimization" below, every panel on exactly the width and x-axis layout of
<prefix>_timeseries_spend_vs_est.png so the dates line up:
<prefix>_timeseries_spend_before_after.png (purple actual spend
(treatment_spend_cols) / dark green optimized spend),
<prefix>_timeseries_incremental_effect_before_after.png (red
incremental effect of the actual spend / blue incremental effect under the
optimized spend) and <prefix>_timeseries_roi_before_after.png (red ROI per
date = incremental KPI / spend, 0 without spend / blue ROI under the
optimized spend, with the ROI threshold as a dotted reference line) — every
panel of the three charts is exactly half the height of spend_vs_est at the
same vertical scale, so each before / after pair fits one screen — and
optimization_yearly_summary.csv (per dimension and calendar year plus
Total: spend_before, incremental_kpi_before, roi_before,
spend_after, incremental_kpi_after, roi_after). The HTML tabs
007_optimization and roi_output_007_optimization (<treatment>) show the
three charts in that order under the existing optimization chart and render
the CSV as a matrix below them that follows the dimension dropdown: one row per
metric (Spend, Incremental KPI, ROI) under "Before optimization" / "After
optimization", one column per year plus Total, numbers only in the cells; the
matrix always fits the width of the tab (its font size adapts to the widest
number, down to 9 px, after which the label columns narrow and, as a last
resort, the matrix scrolls inside the tab rather than cutting digits).
HTML report
When generate_html=True the interactive report is written to the root of
output_dir as <treatment>_<outcome>_<family>.html, for example
tv_spend-digital_spend_sales_power.html. Multiple treatments are joined with a
hyphen, and the _<family> segment is omitted when saturation is not enabled
(tv_spend_sales.html).
Analyzing Results with AI (Browser Chat or ADK Agent)
There are two ways to explore the causal estimates with Gemini:
- Directly in
report.html(no server required): Open<output_dir>/report.htmlin your browser, select the✨ AI Assistant (Gemini)tab, and enter a Gemini API Key to chat interactively with the precomputedCausalResultsReaderoutputs and bundledretail-analyticsskill. - Local ADK Agent (
generate_agent=True): By default, the pipeline also writes a runnable ADK agent to<output_dir>/agents/causal_ml_insight_agent/. The agent answers business questions such as "Which promotion pays back, where, and when?" from the pipeline outputs. Its tools read the results and do the arithmetic deterministically, so the LLM interprets the DoubleML estimates instead of computing causal effects itself. The domain knowledge comes from Agent Skills bundled inskills/, starting withretail-analytics. Coding agents can use these skills directly too.
cp causal_results/agents/causal_ml_insight_agent/.env.example \
causal_results/agents/causal_ml_insight_agent/.env # Set your project.
adk web causal_results/agents
To regenerate the agent with your own skills or model, or to add one to the results of an earlier run:
from doubleml_pipeline.agent import generate_agent
generate_agent("./causal_results", skills_dir="./my_skills")
If you run one orchestrator per channel, one agent can cover all of the runs. It compares the channels side by side but never adds their effects up, since each run is estimated separately:
generate_agent(
["results/Spend_TV", "results/Spend_Digital", "results/Spend_Flyer"],
output_dir="results",
)
# adk web results/agents
User Guide
For a comprehensive step-by-step walkthrough of pipeline settings, modeling workflows, and visual slide guides, refer to the User Guide.
Engaging with the Project
Documentation & Examples
- User Guide: For a step-by-step walkthrough of pipeline settings and visual slide guides, refer to the User Guide.
- API Reference: For a comprehensive list of modules, classes, and function signatures, refer to the API Reference.
- Output CSV Schema: For detailed specifications of all output CSV files, including column names, data types, and descriptions, refer to the Output CSV Schema.
- Interactive Samples: Check out the sample Jupyter notebooks
(
IN_skincareandJP_retailer), which walk through loading realistic simulated panel marketing data, engineering lagged features, scaling, running the DML pipeline, and plotting the results.
Contributing & Feedback
We welcome contributions!
- Reporting Issues: If you encounter bugs or want to request features, please open an issue in the GitHub repository issue tracker.
- Submitting Changes: Please see CONTRIBUTING.md for our guidelines on submitting pull requests.
Acknowledgments & Dataset Credits
We gratefully acknowledge our partners for their collaboration on the simulation
datasets included under examples/:
examples/IN_skincare/: Simulation data and example materials provided in collaboration with Analytic Edge.examples/JP_retailer/: Simulation data designed and provided by Hakuhodo Inc., informed by real-world Japanese marketing dynamics.
References
-
Debiased Machine Learning method Victor Chernozhukov, Denis Chetverikov, Mert Demirer, Esther Duflo, Christian Hansen, Whitney Newey, James Robins. Double/debiased machine learning for treatment and structural parameters. arXiv:1608.00060
-
Hyperparameter Tuning for Causal Inference with Double Machine Learning: A Simulation Study Martin Spindler, et al. arXiv:2402.04674
-
Debiased Machine Learning of Conditional Average Treatment Effects and Other Causal Functions Vira Semenova, Victor Chernozhukov. arXiv:1702.06240
-
Multiway Cluster Robust Double/Debiased Machine Learning Harold D. Chiang, Kengo Kato, Yukitoshi Matsushita, Takuya Ishihara. arXiv:1909.03489
Metadata
Release files for doubleml-pipeline 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| doubleml_pipeline-0.2.0.tar.gz | 446.0 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| doubleml_pipeline-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 765.3 kB
Release files / doubleml_pipeline-0.2.0.tar.gz
| Download URL | doubleml_pipeline-0.2.0.tar.gz |
|---|---|
| Size | 446.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1985e790532b55e8bbfd970f8ba058151818096e7c0c67dba4b900b36edcb12e
|
|
BLAKE2b-256 checksum How to use checksums |
bc88ffd59d201ed3e165588a7adeabfa5d18ae7fdbd11c4640d0a497ed2a92e0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.27 {"installer":{"name":"uv","version":"0.11.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux rodete","version":null,"id":"rodete","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / doubleml_pipeline-0.2.0-py3-none-any.whl
| Download URL | doubleml_pipeline-0.2.0-py3-none-any.whl |
|---|---|
| Size | 319.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ebe05ac633b51158f77c2ed149e71778d0114c26d4b50297dc833e4495f715a5
|
|
BLAKE2b-256 checksum How to use checksums |
27993c0d478d65aead3964f7ab514fd6406ba6186200ba87151e5ade5c3d2948
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.27 {"installer":{"name":"uv","version":"0.11.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux rodete","version":null,"id":"rodete","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for doubleml-pipeline 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.