ergodicDocs
Products: switch from ergodic to another product
Get started

GuidesEstimate a mediated effect

Estimate a mediated effect

Split the effect of a treatment into the part that flows through a mediator and the part that doesn't. You'll check that the split is identifiable, estimate it with intervals and compare it with the values planted in a simulation.

You'll need

  • ergodic, installed with pip install "ergodic".
  • A binary treatment, a binary or low-cardinality categorical mediator, and a continuous or binary outcome.

Run the program#

The simulation is a job-training programme. Training raises wages directly, and it also makes workers more likely to get certified, which raises wages too. Prior experience affects all three.

mediation.pyDownload
import numpy as np
import pandas as pd

from ergodic import dag, tabular
from ergodic.identification import identify_mediation
from ergodic.inference import estimate_mediation


def expit(z):
    return 1 / (1 + np.exp(-z))


# 1. Simulate 2,000 workers. The direct effect of training is 1.0.
rng = np.random.default_rng(0)
n = 2_000
experience = rng.normal(size=n)
training = rng.binomial(1, expit(0.5 * experience))
certified = rng.binomial(1, expit(-1 + 2 * training + 0.5 * experience))
wage = 20 + 1.0 * training + 3.0 * certified + 1.5 * experience + rng.normal(0, 2, n)
frame = pd.DataFrame(
    {"experience": experience, "training": training, "certified": certified, "wage": wage}
)
data = tabular(frame, types={"training": "categorical", "certified": "categorical"})

# 2. Draw the graph and check that the split is identifiable.
graph = dag([
    "experience -> training", "experience -> certified", "experience -> wage",
    "training -> certified", "training -> wage", "certified -> wage",
])
print(identify_mediation(graph, "training", "wage", "certified"))

# 3. Estimate the direct and indirect effects.
split = estimate_mediation(
    graph, data, "training", "wage", "certified", treated=1, random_state=0, n_boot=200
)
for name, effect in [("direct", split.nde), ("indirect", split.nie), ("total", split.total)]:
    low, high = effect.interval()
    print(f"{name:8} {effect.value:.2f}  95% interval [{low:.2f}, {high:.2f}]")
print(f"proportion mediated: {split.proportion_mediated:.2f}")

# 4. The planted indirect effect: 3.0 times the rise in certification.
planted = 3.0 * np.mean(expit(1 + 0.5 * experience) - expit(-1 + 0.5 * experience))
print(f"planted: direct 1.00, indirect {planted:.2f}")

# 5. A confounder of certification and wage that training itself affects.
confounded = dag([*graph.edge_list(), "training -> motivation",
                  "motivation -> certified", "motivation -> wage"])
print(identify_mediation(confounded, "training", "wage", "certified"))
Recorded output
MediationID(identifiable, ['training'] -> [certified] -> ['wage'], W={experience}, cde=True)
direct   1.17  95% interval [0.98, 1.38]
indirect 1.21  95% interval [1.04, 1.37]
total    2.42  95% interval [2.19, 2.66]
proportion mediated: 0.50
planted: direct 1.00, indirect 1.32
MediationID(NDE/NIE not identifiable, ['training'] -> [certified] -> ['wage'], cde=True)

Three effects#

identify_mediation returns a MediationID. It is truthy when the natural effects are identifiable. It carries the formulas (nde_estimand, nie_estimand, cde_estimand), the adjustment set (covariates) and, in assumptions, the assumptions in words.

The natural effects rest on an assumption no data can test

They compare outcomes across two worlds that never both happen: treated, with the mediator at its untreated value. The graph implies that assumption only if you read it as a structural model with independent errors, and no experiment can check it. The CDE needs no such assumption.

Read the estimate#

estimate_mediation returns a MediationEstimate. nde, nie and total are EffectEstimate objects with value, std_error and interval(), from a bootstrap of n_boot=1000 resamples by default. Each resample refits the models but averages them over all your rows, so the intervals are for the effect on these rows' covariates; for a wider population they are somewhat narrow when the effect varies with the covariates.

The method is g-computation: fit the outcome given treatment, mediator and covariates, fit the mediator given treatment and covariates, then average the mediation formula over your rows. The outcome model is ordinary least squares, or logistic regression for a binary outcome. The mediator model is logistic regression. Pass learner={"outcome": ..., "mediator": ...} to use others; the mediator needs a classifier.

nde and nie are the pure effects: each holds the other path at the untreated level. total is the total effect of switching treatment on, computed directly. When treatment and mediator interact in their effect on the outcome, the pure effects don't add up to it, and total - nde - nie is the mediated interaction, available as split.mediated_interaction. The outcome model includes that interaction, so it never quite vanishes in a fit, which is why the program's total is close to, but not exactly, the sum of its two parts.

proportion_mediated is nie / total with the pure NIE: the share of the effect explained by the mediator (Pearl, 2001). Other software, such as R's CMAverse, often reports the share owed to mediation instead, 1 - split.nde.value / split.total.value. The two agree without an interaction and differ by mediated_interaction / total with one; on the Berkeley admissions data they are 1.01 and 1.50. Either is unstable when the total effect is near zero, and proportion_mediated_flagged is then True.

What if my mediator is continuous?

estimate_mediation raises: it supports discrete mediators only. A treatment with more than two levels also raises. identify_mediation itself has no such limit, so you can still check identifiability.

Next steps#

Documentation

Search ergodic 0.2.0

Search ergodic by task, function or error.