Part VI: Discovery in Scientific Domains
Chapter 52: Discovery AI for Social and Economic Systems

52.2 Causal Inference in Social Data

"I controlled for every variable I could think of. The treatment effect vanished. Then my colleague pointed out that I had also controlled for the mechanism through which the treatment works."

A Regression That Adjusted Away Its Own Answer
The Big Picture

Causal inference in social systems faces challenges that the idealized settings of Chapter 31 deliberately set aside. People talk to each other, so treating one person spills over onto their neighbors. Policies roll out gradually, so pre-treatment trends must be verified. Randomized experiments on populations raise ethical concerns that laboratory experiments do not. This section introduces the workhorse identification strategies of applied social science (difference-in-differences, synthetic control, regression discontinuity) and then confronts the fundamental problem of network interference, where the Stable Unit Treatment Value Assumption (SUTVA) breaks down because one unit's treatment status affects another unit's outcome.

1. The Challenge of Observational Social Data

What. In a representative scenario, a team of economists set out to measure whether a federal job-training program raised participants' earnings. They discovered that enrollees were already on steeper income trajectories than non-enrollees. Every "effect" they estimated was contaminated by self-selection they could not see. This is the universal trap of observational social data: the researcher does not control treatment assignment. Governments choose which cities receive a program. Individuals self-select into job training. Companies decide whether to adopt a technology. Treated and untreated units differ systematically in ways that also affect the outcome. Separating cause from selection is the central challenge.

Why. Randomized controlled trials, the gold standard for causal inference, are often infeasible in social settings. You cannot randomly assign cities to different minimum wage laws for a decade. You cannot randomly imprison some individuals to study the effect of incarceration. You cannot randomly assign children to different school quality levels. Observational methods are not a fallback; they are the primary tool for answering the most important social science questions. In short: because you cannot randomize the questions that matter most, you must find clever substitutes for randomization hidden inside the data you already have.

Because randomization is rarely possible in social settings, researchers rely on identification strategies that exploit natural variation to construct credible counterfactuals.

How. Each identification strategy exploits a specific feature of the data-generating process to construct a credible counterfactual. Difference-in-differences exploits parallel trends. Synthetic control constructs a weighted counterfactual from donor units. Regression discontinuity exploits an arbitrary threshold. The credibility of each strategy depends entirely on the plausibility of its identifying assumption. Figure 52.6 below maps how each strategy flows from assumption to estimate, highlighting the validation step that determines whether the result is credible.

Strategy Identifying Assumption Validation Estimate Diff-in-Diff (panel data) Parallel trends: same trajectory w/o treatment Pre-trend test (p > 0.05) ATT Synthetic Control (single treated unit) Weighted donors match pre-treatment trajectory Pre-fit RMSE (close to zero) Post-treat gap Regression Discont. (threshold rule) No manipulation of running variable at cutoff McCrary test + covariate smoothness LATE at cutoff Assumption Validation Estimate
Figure 52.6: Three causal identification strategies for observational social data. Each strategy depends on a specific identifying assumption (yellow), which must be validated (green) before the resulting estimate (purple) is credible. Arrows show the logical flow from data structure through assumption testing to causal effect.

When. Choose the strategy based on the data structure. Difference-in-differences requires panel data (where the same units are observed repeatedly over time). Synthetic control requires a single treated unit with many potential donors. Regression discontinuity requires a running variable with a cutoff that determines treatment. All three require careful validation of their identifying assumptions before estimation.

2. Difference-in-Differences

Scenarios like the following are not uncommon in applied program evaluation: a widely cited evaluation of a U.S. workforce program was retracted after independent replication revealed that pre-existing differences between treated and control counties, not the program itself, explained the entire reported employment gain. The retraction cost the agency two years of misallocated funding and eroded public trust in program evaluation. Difference-in-differences exists precisely to guard against this failure mode.

Difference-in-differences (DiD) compares the change in outcomes over time between treated and untreated groups. The key identifying assumption is parallel trends: in the absence of treatment, the treated group would have followed the same trajectory as the control group.

DiD is the most widely used quasi-experimental method in economics. Its data requirements are modest: repeated observations on treated and untreated groups, before and after a policy change. The estimator controls for time-invariant group differences (geography, culture) and group-invariant time shocks (macroeconomic trends), isolating the causal effect without randomization. It works by double subtraction: subtract pre from post within each group to remove permanent differences, then subtract the control group's change from the treated group's change to remove common trends. Prefer synthetic control when only one unit is treated, or regression discontinuity when a threshold rule determines treatment.

Formally, for unit \(i\) in group \(g \in \{0, 1\}\) at time \(t \in \{0, 1\}\) (pre and post treatment):

$$Y_{it} = \alpha + \beta \cdot \text{Post}_t + \gamma \cdot \text{Treated}_g + \delta \cdot (\text{Post}_t \times \text{Treated}_g) + \epsilon_{it}$$

The coefficient \(\delta\) is the DiD estimator: the average treatment effect on the treated (ATT). It equals the difference in the before-after change between the treated and control groups:

