Cohort study design analysis in Python for Real-World Evidence¶
Skill details
Version 0.5 · Author Yen Low · License Databricks
Overview¶
This skill provides a complete toolkit for comparative effectiveness research (CER) and real-world evidence studies using Python. It maps the standard R-based RWE workflow (MatchIt, WeightIt, cobalt, survival, survey, tableone, EValue, tipr) to Python equivalents using scikit-learn, statsmodels, lifelines, and custom implementations.
When to Use¶
Load this skill when the user wants to: - Compare two or more treatment groups in observational (non-randomized) data - Apply propensity score matching, weighting, or stratification - Build an external control arm from real-world data - Emulate a target trial from EHR or claims data - Assess sensitivity of observational findings to unmeasured confounding - Handle time-varying confounding with marginal structural models - Produce balance diagnostics (standardized mean differences, love plots) - Estimate treatment effects with proper confidence intervals
Prerequisites¶
Package Mapping: R to Python¶
| R Package | Python Equivalent | Notes |
|---|---|---|
| MatchIt | sklearn + custom nearest-neighbor | Logistic regression for PS, sklearn.neighbors for matching |
| WeightIt | sklearn + manual weight computation | IPW, stabilized, overlap weights computed directly |
| cobalt | Custom SMD functions + matplotlib | Love plots and balance tables from scratch |
| tableone | Custom groupby + SMD | Baseline characteristics table with tests |
| survey | statsmodels.stats.weightstats | Weighted mean, proportion, quantile estimation |
| survival | lifelines | KM curves, Cox PH, logrank tests |
| EValue | Custom E-value function | See sensitivity analysis section |
| tipr | Custom tipping point function | See sensitivity analysis section |
| twang | sklearn GBM + custom weight extraction | GBM-based PS with balance optimization |
| optmatch | scipy.optimize.linear_sum_assignment | Optimal matching via Hungarian algorithm |
%pip install scikit-learn statsmodels lifelines matplotlib seaborn --quiet
import pandas as pd
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import cross_val_predict
from sklearn.calibration import calibration_curve
from sklearn.metrics import roc_auc_score, brier_score_loss
import statsmodels.api as sm
import statsmodels.formula.api as smf
from lifelines import KaplanMeierFitter, CoxPHFitter
from lifelines.statistics import logrank_test, proportional_hazard_test
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')
Workflow¶
- Define the study question (PICO: population, intervention, comparator, outcome)
- Cohort assembly (inclusion/exclusion criteria, treatment assignment, follow-up)
- Covariate assessment (baseline confounders measured pre-treatment)
- Propensity score estimation (probability of treatment given covariates)
- Confounding adjustment (matching, weighting, or stratification)
- Balance diagnostics (verify covariate balance after adjustment, SMD < 0.1)
- Treatment effect estimation (outcome model on adjusted sample/weights)
- Sensitivity analysis (E-values, tipping point analysis)
- Reporting (baseline table, balance table, effect estimates with CIs, survival curves)
Guardrails¶
- Adjust only for baseline confounders measured before treatment; never put post-treatment variables in the propensity score model.
- Check common support before estimating effects; report how many patients fall outside it and trim (Section 4.3) rather than extrapolate.
- Do not report an effect until balance is verified: every covariate needs SMD < 0.1 after adjustment.
- Never present an unadjusted comparison as a causal effect; confounding by indication is the default in observational data.
- Report every effect estimate with a 95% CI; use robust (sandwich) standard errors for weighted models.
- Pair every causal estimate with a sensitivity analysis for unmeasured confounding (E-value or tipping point).
- For time-to-event outcomes, test proportional hazards (Schoenfeld) before reporting a Cox HR.
Troubleshooting¶
| Problem | Likely cause | Fix |
|---|---|---|
| Few treated patients matched | Caliper too tight or poor overlap | Check common support; caliper 0.1-0.2 SD of logit(PS) is typical (Section 3) |
| Extreme IPW weights | PS near 0 or 1 | Stabilize weights, trim at percentiles, or use overlap weights (Section 4) |
| SMD > 0.1 after adjustment | PS model misspecified | Add interactions or nonlinear terms, or use a GBM PS model; re-check balance |
| Schoenfeld test rejects PH | Hazards not proportional | Report RMST difference, which does not assume PH (Section 7.3) |
| Weighted Cox CIs too narrow | Naive SEs ignore weighting | Fit with robust=True in lifelines |
| PS logistic model does not converge | Unscaled or collinear covariates | Standardize covariates, raise max_iter, or add L1/L2 regularization |
1. Study Design and Target Trial Emulation¶
Defining the Target Trial¶
Before writing any code, articulate the trial you are emulating:
study_design = {
'question': 'Does drug A reduce mortality compared to drug B in adults with T2DM?',
'population': 'Adults >=18y with T2DM, no prior exposure to either drug',
'intervention': 'Initiation of Drug A',
'comparator': 'Initiation of Drug B',
'outcome': 'All-cause mortality within 2 years',
'follow_up': '2 years from treatment initiation (time zero)',
'assignment': 'New user (initiator) design',
'eligibility_window': 'Index date = first prescription; covariates from 365d pre-index',
'exclusions': ['Prior cancer', 'Pregnancy', '< 365d enrollment pre-index', 'Age < 18'],
}
for k, v in study_design.items():
print(f'{k}: {v}')
Key Design Principles¶
- Time zero: treatment assignment and follow-up start. All covariates must be measured BEFORE time zero.
- New-user design: exclude prior exposure to either treatment (avoids prevalent-user bias).
- Active comparator: use an active treatment as comparator when possible (minimizes confounding by indication).
- Grace period: allow a window (e.g., 30 days) between eligibility and treatment initiation.
- Intention-to-treat: patients stay in assigned group regardless of discontinuation.
Cohort Assembly¶
def assemble_cohort(df, index_date_col='index_date', treatment_col='treatment',
min_enrollment_days=365, min_age=18, max_followup_days=730):
"""Assemble a new-user cohort with eligibility criteria."""
cohort = df.copy()
cohort = cohort[cohort['prior_exposure_drugA'] == False]
cohort = cohort[cohort['prior_exposure_drugB'] == False]
cohort = cohort[cohort['days_enrolled_pre_index'] >= min_enrollment_days]
cohort = cohort[cohort['age_at_index'] >= min_age]
for excl_col in ['prior_cancer', 'pregnancy_at_index']:
if excl_col in cohort.columns:
cohort = cohort[~cohort[excl_col]]
cohort['followup_days'] = (
cohort['disenrollment_date'] - cohort[index_date_col]
).dt.days.clip(upper=max_followup_days).clip(lower=0)
cohort['event'] = cohort['event_date'].notna().astype(int)
cohort['time_to_event'] = np.where(
cohort['event'] == 1,
(cohort['event_date'] - cohort[index_date_col]).dt.days,
cohort['followup_days']
)
print(f"Cohort: {len(cohort)} | Treated: {(cohort[treatment_col]==1).sum()} | Control: {(cohort[treatment_col]==0).sum()}")
return cohort.reset_index(drop=True)
2. Propensity Score Estimation¶
The propensity score is e(X) = P(treatment=1 | X), estimated via logistic regression or ML methods.
def estimate_propensity_scores(df, covariates, treatment_col='treatment',
method='logistic', cv_folds=5, random_state=42):
"""
method: 'logistic' (standard), 'lasso' (L1 variable selection),
'rf' (random forest, cross-fitted), 'gbm' (gradient boosting, cross-fitted)
"""
X = df[covariates].copy().fillna(df[covariates].median())
y = df[treatment_col].values
scaler = StandardScaler()
X_scaled = pd.DataFrame(scaler.fit_transform(X), columns=covariates, index=df.index)
if method == 'logistic':
model = LogisticRegression(penalty='l2', C=1.0, solver='lbfgs', max_iter=2000)
model.fit(X_scaled, y)
ps = model.predict_proba(X_scaled)[:, 1]
print(f"Logistic PS AUC: {roc_auc_score(y, ps):.4f}")
elif method == 'lasso':
model = LogisticRegression(penalty='l1', C=0.1, solver='saga', max_iter=5000)
model.fit(X_scaled, y)
ps = model.predict_proba(X_scaled)[:, 1]
n_sel = (model.coef_[0] != 0).sum()
print(f"Lasso PS AUC: {roc_auc_score(y, ps):.4f} ({n_sel}/{len(covariates)} covariates)")
elif method == 'rf':
model = RandomForestClassifier(n_estimators=500, max_depth=6, min_samples_leaf=20)
ps = cross_val_predict(model, X_scaled, y, cv=cv_folds, method='predict_proba')[:, 1]
model.fit(X_scaled, y)
print(f"RF PS AUC (CV): {roc_auc_score(y, ps):.4f}")
elif method == 'gbm':
model = GradientBoostingClassifier(n_estimators=300, max_depth=3, learning_rate=0.05, subsample=0.8)
ps = cross_val_predict(model, X_scaled, y, cv=cv_folds, method='predict_proba')[:, 1]
model.fit(X_scaled, y)
print(f"GBM PS AUC (CV): {roc_auc_score(y, ps):.4f}")
df = df.copy()
df['propensity_score'] = ps.clip(0.01, 0.99)
print(f"Brier score: {brier_score_loss(y, ps):.4f} (0.25=random)")
for grp in [0, 1]:
s = df[df[treatment_col] == grp]
label = 'Treated' if grp == 1 else 'Control'
print(f" PS {label}: mean={s['propensity_score'].mean():.3f}")
return df
covariates = ['age', 'female', 'charlson_score', 'prior_mi', 'diabetes_duration',
'hba1c', 'egfr', 'bmi', 'smoker', 'statin_use', 'ace_inhibitor']
df = estimate_propensity_scores(df, covariates, method='logistic')
Common Support Check¶
def check_common_support(df, treatment_col='treatment', ps_col='propensity_score'):
"""Visualize PS distribution overlap between groups."""
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
for grp, color, label in [(0, '#1f77b4', 'Control'), (1, '#d62728', 'Treated')]:
s = df[df[treatment_col] == grp]
axes[0].hist(s[ps_col], bins=30, alpha=0.6, color=color, label=label, density=True)
axes[0].set_xlabel('PS'); axes[0].set_title('PS Distribution'); axes[0].legend()
treated = df[df[treatment_col] == 1][ps_col]
control = df[df[treatment_col] == 0][ps_col]
axes[1].hist(control, bins=30, alpha=0.7, color='#1f77b4', label='Control', orientation='horizontal')
axes[1].hist(treated, bins=30, alpha=0.7, color='#d62728', label='Treated',
orientation='horizontal', weights=np.ones_like(treated) * -1)
axes[1].set_title('Mirror Histogram'); axes[1].legend()
plt.tight_layout(); plt.show()
lo = max(treated.min(), control.min()); hi = min(treated.max(), control.max())
n_out = ((df[ps_col] < lo) | (df[ps_col] > hi)).sum()
print(f"Common support: [{lo:.3f}, {hi:.3f}] | Outside: {n_out} ({n_out/len(df)*100:.1f}%)")
3. Propensity Score Matching¶
3.1 Nearest-Neighbor Matching (1:1, with or without replacement)¶
from sklearn.neighbors import NearestNeighbors
def nearest_neighbor_match(df, treatment_col='treatment', ps_col='propensity_score',
ratio=1, caliper=None, replace=False, random_state=42):
"""
Nearest-neighbor matching on logit(PS).
ratio: controls per treated. caliper: max distance in SD of logit(PS) units (0.1-0.2 typical).
"""
np.random.seed(random_state)
treated = df[df[treatment_col] == 1].copy().reset_index(drop=True)
control = df[df[treatment_col] == 0].copy().reset_index(drop=True)
treated['ps_logit'] = np.log(treated[ps_col] / (1 - treated[ps_col]))
control['ps_logit'] = np.log(control[ps_col] / (1 - control[ps_col]))
caliper_val = caliper * np.std(treated['ps_logit'].values) if caliper else np.inf
available = list(range(len(control)))
matched_pairs = []
for t_idx in range(len(treated)):
if not available:
break
t_ps = treated.loc[t_idx, 'ps_logit']
c_vals = control.loc[available, 'ps_logit'].values
dists = np.abs(c_vals - t_ps)
n_match = min(ratio, len(available))
nearest = np.argsort(dists)[:n_match]
for pos in nearest:
if dists[pos] <= caliper_val:
matched_pairs.append((treated.index[t_idx], control.index[available[pos]]))
if not replace:
available.pop(pos)
idx = [p[0] for p in matched_pairs] + [p[1] for p in matched_pairs]
matched_data = df.loc[idx].copy()
print(f"Matched: {len(matched_pairs)} pairs")
return matched_data
matched_df = nearest_neighbor_match(df, ratio=1, caliper=0.2, replace=False)
3.2 Optimal Matching (Hungarian Algorithm)¶
from scipy.optimize import linear_sum_assignment
def optimal_match(df, treatment_col='treatment', ps_col='propensity_score', caliper=None):
"""Optimal matching minimizes total within-pair distance."""
treated = df[df[treatment_col] == 1].copy().reset_index(drop=True)
control = df[df[treatment_col] == 0].copy().reset_index(drop=True)
treated['ps_logit'] = np.log(treated[ps_col] / (1 - treated[ps_col]))
control['ps_logit'] = np.log(control[ps_col] / (1 - control[ps_col]))
dist = np.abs(treated['ps_logit'].values[:, None] - control['ps_logit'].values[None, :])
if caliper:
dist[dist > caliper * np.std(treated['ps_logit'].values)] = 1e6
row_ind, col_ind = linear_sum_assignment(dist)
if caliper:
valid = dist[row_ind, col_ind] < 1e6
row_ind, col_ind = row_ind[valid], col_ind[valid]
matched = pd.concat([treated.iloc[row_ind], control.iloc[col_ind]]).reset_index(drop=True)
print(f"Optimal matched: {len(row_ind)} pairs")
return matched
3.3 Variable-Ratio Matching¶
def variable_ratio_match(df, treatment_col='treatment', ps_col='propensity_score',
max_ratio=5, caliper=0.2, random_state=42):
"""Match more controls where controls are abundant in PS space."""
np.random.seed(random_state)
treated = df[df[treatment_col] == 1].copy().reset_index(drop=True)
control = df[df[treatment_col] == 0].copy().reset_index(drop=True)
treated['ps_logit'] = np.log(treated[ps_col] / (1 - treated[ps_col]))
control['ps_logit'] = np.log(control[ps_col] / (1 - control[ps_col]))
caliper_val = caliper * np.std(treated['ps_logit'].values)
treated = treated.sort_values('propensity_score', ascending=False).reset_index(drop=True)
available = set(range(len(control)))
matched_idx = []
for t_idx in range(len(treated)):
t_ps = treated.loc[t_idx, 'ps_logit']
avail_list = sorted(available)
c_vals = control.loc[avail_list, 'ps_logit'].values
dists = np.abs(c_vals - t_ps)
within = np.where(dists <= caliper_val)[0]
if len(within) == 0:
continue
n_match = min(max_ratio, len(within))
nearest = within[np.argsort(dists[within])[:n_match]]
for pos in nearest:
c_idx = avail_list[pos]
matched_idx.append(treated.index[t_idx])
matched_idx.append(control.index[c_idx])
available.discard(c_idx)
matched_data = df.loc[matched_idx].copy()
print(f"Variable-ratio matched: {len(matched_idx)//2} pairs")
return matched_data
4. Inverse Probability Weighting¶
4.1 Standard IPW (ATE, ATT, ATU, Overlap)¶
def compute_ipw(df, treatment_col='treatment', ps_col='propensity_score',
weight_type='ate', trim_percent=None):
"""
weight_type: 'ate' (1/PS, 1/(1-PS)), 'att' (1, PS/(1-PS)),
'atu' ((1-PS)/PS, 1), 'overlap' ((1-PS), PS)
"""
df = df.copy()
ps, z = df[ps_col].values, df[treatment_col].values
if weight_type == 'ate':
df['ipw'] = np.where(z == 1, 1/ps, 1/(1-ps))
elif weight_type == 'att':
df['ipw'] = np.where(z == 1, 1.0, ps/(1-ps))
elif weight_type == 'atu':
df['ipw'] = np.where(z == 1, (1-ps)/ps, 1.0)
elif weight_type == 'overlap':
df['ipw'] = np.where(z == 1, 1-ps, ps)
if trim_percent is not None:
lo = np.percentile(df['ipw'], trim_percent)
hi = np.percentile(df['ipw'], 100 - trim_percent)
df = df[(df['ipw'] >= lo) & (df['ipw'] <= hi)]
ess = lambda w: (w.sum()**2) / (w**2).sum()
print(f"Weight: {weight_type} | range [{df['ipw'].min():.2f}, {df['ipw'].max():.2f}]")
print(f" Effective N: T={ess(df.loc[df[treatment_col]==1,'ipw']):.0f}, C={ess(df.loc[df[treatment_col]==0,'ipw']):.0f}")
return df
df_weighted = compute_ipw(df, weight_type='ate', trim_percent=1)
4.2 Stabilized Weights¶
def compute_stabilized_weights(df, treatment_col='treatment', ps_col='propensity_score'):
"""Stabilized IPW reduces weight variance by multiplying by marginal P(treatment)."""
df = df.copy()
ps, z = df[ps_col].values, df[treatment_col].values
p = z.mean()
df['ipw_stabilized'] = np.where(z == 1, p/ps, (1-p)/(1-ps))
print(f"Stabilized weights: mean={df['ipw_stabilized'].mean():.3f}, SD={df['ipw_stabilized'].std():.3f}")
return df
4.3 Trimming Rules¶
def trim_weights(df, ps_col='propensity_score', method='crump', lower=5, upper=95):
"""crump: keep PS in [0.1, 0.9]. percentile: trim at lower/upper percentiles."""
if method == 'crump':
mask = (df[ps_col] >= 0.1) & (df[ps_col] <= 0.9)
elif method == 'percentile':
lo, hi = np.percentile(df[ps_col], [lower, upper])
mask = (df[ps_col] >= lo) & (df[ps_col] <= hi)
return df[mask].copy()
5. Balance Diagnostics¶
5.1 Standardized Mean Difference (SMD)¶
def compute_smd(data, covariate, treatment_col='treatment', weight_col=None):
"""SMD for continuous covariate. SMD > 0.1 indicates imbalance (Austin 2009)."""
t, c = data[data[treatment_col]==1], data[data[treatment_col]==0]
if weight_col:
wt, wc = t[weight_col].values, c[weight_col].values
mt, mc = np.average(t[covariate], weights=wt), np.average(c[covariate], weights=wc)
vt = np.average((t[covariate]-mt)**2, weights=wt)
vc = np.average((c[covariate]-mc)**2, weights=wc)
else:
mt, mc = t[covariate].mean(), c[covariate].mean()
vt, vc = t[covariate].var(), c[covariate].var()
sd = np.sqrt((vt + vc) / 2)
return (mt - mc) / sd if sd > 0 else 0.0
def compute_smd_categorical(data, covariate, treatment_col='treatment', weight_col=None):
"""SMD for binary/categorical variables based on proportions."""
t, c = data[data[treatment_col]==1], data[data[treatment_col]==0]
if weight_col:
pt = np.average(t[covariate], weights=t[weight_col])
pc = np.average(c[covariate], weights=c[weight_col])
else:
pt, pc = t[covariate].mean(), c[covariate].mean()
pv = (pt*(1-pt) + pc*(1-pc)) / 2
return (pt - pc) / np.sqrt(pv) if pv > 0 else 0.0
5.2 Balance Table (TableOne Equivalent)¶
def balance_table(data, covariates, treatment_col='treatment',
weight_col=None, categorical_cols=None):
"""Covariate balance table with SMDs before/after adjustment."""
categorical_cols = categorical_cols or []
results = []
for cov in covariates:
fn = compute_smd_categorical if cov in categorical_cols else compute_smd
smd_unadj = abs(fn(data, cov, treatment_col, weight_col=None))
smd_adj = abs(fn(data, cov, treatment_col, weight_col=weight_col)) if weight_col else None
results.append({'Covariate': cov, 'SMD_unadjusted': smd_unadj,
'SMD_adjusted': smd_adj, 'Balanced': (smd_adj < 0.1 if smd_adj is not None else smd_unadj < 0.1)})
bal = pd.DataFrame(results)
print(f"\n{'Covariate':<25} {'SMD(unadj)':>12} {'SMD(adj)':>12} {'OK':>5}")
print('-'*60)
for _, r in bal.iterrows():
adj = f"{r['SMD_adjusted']:.4f}" if r['SMD_adjusted'] is not None else 'N/A'
print(f"{r['Covariate']:<25} {r['SMD_unadjusted']:>12.4f} {adj:>12} {'Y' if r['Balanced'] else 'N':>5}")
print(f"Balanced: {bal['Balanced'].sum()}/{len(bal)}")
return bal
# For matched data
balance_table(matched_df, covariates, categorical_cols=['female', 'smoker', 'statin_use'])
# For weighted data
balance_table(df_weighted, covariates, weight_col='ipw', categorical_cols=['female', 'smoker', 'statin_use'])
5.3 Love Plot¶
def love_plot(data, covariates, treatment_col='treatment', weight_col=None,
categorical_cols=None, title='Covariate Balance'):
"""Love plot: SMD before vs after adjustment (cobalt::love.plot equivalent)."""
categorical_cols = categorical_cols or []
smds_before, smds_after = [], []
for cov in covariates:
fn = compute_smd_categorical if cov in categorical_cols else compute_smd
smds_before.append(abs(fn(data, cov, treatment_col, weight_col=None)))
if weight_col:
smds_after.append(abs(fn(data, cov, treatment_col, weight_col=weight_col)))
fig, ax = plt.subplots(figsize=(10, max(6, len(covariates)*0.4)))
y = np.arange(len(covariates))
ax.scatter(smds_before, y, c='#d62728', s=60, label='Unadjusted', zorder=3)
if weight_col:
ax.scatter(smds_after, y, c='#1f77b4', s=60, label='Adjusted', zorder=3)
ax.hlines(y, smds_before, smds_after, colors='grey', lw=0.8, alpha=0.5)
ax.axvline(0.1, color='black', ls='--', lw=1, alpha=0.5, label='SMD=0.1')
ax.set_yticks(y); ax.set_yticklabels(covariates)
ax.set_xlabel('Absolute SMD'); ax.set_title(title); ax.legend()
plt.tight_layout(); plt.show()
6. Treatment Effect Estimation¶
6.1 Continuous Outcome (Weighted Linear Regression)¶
def estimate_ate_continuous(data, outcome_col, covariates, treatment_col='treatment',
weight_col='ipw'):
"""
Estimate ATE for a continuous outcome via weighted least squares (WLS).
Reports the treatment coefficient with robust (HC1) 95% CI.
"""
formula = f"{outcome_col} ~ {treatment_col} + " + " + ".join(covariates)
model = smf.wls(formula, data=data, weights=data[weight_col]).fit(
cov_type='HC1' # sandwich SEs for IPW
)
ate = model.params[treatment_col]
ci = model.conf_int(alpha=0.05).loc[treatment_col]
p = model.pvalues[treatment_col]
print(f"ATE (WLS): {ate:.4f} [95% CI {ci[0]:.4f}, {ci[1]:.4f}], p={p:.6f}")
print(model.summary().tables[1])
return {'ate': ate, 'ci': (ci[0], ci[1]), 'p': p, 'model': model}
# Example:
# estimate_ate_continuous(df_weighted, 'outcome', covariates, weight_col='ipw')
6.2 Binary Outcome (Weighted Logistic Regression)¶
def estimate_ate_binary(data, outcome_col, covariates, treatment_col='treatment',
weight_col='ipw'):
"""
Estimate treatment OR for a binary outcome via IPW logistic regression.
Reports OR with 95% CI (profile-likelihood based via statsmodels).
Also derives risk difference and risk ratio from marginal predictions.
"""
formula = f"{outcome_col} ~ {treatment_col} + " + " + ".join(covariates)
model = smf.glm(
formula, data=data, family=sm.families.Binomial(),
freq_weights=data[weight_col].values
).fit(cov_type='HC1')
# Odds ratio with 95% CI
log_or = model.params[treatment_col]
log_ci = model.conf_int(alpha=0.05).loc[treatment_col]
or_val = np.exp(log_or)
or_ci = np.exp(log_ci)
p = model.pvalues[treatment_col]
print(f"OR: {or_val:.4f} [95% CI {or_ci[0]:.4f}, {or_ci[1]:.4f}], p={p:.6f}")
# Marginal risk difference and risk ratio via standardization
p1 = model.predict(data.assign(**{treatment_col: 1})).mean()
p0 = model.predict(data.assign(**{treatment_col: 0})).mean()
rd = p1 - p0
rr = p1 / p0 if p0 > 0 else np.inf
print(f"Marginal risk diff: {rd:.4f} | Risk ratio: {rr:.4f}")
print(f"NNT: {1/rd:.1f}" if rd != 0 else "NNT: undefined")
print(model.summary().tables[1])
return {'or': or_val, 'or_ci': (or_ci[0], or_ci[1]), 'rd': rd, 'rr': rr, 'p': p, 'model': model}
# Example:
# estimate_ate_binary(df_weighted, 'mortality', covariates, weight_col='ipw')
7. Survival Analysis¶
7.1 Weighted Kaplan-Meier Curves¶
def weighted_km_curves(data, time_col='time_to_event', event_col='event',
treatment_col='treatment', weight_col='ipw',
labels=None, max_time=None):
"""Weighted KM survival curves with logrank test."""
labels = labels or ['Control', 'Treated']
if max_time is None:
max_time = data[time_col].max()
fig, ax = plt.subplots(figsize=(10, 7))
colors = ['#1f77b4', '#d62728']
for i, grp in enumerate([0, 1]):
s = data[data[treatment_col] == grp]
w = s[weight_col].values if weight_col else None
kmf = KaplanMeierFitter()
kmf.fit(s[time_col], event_observed=s[event_col], weights=w, label=labels[i])
kmf.plot_survival_function(ax=ax, ci_show=True, color=colors[i])
med = kmf.median_survival_time_
print(f"{labels[i]}: median = {med:.1f}d" if not np.isinf(med) else f"{labels[i]}: median not reached")
t, c = data[data[treatment_col]==1], data[data[treatment_col]==0]
lr = logrank_test(t[time_col], c[time_col], event_observed_A=t[event_col],
event_observed_B=c[event_col],
weight_A=t[weight_col] if weight_col else None,
weight_B=c[weight_col] if weight_col else None)
print(f"\nWeighted logrank: p = {lr.p_value:.6f}")
ax.set_xlabel('Days'); ax.set_ylabel('Survival'); ax.set_title('Weighted KM Curves')
ax.set_xlim(0, max_time); ax.legend(); plt.tight_layout(); plt.show()
return lr.p_value
weighted_km_curves(df_weighted, weight_col='ipw')
7.2 Weighted Cox Proportional Hazards¶
def weighted_cox_model(data, time_col='time_to_event', event_col='event',
treatment_col='treatment', weight_col='ipw',
covariates=None, robust_se=True):
"""Weighted Cox PH with robust sandwich SEs."""
if covariates is None:
covariates = []
cox = data[[time_col, event_col, treatment_col, weight_col] + covariates].copy()
cox = cox.rename(columns={time_col: 'duration', event_col: 'event',
weight_col: 'weights', treatment_col: 'treatment'})
cph = CoxPHFitter(penalizer=0.01)
formula = 'treatment' + (' + ' + ' + '.join(covariates) if covariates else '')
cph.fit(cox, duration_col='duration', event_col='event',
weights_col='weights', robust=robust_se, formula=formula)
hr = np.exp(cph.params_['treatment'])
ci = np.exp(cph.confidence_intervals_.loc['treatment'])
p = cph.summary.loc['treatment', 'p']
print(f"Treatment HR: {hr:.4f} [{ci.iloc[0]:.4f}, {ci.iloc[1]:.4f}], p={p:.6f}")
print(cph.summary[['coef', 'se(coef)', 'HR', 'p']])
ph = proportional_hazard_test(cph, cox, time_transform='rank')
print(f"\nSchoenfeld PH test:\n{ph.summary[['test_statistic', 'p']]}")
return cph
cph = weighted_cox_model(df_weighted, weight_col='ipw_stabilized', covariates=['age', 'charlson_score'])
7.3 Restricted Mean Survival Time (RMST)¶
def rmst_difference(data, time_col='time_to_event', event_col='event',
treatment_col='treatment', weight_col='ipw', tau=None):
"""
RMST: area under survival curve up to tau. Valid even if PH assumption fails.
"""
if tau is None:
tau = data[time_col].max()
rmst = {}
fig, ax = plt.subplots(figsize=(10, 7))
for grp, label, color in [(0, 'Control', '#1f77b4'), (1, 'Treated', '#d62728')]:
s = data[data[treatment_col] == grp]
w = s[weight_col].values if weight_col else None
kmf = KaplanMeierFitter()
kmf.fit(s[time_col], event_observed=s[event_col], weights=w, label=label)
surv = kmf.survival_function_at_times(np.arange(0, tau+1)).values
rmst[grp] = np.trapz(surv, dx=1)
kmf.plot_survival_function(ax=ax, ci_show=False, color=color)
tv = kmf.survival_function_.index.values
sv = kmf.survival_function_.iloc[:, 0].values
ax.fill_between(tv, sv, alpha=0.2, color=color)
print(f"{label}: RMST({tau}) = {rmst[grp]:.1f} days")
ax.axvline(tau, color='black', ls='--', alpha=0.5, label=f'tau={tau}')
ax.set_xlabel('Days'); ax.set_title(f'RMST (tau={tau})'); ax.legend()
plt.tight_layout(); plt.show()
print(f"RMST difference: {rmst[1]-rmst[0]:.1f} days")
print(f"RMST ratio: {rmst[1]/rmst[0]:.4f}")
return {'rmst_diff': rmst[1]-rmst[0]}
8. Sensitivity Analysis¶
8.1 E-values¶
def evalue(rr, ci_lower=None, ci_upper=None):
"""
E-value (VanderWeele & Ding 2017): minimum strength of association an unmeasured
confounder needs with BOTH treatment and outcome to explain away the observed effect.
Formula: E = RR + sqrt(RR * (RR - 1)) where RR >= 1.
"""
pt = rr if rr >= 1 else 1/rr
e = pt + np.sqrt(pt * (pt - 1))
print(f"E-value (point): {e:.3f}")
if ci_lower is not None and ci_upper is not None:
ci_b = ci_lower if rr >= 1 else 1/ci_upper
e_ci = ci_b + np.sqrt(ci_b*(ci_b-1)) if ci_b >= 1 else 1.0
print(f"E-value (CI bound): {e_ci:.3f}")
return {'evalue_point': e, 'evalue_ci': e_ci}
return {'evalue_point': e}
# E-value of ~2.2 means unmeasured confounder needs RR >= 2.2 with both treatment and outcome
evalue(rr=0.72, ci_lower=0.55, ci_upper=0.94)
8.2 Tipping Point Analysis¶
def tipping_point(data, outcome_col, treatment_col='treatment', weight_col='ipw',
strength_range=(1.0, 5.0), prevalence_range=(0.05, 0.5), n_grid=20):
"""
Vary strength (RR) and prevalence of a hypothetical unmeasured confounder
to find where the treatment effect crosses null.
"""
t, c = data[data[treatment_col]==1], data[data[treatment_col]==0]
observed_rd = np.average(t[outcome_col], weights=t[weight_col].values) - np.average(c[outcome_col], weights=c[weight_col].values)
strengths = np.linspace(*strength_range, n_grid)
prevalences = np.linspace(*prevalence_range, n_grid)
results = []
for s in strengths:
for p in prevalences:
adj_rd = observed_rd - p * (s - 1)
results.append({'strength': s, 'prevalence': p, 'adjusted_rd': adj_rd, 'null': abs(adj_rd) < 0.001})
tip = pd.DataFrame(results)
pivot = tip.pivot(index='prevalence', columns='strength', values='adjusted_rd')
fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(pivot, cmap='RdBu_r', center=0, ax=ax, cbar_kws={'label': 'Adjusted RD'})
ax.set_xlabel('Confounder-Outcome RR'); ax.set_ylabel('Confounder Prevalence')
ax.set_title('Tipping Point Analysis'); plt.tight_layout(); plt.show()
crossing = tip[tip['null']].sort_values('strength')
if len(crossing) > 0:
print(f"Effect crosses null at RR={crossing.iloc[0]['strength']:.2f}, prevalence={crossing.iloc[0]['prevalence']:.2f}")
return tip
9. External Control Arms¶
Integrating RWD as External Controls for Single-Arm Trials¶
def build_external_control_arm(rwd_df, trial_df, covariates,
treatment_col='treatment',
weight_method='overlap', trim=0.1):
"""
Build an external control arm from real-world data to augment a single-arm trial.
1. Pool trial patients (treated) and RWD patients (potential controls)
2. Estimate PS for being in the trial vs RWD
3. Apply weighting (overlap preferred) and trim poorly overlapping observations
4. Assess balance and return weighted external control arm
"""
trial_df = trial_df.copy(); trial_df[treatment_col] = 1; trial_df['source'] = 'trial'
rwd_df = rwd_df.copy(); rwd_df[treatment_col] = 0; rwd_df['source'] = 'rwd'
pooled = pd.concat([trial_df, rwd_df], ignore_index=True)
pooled = estimate_propensity_scores(pooled, covariates, treatment_col, method='logistic')
pooled = trim_weights(pooled, method='crump')
pooled = compute_ipw(pooled, treatment_col=treatment_col, weight_type=weight_method)
cat_cols = [c for c in covariates if pooled[c].nunique() <= 2]
balance_table(pooled, covariates, treatment_col=treatment_col, weight_col='ipw', categorical_cols=cat_cols)
ext_ctrl = pooled[pooled['source'] == 'rwd'].copy()
trial_arm = pooled[pooled['source'] == 'trial'].copy()
print(f"\nExternal control: {len(ext_ctrl)} | Trial: {len(trial_arm)} | Method: {weight_method}")
return pooled, ext_ctrl, trial_arm
10. Marginal Structural Models (Time-Varying Confounding)¶
When confounders are affected by prior treatment (e.g., CD4 count in HIV), standard regression is biased. MSMs with time-varying IPW solve this.
def estimate_msm_ipw(long_df, id_col, time_col, treatment_col, outcome_col,
covariates_static, covariates_time_varying, max_time=None):
"""
Estimate a marginal structural model using time-varying stabilized IPW.
Requires long-format data (one row per patient-time period) with treatment_lag column.
"""
if max_time is None:
max_time = long_df[time_col].max()
df = long_df.copy().sort_values([id_col, time_col]).reset_index(drop=True)
num_w = pd.Series(1.0, index=df.index)
den_w = pd.Series(1.0, index=df.index)
for t in range(1, max_time + 1):
period = df[df[time_col] == t]
if len(period) == 0:
continue
y = period[treatment_col].values
# Numerator: treatment ~ prior treatment + static covariates
X_num = period[[treatment_col + '_lag'] + covariates_static].fillna(0)
# Denominator: treatment ~ prior treatment + ALL covariates
X_den = period[[treatment_col + '_lag'] + covariates_static + covariates_time_varying].fillna(0)
if 0 < y.sum() < len(y):
m_num = LogisticRegression(max_iter=1000).fit(X_num, y)
m_den = LogisticRegression(max_iter=1000).fit(X_den, y)
ps_num = m_num.predict_proba(X_num)[:, 1]
ps_den = m_den.predict_proba(X_den)[:, 1]
else:
ps_num = ps_den = np.full(len(period), 0.5)
idx = period.index
num_w.loc[idx] = np.where(y == 1, ps_num, 1 - ps_num)
den_w.loc[idx] = np.where(y == 1, ps_den, 1 - ps_den)
df['sw'] = df.groupby(id_col).apply(
lambda g: np.cumprod(g['sw_numerator'].values / g['sw_denominator'].values)
if 'sw_numerator' in g else np.cumprod(num_w.loc[g.index].values / den_w.loc[g.index].values)
).explode().values
df['sw'] = df['sw'].clip(0, 10)
# Fit weighted outcome model on final observation per patient
final = df.groupby(id_col).last().reset_index()
final['cum_treatment'] = df.groupby(id_col)[treatment_col].mean().values
model = smf.wls(f"{outcome_col} ~ cum_treatment + " + " + ".join(covariates_static),
data=final, weights=final['sw']).fit()
ate = model.params['cum_treatment']
ci = model.conf_int().loc['cum_treatment']
print(f"MSM causal effect: {ate:.4f} [{ci[0]:.4f}, {ci[1]:.4f}]")
return model
11. Target Trial Emulation (Cloning, Censoring, Weighting)¶
def target_trial_emulation(df, id_col, treatment_col, outcome_col,
grace_period=30, max_followup=730):
"""
Emulate a target trial with two strategies:
- Strategy A: Initiate treatment within grace period and continue
- Strategy B: Do not initiate treatment within grace period
Uses cloning, censoring, and IPW to estimate per-protocol effects.
"""
clone_treated = df.copy(); clone_treated['strategy'] = 1
clone_control = df.copy(); clone_control['strategy'] = 0
pooled = pd.concat([clone_treated, clone_control], ignore_index=True)
pooled['clone_id'] = pooled.index
# Censoring: deviate from assigned strategy
treated_within_grace = pooled[treatment_col] == 1
pooled['censored'] = False
pooled.loc[(pooled['strategy'] == 1) & (~treated_within_grace), 'censored'] = True
pooled.loc[(pooled['strategy'] == 0) & treated_within_grace, 'censored'] = True
# Follow-up and outcome
pooled['followup'] = max_followup
pooled.loc[pooled['censored'], 'followup'] = grace_period
pooled['event'] = pooled[outcome_col]
pooled.loc[pooled['censored'], 'event'] = 0
# IPW for censoring
censor_covs = [c for c in df.columns if c not in [id_col, treatment_col, outcome_col, 'strategy']]
y_unc = (~pooled['censored']).astype(int)
X_c = pooled[censor_covs].fillna(0)
if 0 < y_unc.sum() < len(y_unc):
cm = LogisticRegression(max_iter=1000).fit(X_c, y_unc)
p_unc = cm.predict_proba(X_c)[:, 1].clip(0.01, 0.99)
pooled['ipw_censor'] = np.where(pooled['censored'], 0, 1/p_unc)
else:
pooled['ipw_censor'] = 1.0
uncensored = pooled[~pooled['censored']].copy()
print(f"Cloned: {len(pooled)} (from {len(df)})")
print(f" Strategy A: {(pooled['strategy']==1).sum()} ({pooled[pooled['strategy']==1]['censored'].sum()} censored)")
print(f" Strategy B: {(pooled['strategy']==0).sum()} ({pooled[pooled['strategy']==0]['censored'].sum()} censored)")
print(f" Uncensored for analysis: {len(uncensored)}")
return pooled
12. Propensity Score Stratification¶
def ps_stratification(df, covariates, treatment_col='treatment',
ps_col='propensity_score', outcome_col=None,
n_strata=5, outcome_type='continuous'):
"""
PS stratification (quintiles): estimate effect within strata and pool.
"""
df = df.copy()
df['ps_stratum'] = pd.qcut(df[ps_col], n_strata, labels=False)
for s in range(n_strata):
sd = df[df['ps_stratum'] == s]
print(f"Stratum {s+1} (n={len(sd)}, PS [{sd[ps_col].min():.3f}, {sd[ps_col].max():.3f}]):")
for cov in covariates[:5]:
smd = compute_smd(sd, cov, treatment_col)
print(f" {cov}: SMD={abs(smd):.4f} {'OK' if abs(smd)<0.1 else 'IMBALANCED'}")
if outcome_col:
effects = []
for s in range(n_strata):
sd = df[df['ps_stratum'] == s]
t, c = sd[sd[treatment_col]==1], sd[sd[treatment_col]==0]
if outcome_type == 'continuous':
eff = t[outcome_col].mean() - c[outcome_col].mean()
else:
eff = t[outcome_col].mean() - c[outcome_col].mean()
w = len(sd) / len(df)
effects.append((s, eff, w))
pooled = sum(e * w for _, e, w in effects)
print(f"\nPooled effect (stratified): {pooled:.4f}")
for s, e, w in effects:
print(f" Stratum {s+1}: effect={e:.4f}, weight={w:.3f}")
return pooled
13. Reporting and Best Practices¶
Baseline Characteristics Table¶
def baseline_table(data, covariates, treatment_col='treatment',
categorical_cols=None, weight_col=None):
"""Produce Table 1: baseline characteristics by treatment group."""
categorical_cols = categorical_cols or []
rows = []
for cov in covariates:
t, c = data[data[treatment_col]==1], data[data[treatment_col]==0]
if weight_col:
wt, wc = t[weight_col].values, c[weight_col].values
if cov in categorical_cols:
mt = f"{np.average(t[cov], weights=wt)*100:.1f}%"
mc = f"{np.average(c[cov], weights=wc)*100:.1f}%"
else:
mt = f"{np.average(t[cov], weights=wt):.2f} +/- {np.sqrt(np.average((t[cov]-np.average(t[cov], weights=wt))**2, weights=wt)):.2f}"
mc = f"{np.average(c[cov], weights=wc):.2f} +/- {np.sqrt(np.average((c[cov]-np.average(c[cov], weights=wc))**2, weights=wc)):.2f}"
else:
if cov in categorical_cols:
mt = f"{t[cov].mean()*100:.1f}%"
mc = f"{c[cov].mean()*100:.1f}%"
else:
mt = f"{t[cov].mean():.2f} +/- {t[cov].std():.2f}"
mc = f"{c[cov].mean():.2f} +/- {c[cov].std():.2f}"
fn = compute_smd_categorical if cov in categorical_cols else compute_smd
smd = abs(fn(data, cov, treatment_col, weight_col))
rows.append({'Variable': cov, 'Treated': mt, 'Control': mc, 'SMD': f"{smd:.3f}"})
return pd.DataFrame(rows)
Expected Outputs¶
- State the target trial specification (PICO, eligibility, treatment strategies, outcomes, follow-up)
- Report sample sizes at each stage (exclusions, matched/weighted N, effective sample size)
- Present baseline characteristics table (Table 1) before and after adjustment
- Report balance diagnostics: SMD for each covariate, love plot, proportion balanced
- Report treatment effect with 95% CI for: risk difference, RR, OR (binary) or mean difference (continuous)
- For time-to-event: HR from Cox model, KM curves, RMST difference, logrank p-value
- Report E-value and tipping point analysis for unmeasured confounding
- Specify PS model (method, covariates, AUC, calibration)
- Report sensitivity analyses (weight truncation, alternative estimators, alternative covariate sets)
- Discuss common support and potential for residual confounding
References¶
- Adapted from RWE analysis in R skill on MCP Market
- Austin PC. Balance diagnostics for comparing the distribution of baseline covariates between treatment groups in propensity-score matched samples. Stat Med. 2009. https://doi.org/10.1002/sim.3697
- VanderWeele TJ, Ding P. Sensitivity analysis in observational research: introducing the E-value. Ann Intern Med. 2017. https://doi.org/10.7326/M16-2607
- Hernan MA, Robins JM. Using big data to emulate a target trial when a randomized trial is not available. Am J Epidemiol. 2016. https://doi.org/10.1093/aje/kwv254
- Robins JM, Hernan MA, Brumback B. Marginal structural models and causal inference in epidemiology. Epidemiology. 2000. https://doi.org/10.1097/00001648-200009000-00011
- Crump RK et al. Dealing with limited overlap in estimation of average treatment effects. Biometrika. 2009. https://doi.org/10.1093/biomet/asn055
- Li F, Morgan KL, Zaslavsky AM. Balancing covariates via propensity score weighting. JASA. 2018. https://doi.org/10.1080/01621459.2016.1260466
- Stuart EA. Matching methods for causal inference: a review and a look forward. Stat Sci. 2010. https://doi.org/10.1214/09-STS313