$$\hat{\delta}_{\text{DiD}} = (\bar{Y}_{1,\text{post}} - \bar{Y}_{1,\text{pre}}) - (\bar{Y}_{0,\text{post}} - \bar{Y}_{0,\text{pre}})$$
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
from dataclasses import dataclass


@dataclass
class DiDResult:
    """Results from a difference-in-differences analysis."""
    att: float
    se: float
    ci_lower: float
    ci_upper: float
    p_value: float
    pre_trend_test_p: float
    n_treated: int
    n_control: int


def difference_in_differences(
    panel: pd.DataFrame,
    outcome: str,
    treatment_col: str,
    time_col: str,
    unit_col: str,
    treatment_time: int,
    covariates: list = None,
) -> DiDResult:
    """
    Estimate the ATT using difference-in-differences with
    optional covariates and a pre-trend test.

    Parameters
    ----------
    panel : DataFrame in long format (one row per unit-time)
    outcome : name of the outcome variable
    treatment_col : binary column indicating treated group
    time_col : time period column
    unit_col : unit identifier column
    treatment_time : the period when treatment begins
    covariates : optional list of control variable names
    """
    df = panel.copy()
    df["post"] = (df[time_col] >= treatment_time).astype(int)
    df["treated_post"] = df[treatment_col] * df["post"]

    # Main DiD regression with unit and time fixed effects
    formula = f"{outcome} ~ treated_post + C({unit_col}) + C({time_col})"
    if covariates:
        formula += " + " + " + ".join(covariates)

    model = smf.ols(formula, data=df).fit(
        cov_type="cluster", cov_kwds={"groups": df[unit_col]}
    )

    att = model.params["treated_post"]
    se = model.bse["treated_post"]
    p_val = model.pvalues["treated_post"]
    ci = model.conf_int().loc["treated_post"]

    # Pre-trend test: interaction of treated with each pre-period
    pre_data = df[df[time_col] < treatment_time].copy()
    pre_data["time_trend"] = pre_data[time_col] - treatment_time
    pre_data["treated_trend"] = (
        pre_data[treatment_col] * pre_data["time_trend"]
    )

    pre_formula = (
        f"{outcome} ~ treated_trend + C({unit_col}) + C({time_col})"
    )
    pre_model = smf.ols(pre_formula, data=pre_data).fit(
        cov_type="cluster", cov_kwds={"groups": pre_data[unit_col]}
    )
    pre_trend_p = pre_model.pvalues.get("treated_trend", 1.0)

    return DiDResult(
        att=att,
        se=se,
        ci_lower=ci[0],
        ci_upper=ci[1],
        p_value=p_val,
        pre_trend_test_p=pre_trend_p,
        n_treated=df[df[treatment_col] == 1][unit_col].nunique(),
        n_control=df[df[treatment_col] == 0][unit_col].nunique(),
    )


# Synthetic panel data: effect of a job training program
np.random.seed(42)
n_units = 200
n_periods = 10
treatment_period = 6

units = np.arange(n_units)
treated = np.random.binomial(1, 0.4, n_units)

rows = []
for i in units:
    unit_fe = np.random.normal(0, 2)  # Unit fixed effect
    for t in range(n_periods):
        time_fe = 0.5 * t  # Common time trend
        treatment_effect = (
            3.0 * treated[i] * (t >= treatment_period)  # True ATT = 3.0
        )
        noise = np.random.normal(0, 1)
        y = 10 + unit_fe + time_fe + treatment_effect + noise
        rows.append({
            "unit": i, "time": t, "treated": treated[i], "outcome": y
        })

panel_data = pd.DataFrame(rows)

result = difference_in_differences(
    panel_data, "outcome", "treated", "time", "unit",
    treatment_time=treatment_period,
)

print(f"ATT estimate: {result.att:.3f} (true: 3.000)")
print(f"Standard error: {result.se:.3f}")
print(f"95% CI: [{result.ci_lower:.3f}, {result.ci_upper:.3f}]")
print(f"Pre-trend test p-value: {result.pre_trend_test_p:.3f}")
print(f"  (p > 0.05 supports parallel trends assumption)")
Figure 52.7: Difference-in-differences estimation with clustered standard errors and a pre-trend validation test. The pre-trend test checks whether treated and control groups were diverging before treatment; a non-significant result (p > 0.05) supports the parallel trends assumption.
Key Insight: The Pre-Trend Test Is Necessary but Not Sufficient

Passing the pre-trend test means that treated and control groups moved in parallel before treatment. This is necessary for DiD validity but not sufficient: the groups could have been on parallel paths that would have diverged at exactly the treatment time for reasons unrelated to treatment. The pre-trend test cannot rule out this scenario. Always supplement statistical tests with domain-specific arguments about why the parallel trends assumption is credible. This connects to the refutation testing philosophy from Chapter 31: stress-test your assumptions, do not just check a p-value.

3. Synthetic Control Method

When only a single unit receives treatment (a specific state passes a law, a single country implements a policy), DiD requires choosing comparison units subjectively. The synthetic control method (Abadie and Gardeazabal, 2003) constructs an optimal comparison unit as a weighted combination of untreated "donor" units (the pool of untreated units whose weighted average forms the synthetic counterfactual), chosen to match the treated unit's pre-treatment trajectory.

Formally, we seek weights \(w_j \geq 0\) with \(\sum_j w_j = 1\) that minimize the distance between the treated unit's pre-treatment outcomes and the weighted average of donor outcomes:

$$\min_{\mathbf{w}} \| \mathbf{X}_1 - \mathbf{X}_0 \mathbf{w} \|_V = \sqrt{(\mathbf{X}_1 - \mathbf{X}_0 \mathbf{w})' V (\mathbf{X}_1 - \mathbf{X}_0 \mathbf{w})}$$

where \(\mathbf{X}_1\) is the vector of pre-treatment characteristics for the treated unit, \(\mathbf{X}_0\) is the matrix of characteristics for donor units, and \(V\) is a positive definite weighting matrix (a symmetric matrix whose quadratic form is always positive, ensuring the distance measure is well-behaved).

from scipy.optimize import minimize


def synthetic_control(
    panel: pd.DataFrame,
    treated_unit: int,
    outcome: str,
    time_col: str,
    unit_col: str,
    treatment_time: int,
    predictors: list = None,
) -> dict:
    """
    Construct a synthetic control unit and estimate the treatment effect.

    Parameters
    ----------
    panel : DataFrame in long format
    treated_unit : identifier of the treated unit
    outcome : outcome variable name
    treatment_time : period when treatment begins

    Returns
    -------
    dict with weights, synthetic outcomes, treatment effects
    """
    # Separate treated and donor units
    treated_data = panel[panel[unit_col] == treated_unit]
    donor_data = panel[panel[unit_col] != treated_unit]
    donor_units = donor_data[unit_col].unique()

    # Pre-treatment outcomes matrix
    pre_treated = (
        treated_data[treated_data[time_col] < treatment_time]
        .set_index(time_col)[outcome]
        .values
    )

    pre_donors = np.column_stack([
        donor_data[
            (donor_data[unit_col] == u)
            & (donor_data[time_col] < treatment_time)
        ].set_index(time_col)[outcome].values
        for u in donor_units
    ])

    n_donors = len(donor_units)

    # Optimize weights to minimize pre-treatment RMSE
    def objective(w):
        synthetic = pre_donors @ w
        return np.sum((pre_treated - synthetic) ** 2)

    # Constraints: weights sum to 1, all non-negative
    constraints = {"type": "eq", "fun": lambda w: np.sum(w) - 1.0}
    bounds = [(0, 1)] * n_donors
    w0 = np.ones(n_donors) / n_donors

    result = minimize(
        objective, w0, method="SLSQP",
        bounds=bounds, constraints=constraints,
    )
    optimal_weights = result.x

    # Construct synthetic control for all periods
    all_times = sorted(panel[time_col].unique())
    synthetic_outcomes = {}
    treated_outcomes = {}

    for t in all_times:
        treated_outcomes[t] = treated_data[
            treated_data[time_col] == t
        ][outcome].values[0]

        donor_vals = np.array([
            donor_data[
                (donor_data[unit_col] == u)
                & (donor_data[time_col] == t)
            ][outcome].values[0]
            for u in donor_units
        ])
        synthetic_outcomes[t] = donor_vals @ optimal_weights

    # Treatment effects (post-treatment gaps)
    effects = {
        t: treated_outcomes[t] - synthetic_outcomes[t]
        for t in all_times if t >= treatment_time
    }
    pre_fit_rmse = np.sqrt(np.mean([
        (treated_outcomes[t] - synthetic_outcomes[t]) ** 2
        for t in all_times if t < treatment_time
    ]))

    # Identify top donor weights
    top_donors = sorted(
        zip(donor_units, optimal_weights),
        key=lambda x: x[1], reverse=True
    )[:5]

    return {
        "weights": dict(zip(donor_units, optimal_weights)),
        "top_donors": top_donors,
        "treated_outcomes": treated_outcomes,
        "synthetic_outcomes": synthetic_outcomes,
        "effects": effects,
        "avg_effect": np.mean(list(effects.values())),
        "pre_fit_rmse": pre_fit_rmse,
    }


# Synthetic data: one treated region among 20
np.random.seed(42)
n_regions = 20
n_years = 15
treat_year = 10

rows = []
for region in range(n_regions):
    base = np.random.normal(50, 5)
    trend = np.random.normal(1.5, 0.3)
    for year in range(n_years):
        y = base + trend * year + np.random.normal(0, 1)
        if region == 0 and year >= treat_year:
            y += 5.0  # True effect on treated unit
        rows.append({
            "region": region, "year": year, "gdp_growth": y
        })

region_panel = pd.DataFrame(rows)

sc_result = synthetic_control(
    region_panel, treated_unit=0, outcome="gdp_growth",
    time_col="year", unit_col="region", treatment_time=treat_year,
)

print(f"Pre-treatment fit RMSE: {sc_result['pre_fit_rmse']:.3f}")
print(f"Average treatment effect: {sc_result['avg_effect']:.3f} (true: 5.0)")
print(f"Top donor weights: {sc_result['top_donors'][:3]}")
Figure 52.8: Synthetic control method constructing a weighted counterfactual from 19 donor regions. The algorithm minimizes pre-treatment RMSE between the treated region and its synthetic twin, then reads off the post-treatment gap as the causal effect.

4. SUTVA Violations and Network Interference

Both DiD and synthetic control assume that treating one unit leaves every other unit's outcome unchanged, but in social systems that assumption rarely holds. The Stable Unit Treatment Value Assumption (SUTVA) requires that each unit's outcome depend only on its own treatment status. In many social settings, SUTVA is violated to some degree. Vaccinating your neighbor protects you (herd immunity). Training a worker benefits their coworkers through knowledge spillovers. Regulating one bank changes the risk exposure of every bank it trades with.

Mental Model

SUTVA violations as fertilizer dissolving into a shared garden sprinkler system and feeding every plot downstream

Think of SUTVA violations like a shared sprinkler system in a garden. If you fertilize only your own plot, the standard assumption says your neighbor's tomatoes are unaffected. But the fertilizer dissolves into the shared irrigation water and feeds every plot downstream. Measuring the "effect of fertilizer on your plot" now conflates two things: the direct boost from the fertilizer you applied, and the indirect boost every plot gets through the shared water. The exposure mapping framework below is like installing separate water meters on each plot so you can measure how much fertilizer each one actually received through the pipes, regardless of where it was originally applied.

When SUTVA fails, the standard potential outcomes framework collapses. Instead of two potential outcomes per unit (\(Y_i(0)\) and \(Y_i(1)\)), we have \(2^n\) potential outcomes for each unit, one for every possible treatment assignment of all \(n\) units in the network; in a study of just 30 people, that is over one billion counterfactual states per person. Estimation becomes intractable without structure.

Key Insight: Interference Makes Effects Contagious

Under interference, the treatment effect on unit \(i\) depends not just on whether \(i\) is treated, but on how many of \(i\)'s neighbors are treated. This means a single treatment effect number (the average treatment effect, or ATE) is no longer well-defined. Instead, we need to estimate a family of effects: the direct effect of treating unit \(i\), the spillover effect of treating \(i\)'s neighbors, and the interaction between these two. The exposure mapping framework below provides a tractable way to decompose these effects. This is the network analog of the heterogeneous treatment effects from Chapter 31, with the network itself as the source of heterogeneity.

4.1 Exposure Mapping

Exposure mapping solves the intractability problem above by replacing the full \(2^n\) treatment vector with a low-dimensional summary of each unit's neighborhood. The exposure mapping approach (Aronow and Samii, 2017) reduces the \(2^n\) potential outcomes to a manageable set by defining each unit's "exposure" as a low-dimensional summary of the network treatment configuration. The most common summary is the fraction of neighbors treated:

$$E_i(\mathbf{Z}) = \frac{\sum_{j \in \mathcal{N}(i)} Z_j}{|\mathcal{N}(i)|}$$

where \(\mathbf{Z}\) is the treatment vector and \(\mathcal{N}(i)\) is the set of \(i\)'s neighbors. Under the partial interference assumption (the assumption that interference occurs only within predefined clusters or network neighborhoods, not across the entire population) (Hudgens and Halloran, 2008), we can then define: Figure 52.2.1 illustrates exposure mapping under network interference.

Exposure mapping under network interference
Figure 52.2.1: Exposure mapping decomposes treatment effects in a social network into direct effects on treated nodes and spillover effects transmitted through network edges to untreated neighbors.

Under the partial interference assumption, we can define:

Checkpoint

So far: when units interact through a network, SUTVA fails and the number of potential outcomes explodes; exposure mapping tames this by summarizing each unit's network context as a single number (fraction of treated neighbors), which lets us decompose treatment effects into direct, spillover, and total components.

import networkx as nx
import statsmodels.formula.api as smf
from sklearn.linear_model import LinearRegression


@dataclass
class InterferenceEffects:
    """Estimated direct, spillover, and total treatment effects."""
    direct_effect: float
    spillover_effect: float
    total_effect: float
    direct_se: float
    spillover_se: float
    n_units: int


def estimate_with_interference(
    G: nx.Graph,
    treatment: dict,
    outcome: dict,
    covariates: pd.DataFrame = None,
) -> InterferenceEffects:
    """
    Estimate direct and spillover effects using exposure mapping.

    Models outcome as:
        Y_i = alpha + beta_1 * Z_i + beta_2 * E_i + beta_3 * Z_i * E_i
              + gamma * X_i + epsilon_i

    where Z_i is own treatment, E_i is neighborhood exposure.

    Parameters
    ----------
    G : social/economic network (undirected)
    treatment : dict mapping node -> 0/1 treatment indicator
    outcome : dict mapping node -> outcome value
    covariates : optional DataFrame indexed by node
    """
    nodes = list(G.nodes())
    n = len(nodes)

    # Compute exposure for each unit
    exposure = {}
    for node in nodes:
        neighbors = list(G.neighbors(node))
        if neighbors:
            treated_neighbors = sum(
                treatment.get(nb, 0) for nb in neighbors
            )
            exposure[node] = treated_neighbors / len(neighbors)
        else:
            exposure[node] = 0.0

    # Build regression matrix
    Z = np.array([treatment.get(nd, 0) for nd in nodes])
    E = np.array([exposure[nd] for nd in nodes])
    Y = np.array([outcome[nd] for nd in nodes])
    ZE = Z * E  # Interaction term

    X_matrix = np.column_stack([Z, E, ZE, np.ones(n)])
    col_names = ["treatment", "exposure", "interaction", "intercept"]

    if covariates is not None:
        cov_matrix = covariates.loc[nodes].values
        X_matrix = np.column_stack([X_matrix, cov_matrix])

    # OLS with HC1 (Huber-White heteroskedasticity-robust) standard errors
    model = smf.ols(
        "Y ~ treatment + exposure + interaction",
        data=pd.DataFrame({
            "Y": Y, "treatment": Z,
            "exposure": E, "interaction": ZE,
        }),
    ).fit(cov_type="HC1")

    direct = model.params["treatment"]
    spillover = model.params["exposure"]
    direct_se = model.bse["treatment"]
    spillover_se = model.bse["exposure"]

    # Total effect: direct + spillover at mean exposure among treated
    mean_exposure_treated = np.mean(E[Z == 1])
    interaction = model.params.get("interaction", 0)
    total = direct + spillover * mean_exposure_treated

    return InterferenceEffects(
        direct_effect=direct,
        spillover_effect=spillover,
        total_effect=total,
        direct_se=direct_se,
        spillover_se=spillover_se,
        n_units=n,
    )


# Simulate an experiment with interference
np.random.seed(42)
# Watts-Strogatz graph: a "small-world" network where each node
# connects to k nearest neighbors, then each edge is randomly
# rewired with probability p, producing short path lengths and
# high clustering similar to real social networks.
G_social = nx.watts_strogatz_graph(500, 6, 0.1, seed=42)

# Random treatment assignment
treatment = {n: int(np.random.random() < 0.3) for n in G_social.nodes()}

# Generate outcomes with true direct and spillover effects
true_direct = 2.0
true_spillover = 1.5
outcome = {}
for node in G_social.nodes():
    neighbors = list(G_social.neighbors(node))
    neighbor_exposure = (
        sum(treatment.get(nb, 0) for nb in neighbors) / len(neighbors)
        if neighbors else 0
    )
    y = (
        5.0
        + true_direct * treatment[node]
        + true_spillover * neighbor_exposure
        + np.random.normal(0, 1)
    )
    outcome[node] = y

effects = estimate_with_interference(G_social, treatment, outcome)
print(f"Direct effect: {effects.direct_effect:.3f} "
      f"(true: {true_direct}, SE: {effects.direct_se:.3f})")
print(f"Spillover effect: {effects.spillover_effect:.3f} "
      f"(true: {true_spillover}, SE: {effects.spillover_se:.3f})")
print(f"Total effect: {effects.total_effect:.3f}")
Figure 52.9: Exposure mapping for interference-aware causal inference on a Watts-Strogatz network. The model separates the direct effect (own treatment) from the spillover effect (fraction of treated neighbors), recovering both from a single cross-sectional regression with HC1-robust standard errors.
Warning: Exposure Mapping Requires a Known Network

Exposure mapping assumes you observe the complete network of interference. If the network is partially observed (missing edges, unobserved nodes) or if interference operates through channels not captured by the network (shared media exposure, market-level shocks), the exposure variable is measured with error. This typically attenuates spillover estimates toward zero. Always conduct sensitivity analysis on the network specification: does the result change when you use 2-hop neighbors instead of 1-hop? When you use a different network (trade vs. geographic proximity)?

5. Regression Discontinuity Design

While interference complicates treatment and control comparisons across connected units, a different class of natural experiments sidesteps the problem entirely by focusing on a sharp boundary that separates otherwise identical individuals. Regression discontinuity (RD) exploits settings where treatment is determined by whether a continuous "running variable" (the continuous score or measurement that determines treatment assignment at a fixed cutoff) exceeds a threshold. Students with test scores above 80 receive a scholarship. Firms with fewer than 50 employees are exempt from a regulation. Cities with population above 100,000 receive federal funding.

The key insight: units just above and just below the threshold are nearly identical in all respects except treatment status. The discontinuity in the outcome at the threshold therefore estimates the causal effect of treatment.

Common Misconception

A frequent mistake is treating the RD estimate as a global average treatment effect that applies to everyone in the sample. It does not. The RD estimate is a local average treatment effect (LATE, where "local" means the estimate applies only to units at the cutoff, not to the full population) valid only at the cutoff: it tells you the causal effect for units whose running variable places them right at the threshold, not for units far from it. A scholarship effect estimated at the score cutoff of 80 may differ substantially from the effect on students scoring 95, because those students have different baseline characteristics and opportunity costs. Generalizing an RD estimate beyond a narrow window around the cutoff requires additional assumptions that the design itself does not provide.

The RD estimator formalizes this by measuring the gap between the outcome's left and right limits at the cutoff \(c\); if the outcome function is continuous everywhere except at \(c\), any jump at that point is attributable to the treatment:

$$\tau_{\text{RD}} = \lim_{x \downarrow c} E[Y | X = x] - \lim_{x \uparrow c} E[Y | X = x]$$
from sklearn.linear_model import LinearRegression


def regression_discontinuity(
    data: pd.DataFrame,
    running_var: str,
    outcome: str,
    cutoff: float,
    bandwidth: float = None,
    poly_order: int = 1,
) -> dict:
    """
    Sharp regression discontinuity design with local linear regression.

    Parameters
    ----------
    data : DataFrame with running variable and outcome
    running_var : name of the running variable
    outcome : name of the outcome variable
    cutoff : treatment threshold
    bandwidth : window around cutoff (uses IK optimal if None)
    poly_order : polynomial order for local regression
    """
    df = data.copy()
    df["centered"] = df[running_var] - cutoff
    df["treated"] = (df[running_var] >= cutoff).astype(int)

    # Simple bandwidth selection: Imbens-Kalyanaraman style
    # (As of 2024, the rdrobust package by Calonico, Cattaneo, and
    # Titiunik provides the preferred MSE-optimal and CER-optimal
    # bandwidth selectors; the rule of thumb below is for illustration.)
    if bandwidth is None:
        std_x = df["centered"].std()
        n = len(df)
        bandwidth = 1.84 * std_x * n ** (-1 / 5)  # Rule of thumb

    # Restrict to bandwidth window
    in_window = df[df["centered"].abs() <= bandwidth].copy()
    n_left = (in_window["treated"] == 0).sum()
    n_right = (in_window["treated"] == 1).sum()

    # Local polynomial regression on each side
    # Y = alpha + beta * (X - c) + tau * D + gamma * D * (X - c) + eps
    features = ["treated", "centered"]
    in_window["treated_centered"] = (
        in_window["treated"] * in_window["centered"]
    )
    features.append("treated_centered")

    if poly_order >= 2:
        in_window["centered_sq"] = in_window["centered"] ** 2
        in_window["treated_centered_sq"] = (
            in_window["treated"] * in_window["centered_sq"]
        )
        features.extend(["centered_sq", "treated_centered_sq"])

    formula = f"{outcome} ~ " + " + ".join(features)
    model = smf.ols(formula, data=in_window).fit(cov_type="HC1")

    tau = model.params["treated"]
    se = model.bse["treated"]
    p_val = model.pvalues["treated"]

    return {
        "tau": tau,
        "se": se,
        "p_value": p_val,
        "ci_lower": tau - 1.96 * se,
        "ci_upper": tau + 1.96 * se,
        "bandwidth": bandwidth,
        "n_left": n_left,
        "n_right": n_right,
        "n_total": len(in_window),
    }


# Synthetic RD data: scholarship effect on college enrollment
np.random.seed(42)
n_students = 2000
test_scores = np.random.normal(80, 10, n_students)
scholarship = (test_scores >= 80).astype(int)
enrollment = (
    0.3 + 0.005 * test_scores
    + 0.15 * scholarship  # True RD effect = 0.15
    + np.random.normal(0, 0.1, n_students)
)

rd_data = pd.DataFrame({
    "test_score": test_scores,
    "enrollment": enrollment,
})

rd_result = regression_discontinuity(
    rd_data, "test_score", "enrollment", cutoff=80.0
)

print(f"RD estimate: {rd_result['tau']:.4f} (true: 0.1500)")
print(f"SE: {rd_result['se']:.4f}")
print(f"95% CI: [{rd_result['ci_lower']:.4f}, {rd_result['ci_upper']:.4f}]")
print(f"Bandwidth: {rd_result['bandwidth']:.2f}")
print(f"Observations in window: {rd_result['n_total']}")
Figure 52.10: Sharp regression discontinuity design estimating the scholarship effect on college enrollment at a test-score cutoff of 80, using local linear regression within an Imbens-Kalyanaraman bandwidth.
Practical Example: Evaluating a Tax Incentive

A government economist evaluates a tax credit for small businesses with fewer than 250 employees. Using administrative data on 80,000 firms, she applies an RD design at the 250-employee threshold. The RD estimate shows the tax credit increases R&D spending by 12% (95% CI: 7% to 17%). She validates the design by testing for manipulation of the running variable (the McCrary density test, which checks whether units bunch suspiciously on one side of the cutoff, indicating that they may have manipulated the running variable to obtain or avoid treatment) and checking that pre-treatment covariates (industry, age, revenue) are smooth through the threshold. A placebo test at fake cutoffs (200, 300 employees) produces null effects, confirming that the 250-employee discontinuity is specific to the policy.

6. Sensitivity Analysis for Social Data

Each method above produces a point estimate and a confidence interval, but neither quantity reveals how fragile the conclusion is if its core assumption fails. Every observational causal estimate rests on untestable assumptions. Sensitivity analysis quantifies how robust your conclusions are to violations of these assumptions. The E-value (VanderWeele and Ding, 2017) answers: how strong would an unmeasured confounder need to be to explain away the observed effect?

Note that the E-value is defined for risk ratios (or rate ratios), not for the raw ATT or regression coefficients produced by DiD, synthetic control, or RD. To apply it to those estimates, you must first convert the effect to a risk ratio scale, for example by exponentiating a log-odds coefficient or computing the ratio of outcome probabilities between treated and control groups. The function below takes the risk ratio directly.

def compute_e_value(point_estimate: float, ci_lower: float) -> dict:
    """
    Compute the E-value for a risk ratio or rate ratio.

    The E-value is the minimum strength of association that
    an unmeasured confounder would need to have with both
    the treatment and the outcome to fully explain away
    the observed effect.

    Parameters
    ----------
    point_estimate : observed risk ratio (must be > 1)
    ci_lower : lower bound of 95% CI for the risk ratio
    """
    def e_value_formula(rr):
        if rr <= 1:
            return 1.0
        return rr + np.sqrt(rr * (rr - 1))

    e_point = e_value_formula(point_estimate)
    e_ci = e_value_formula(max(ci_lower, 1.0))

    return {
        "e_value_point": e_point,
        "e_value_ci": e_ci,
        "interpretation": (
            f"An unmeasured confounder would need a risk ratio "
            f"of at least {e_point:.2f} with both treatment and "
            f"outcome to explain away the point estimate, or "
            f"{e_ci:.2f} to move the CI to include the null."
        ),
    }


# Example: job training program with RR = 1.8, 95% CI [1.3, 2.5]
e_result = compute_e_value(1.8, 1.3)
print(f"E-value (point): {e_result['e_value_point']:.2f}")
print(f"E-value (CI): {e_result['e_value_ci']:.2f}")
print(f"\n{e_result['interpretation']}")
Figure 52.11: E-value sensitivity analysis for a job-training risk ratio of 1.8. The E-value quantifies how strong an unmeasured confounder must be (in terms of its association with both treatment and outcome) to nullify the observed effect.

Research Frontier

Classical two-period, two-group DiD breaks down when treatment rolls out to different units at different times ("staggered adoption"). Callaway and Sant'Anna (2021) showed that the standard two-way fixed effects estimator can produce severely biased estimates under treatment effect heterogeneity, because it implicitly uses already-treated units as controls for newly-treated units. Their did R package (with a Python port via csdid) estimates group-time average treatment effects that avoid this contamination. Building on this, Roth et al. (2023, "What's Trending in Difference-in-Differences?", Journal of Econometrics) provide a comprehensive guide to the new DiD toolkit, covering honest inference under pre-trend violations, sensitivity analysis for parallel trends, and diagnostics for staggered designs. These methods have been widely adopted in recent applied work: any new DiD analysis with staggered treatment timing should use the Callaway-Sant'Anna or Sun-Abraham estimator rather than the textbook two-way fixed effects specification shown above.

Library Shortcut: DoWhy for Complete Causal Pipelines

The individual estimators above (DiD, synthetic control, RD) each required 50-80 lines of implementation. DoWhy wraps all of these (and more) in a unified four-step workflow:

import dowhy

# 1. Model: specify the causal graph
model = dowhy.CausalModel(
    data=df,
    treatment="job_training",
    outcome="earnings",
    common_causes=["education", "age", "prior_income"],
)

# 2. Identify: find the estimand
estimand = model.identify_effect()

# 3. Estimate: compute the effect
estimate = model.estimate_effect(
    estimand, method_name="backdoor.econml.dml.DML",
    method_params={"init_params": {"model_y": LGBMRegressor(),
                                    "model_t": LGBMClassifier()}}
)

# 4. Refute: stress-test the result
refutation = model.refute_estimate(
    estimand, estimate, method_name="random_common_cause"
)
Figure 52.12: DoWhy four-step causal pipeline (model, identify, estimate, refute) replacing 200+ lines of custom DiD, synthetic control, and RD code with a unified graph-based interface and built-in refutation tests.

DoWhy handles graph-based identification, multiple estimation strategies (inverse probability weighting, double machine learning, instrumental variables, DiD), and built-in refutation tests (placebo treatment, random common cause, data subset). The full pipeline from Chapter 31 applies directly here, reducing 200+ lines of custom code to roughly 15 lines. The custom implementations above exist to show the mechanics that DoWhy automates. Note that DoWhy 0.8 and later (circa 2022) introduced a redesigned API; the snippet above uses the legacy interface, which remains functional but the current recommended entry point is the dowhy.CausalModel class with graph specification via GML strings (Graph Modelling Language, a text format for describing graph structure) or NetworkX objects.

Try It: DiD with Real-World Policy Data

Build a difference-in-differences analysis of a simulated state-level policy change using only Python, pandas, and statsmodels. This project walks through the full workflow from data construction to robustness checks.

Step 1. Create a synthetic panel of 50 U.S. states observed quarterly over 5 years (1,000 rows). Assign 10 states as "treated" starting in quarter 12. Generate outcomes with a known true effect of 4.0 plus state fixed effects and a common linear time trend.

Step 2. Estimate the basic DiD using statsmodels.formula.api.ols with the formula outcome ~ treated_post + C(state) + C(quarter). Cluster standard errors at the state level. Verify the ATT estimate is close to 4.0.

Step 3. Plot pre-treatment trends for treated vs. control groups using matplotlib. Compute the average outcome per group per quarter and overlay the two lines. Visually confirm they are parallel before the treatment date.

Step 4. Run a placebo test: re-estimate the model pretending treatment began 4 quarters earlier (quarter 8 instead of 12). The placebo ATT should be near zero and statistically insignificant.

Step 5. Add a confounder: introduce a variable correlated with both treatment assignment and the outcome (for example, baseline GDP). Re-estimate with and without controlling for it, and compare how the ATT changes. This demonstrates why covariate selection matters even in a DiD framework.

Exercise 52.2.1

A researcher estimates a difference-in-differences model and reports an ATT of 5.2 with a pre-trend test p-value of 0.03. She also estimates a placebo DiD using a fake treatment date four periods earlier and finds a placebo ATT of 4.8. What two concerns should you raise about the credibility of her causal estimate, and which specific assumption of DiD does each concern threaten?

Hint

The pre-trend test checks whether treated and control groups were already diverging before treatment. A p-value below 0.05 suggests they were. The placebo test checks whether a "treatment effect" appears at a time when no treatment occurred. Both findings point to the same threatened assumption: think about what must hold for the double subtraction to isolate a causal effect.

Step-Through: Difference-in-Differences by Hand

Trace through the DiD estimator with four numbers. Suppose mean outcomes are: treated group pre = 40, treated group post = 52, control group pre = 38, control group post = 45. First, compute within-group changes: treated change = 52 − 40 = 12; control change = 45 − 38 = 7. Second, compute the double difference: DiD = 12 − 7 = 5. The ATT estimate is 5.0. The control group's change of 7 captures the common time trend (what would have happened without treatment). The treated group changed by 12, which is 5 units more than the counterfactual trend, so 5 is attributed to the treatment. If parallel trends fail (say the treated group was already growing 2 units faster per period), the true effect is only 5 − 2 = 3, and the naive DiD overestimates by 2.

Real-World Application: Uber's Marketplace Experimentation Platform

Uber's internal experimentation platform uses synthetic control and interference-aware methods to evaluate pricing and matching algorithm changes across cities. Because treating one city's pricing affects driver supply in neighboring regions (a SUTVA violation), Uber constructs synthetic control cities from geographically distant donor pools and estimates spillover effects through an exposure mapping framework similar to the one described in Section 4.1. According to published accounts from Uber's engineering blog, this approach has improved the reliability of their causal estimates in the presence of cross-market interference.

The Law That Proved Itself Wrong

In 1994, economists David Card and Alan Krueger used a natural experiment (a difference-in-differences design comparing fast-food restaurants in New Jersey and Pennsylvania after New Jersey raised its minimum wage) to show that the wage increase did not reduce employment, contradicting decades of textbook predictions. The study was so controversial that it triggered a methodological revolution: the "credibility revolution" in economics, which elevated quasi-experimental designs like DiD, RDD, and synthetic control from niche techniques to the dominant paradigm. Card shared the 2021 Nobel Prize in Economics partly for this work, making it one of the rare cases where a single natural experiment reshaped an entire discipline's standards of evidence.

Lab: Spillover Detection on a Simulated Social Network

Goal: Empirically discover how ignoring network interference biases treatment effect estimates, and verify that exposure mapping corrects the bias.

Tools needed: Python with networkx, numpy, statsmodels, and matplotlib (all pip-installable).

Procedure (25 minutes): (1) Generate a Watts-Strogatz small-world graph with 500 nodes (k=6, p=0.1). (2) Randomly assign 30% of nodes to treatment. Simulate outcomes with a known direct effect of 3.0, a spillover effect of 2.0 (proportional to fraction of treated neighbors), and Gaussian noise. (3) First, estimate the "naive" ATE by regressing outcome on treatment indicator alone, ignoring the network. Record the estimate. (4) Then estimate using the exposure mapping model from Section 4.1 (regress on own treatment, neighbor exposure fraction, and their interaction). Record direct and spillover estimates. (5) Vary the spillover strength from 0 to 4 in steps of 0.5, rerunning both estimators each time. Plot naive ATE bias (naive estimate minus true direct effect) against spillover strength.

What to observe: The naive estimator's bias should grow linearly with spillover strength because it absorbs spillover into the direct effect. The exposure mapping estimator should recover both effects accurately regardless of spillover magnitude. This demonstrates concretely why SUTVA violations cannot be safely ignored.

What's Next

Causal estimates tell us what happened in the past: this policy had this effect on this population. But policymakers need to know what will happen if they implement a new policy, in a different context, at a different time. Section 52.3: Policy Simulation bridges this gap by building agent-based models that embed causal mechanisms in a forward-looking simulation. The treatment effects estimated here become the behavioral parameters of simulated agents, and the network structure from Section 52.1 determines how simulated policies propagate through the population.