Demos

Two end-to-end walkthroughs ship in the repository root. Each one is a single self-contained script you can run with python demo_<name>.py after installing smmargins.

Williams-style logit walkthrough

demo_margins.py reproduces, on a simulated voting dataset, every core statistic in Richard Williams’ Margins01 notes:

  1. Adjusted predictions at specific values (APR / margins, at(...))

  2. Adjusted predictions at means (APM / margins, atmeans)

  3. Average adjusted predictions (AAP / margins)

  4. Marginal effects at representative values (MER / margins, dydx(..) at(..))

  5. Marginal effects at means (MEM / margins, dydx(..) atmeans)

  6. Average marginal effects (AME / margins, dydx(..))

  7. Discrete contrasts for a categorical variable

  8. Williams’ classic interaction example: AME of age separately by sex

Highlights from the script

Fit a logit with an interaction:

fit = smf.logit(
    "voted ~ age + income + C(educ) + female + age:female",
    data=df,
).fit()
M = Margins(fit)

APR — predictions at policy-relevant ages, averaging everything else over the sample:

M.predict(atexog={"age": [25, 45, 65]})

MER, MEM, and AME for age — these can differ meaningfully in nonlinear models with interactions:

M.dydx("age", atexog={"age": [25, 45, 65]})   # MER
M.dydx("age", at="mean")                      # MEM
M.dydx("age")                                 # AME

Discrete AME for a multi-level factor with an explicit reference level:

M.dydx("educ", reference="college")

Williams’ interaction lesson — same model, AME of age for each sex:

M.dydx("age", atexog={"female": [0, 1]})

Full source

  1"""
  2demo_margins.py
  3===============
  4
  5Walkthrough of the core analyses in Richard Williams' *Margins01* notes
  6(https://academicweb.nd.edu/~rwilliam/stats/Margins01.pdf), implemented
  7on top of StatsModels + patsy + the ``smmargins`` package.
  8
  9We'll reproduce, in turn:
 10
 11  1. Adjusted predictions at specific values (APR / "margins, at(...)")
 12  2. Adjusted predictions at means (APM / "margins, atmeans")
 13  3. Average adjusted predictions (AAP / "margins")
 14  4. Marginal effects at representative values (MER / "margins, dydx(..) at(..)")
 15  5. Marginal effects at means (MEM / "margins, dydx(..) atmeans")
 16  6. Average marginal effects (AME / "margins, dydx(..)")
 17  7. Discrete changes for categorical variables
 18  8. The interaction example that Williams uses to motivate AME
 19"""
 20
 21import numpy as np
 22import pandas as pd
 23import statsmodels.formula.api as smf
 24
 25from smmargins import Margins
 26
 27pd.options.display.width = 120
 28pd.options.display.float_format = "{: .4f}".format
 29
 30# ---------------------------------------------------------------------------
 31# Simulate a binary-outcome dataset with structure similar to Williams' notes
 32# ---------------------------------------------------------------------------
 33rng = np.random.default_rng(7)
 34N = 5_000
 35df = pd.DataFrame(
 36    {
 37        "age":    rng.normal(45, 12, N).clip(18, 90),
 38        "income": rng.lognormal(10.5, 0.4, N),          # ~36k median
 39        "educ":   rng.choice(["hs", "college", "grad"], N, p=[0.4, 0.4, 0.2]),
 40        "female": rng.integers(0, 2, N),
 41    }
 42)
 43eta = (
 44    -4.0
 45    + 0.05 * df["age"]
 46    + 0.00001 * df["income"]
 47    + 0.8 * (df["educ"] == "college")
 48    + 1.4 * (df["educ"] == "grad")
 49    + 0.3 * df["female"]
 50    - 0.0004 * df["age"] * (df["female"])        # interaction
 51)
 52df["voted"] = (rng.uniform(0, 1, N) < 1 / (1 + np.exp(-eta))).astype(int)
 53
 54print("Sample:")
 55print(df.head(3), "\n")
 56
 57# ---------------------------------------------------------------------------
 58# Fit a logit with an interaction, like the Williams example
 59# ---------------------------------------------------------------------------
 60fit = smf.logit(
 61    "voted ~ age + income + C(educ) + female + age:female",
 62    data=df,
 63).fit(disp=False)
 64print("=" * 80)
 65print("Fitted logit")
 66print("=" * 80)
 67print(fit.summary().tables[1])
 68print()
 69
 70# `analytic=True` is the default: the outer ∂g/∂β goes through
 71# `family.link.inverse_deriv` for any GLM (Logit/Probit/Poisson/...) and
 72# the identity link for OLS/WLS/GLS, falling back to central finite
 73# differences only when the link derivative isn't available. Set
 74# `analytic=False` to force FD; you'll get the same answers (see the
 75# parity check at the bottom of this file) but pay p extra forward
 76# predict() calls per statistic.
 77M = Margins(fit)
 78
 79# ---------------------------------------------------------------------------
 80# 1. Adjusted predictions at representative values (APR)
 81#    Stata: margins, at(age=(25 45 65))
 82# ---------------------------------------------------------------------------
 83print("=" * 80)
 84print("1. APR  (predict at age=25,45,65; everything else at sample values)")
 85print("=" * 80)
 86print(M.predict(atexog={"age": [25, 45, 65]}))
 87print()
 88
 89# ---------------------------------------------------------------------------
 90# 2. Adjusted prediction at means (APM)  vs  average adjusted prediction (AAP)
 91# ---------------------------------------------------------------------------
 92print("=" * 80)
 93print("2. APM  (margins, atmeans)   vs   AAP  (margins)")
 94print("=" * 80)
 95print("APM:"); print(M.predict(at="mean"))
 96print("\nAAP:"); print(M.predict())
 97print()
 98
 99# ---------------------------------------------------------------------------
100# 3. Marginal effect: MER vs MEM vs AME for `age`
101#    (Williams points out these three can differ meaningfully in nonlinear
102#    models with interactions)
103# ---------------------------------------------------------------------------
104print("=" * 80)
105print("3. d Pr(voted)/d age : MER (at age=25,45,65),  MEM, and AME")
106print("=" * 80)
107print("MER (at age=25,45,65):")
108print(M.dydx("age", atexog={"age": [25, 45, 65]}))
109print("\nMEM (at means of everything):")
110print(M.dydx("age", at="mean"))
111print("\nAME (averaged over the sample):")
112print(M.dydx("age"))
113print()
114
115# ---------------------------------------------------------------------------
116# 4. Discrete contrast for the categorical variable `educ`
117# ---------------------------------------------------------------------------
118print("=" * 80)
119print("4. Discrete AME for educ  (each level vs 'college' as reference)")
120print("=" * 80)
121print(M.dydx("educ", reference="college"))
122print()
123
124# ---------------------------------------------------------------------------
125# 5. Discrete change for the dummy `female`  (auto-detected as discrete)
126# ---------------------------------------------------------------------------
127print("=" * 80)
128print("5. AME for female (0/1 dummy):  Pr(voted|female=1) - Pr(voted|female=0)")
129print("=" * 80)
130print(M.dydx("female"))
131print()
132
133# ---------------------------------------------------------------------------
134# 6. Interaction-sensitivity: marginal effect of age, separately for men/women
135#    This is Williams' classic motivating example: the interaction coefficient
136#    alone tells you little about what the marginal effect actually is for any
137#    given subpopulation.
138# ---------------------------------------------------------------------------
139print("=" * 80)
140print("6. AME of age, separately by sex  (Williams' interaction illustration)")
141print("=" * 80)
142print(M.dydx("age", atexog={"female": [0, 1]}))
143print()
144
145# ---------------------------------------------------------------------------
146# 7. Adjusted predictions, age by sex — table suitable for plotting
147# ---------------------------------------------------------------------------
148print("=" * 80)
149print("7. Predicted Pr(voted) over age, for each sex")
150print("=" * 80)
151tbl = M.predict(atexog={"age": list(range(20, 91, 10)), "female": [0, 1]})
152print(tbl)
153
154# ---------------------------------------------------------------------------
155# 8. Analytic vs FD: same answers, faster path
156#    Logit exposes `family.link.inverse_deriv`, so the analytic outer
157#    Jacobian is used by default. Toggling `analytic=False` reroutes
158#    every statistic through central finite differences — useful as a
159#    sanity check or when working with a custom Link subclass that
160#    doesn't implement inverse_deriv.
161# ---------------------------------------------------------------------------
162print()
163print("=" * 80)
164print("8. Analytic vs FD — same numbers, taken via different paths")
165print("=" * 80)
166M_fd = Margins(fit, analytic=False)
167ame_an = M.dydx("age")
168ame_fd = M_fd.dydx("age")
169print(f"AME(age) analytic : est={ame_an.estimate[0]: .8f}  se={ame_an.se[0]: .8f}")
170print(f"AME(age) FD       : est={ame_fd.estimate[0]: .8f}  se={ame_fd.se[0]: .8f}")
171print(f"max abs diff      : "
172      f"est {abs(ame_an.estimate[0] - ame_fd.estimate[0]): .2e}, "
173      f"se {abs(ame_an.se[0] - ame_fd.se[0]): .2e}")

Healthcare-style 2x2 difference-in-differences

demo_did.py answers a clinical question:

Is there a rate difference of condition \(X\) between groups A and B, with or without a preexisting condition \(Y\)?

The script fits a logit on simulated patient data and reports, on the probability scale:

  • 4 cell predictions \(P(X \mid \text{group}, Y)\)

  • 2 simple effects \(P(X \mid B, Y) - P(X \mid A, Y)\) at each \(Y\)

  • 1 difference-in-differences (whether the A-vs-B gap depends on \(Y\))

All with delta-method standard errors and confidence intervals. The DiD here is not the coefficient on the group:Y interaction — that coefficient is on the log-odds scale, while the clinical question is about probabilities. This is Ai & Norton (2003) in practice; see Mathematical motivation for the derivation.

Highlights from the script

Fit and call did():

fit = smf.logit(
    "condition_X ~ C(group) + preexist_Y + C(group):preexist_Y "
    "+ age + female",
    data=df,
).fit()
M = Margins(fit)

did = M.did("group", "preexist_Y",
            group_levels=["A", "B"],
            condition_levels=[0, 1])
print(did)              # cells + simple effects + DiD

Same DiD at one specific patient profile (60-year-old male):

M.did("group", "preexist_Y",
      group_levels=["A", "B"], condition_levels=[0, 1],
      atexog={"age": 60, "female": 0})

Plot-ready cell table:

tbl = did.cells.summary()        # estimate / SE / CI per cell

Full source

  1"""
  2demo_did.py
  3===========
  4
  5Difference-in-differences example directly matching the question:
  6
  7    "Is there a rate difference of condition X between group A and B,
  8     with or without preexisting condition Y?"
  9
 10We fit a logit for P(X=1) on group (A/B), preexisting Y (0/1), their
 11interaction, and control covariates. Then we use Margins.did() to get,
 12on the *probability* scale:
 13
 14  * 4 cell predictions      P(X | group, Y)
 15  * 2 simple effects        P(X|B,Y) - P(X|A,Y)   at each Y
 16  * 1 DiD                   (simple effect at Y=1) - (simple effect at Y=0)
 17
 18All with delta-method standard errors and CIs.
 19
 20The DiD here is the "does the A-vs-B gap depend on Y?" question.  It is
 21NOT the coefficient on group×Y (that's on the log-odds scale); on the
 22probability scale you have to go through the inverse link — which is
 23exactly what Margins.did() does.
 24"""
 25import numpy as np
 26import pandas as pd
 27import statsmodels.formula.api as smf
 28
 29from smmargins import Margins
 30
 31pd.options.display.width = 140
 32pd.options.display.float_format = "{: .4f}".format
 33
 34# ---------------------------------------------------------------------------
 35# Simulate patient-level data
 36# ---------------------------------------------------------------------------
 37rng = np.random.default_rng(42)
 38N = 6_000
 39df = pd.DataFrame({
 40    "group":        rng.choice(["A", "B"], N, p=[0.55, 0.45]),
 41    "preexist_Y":   rng.integers(0, 2, N),             # 0 = no Y, 1 = has Y
 42    "age":          rng.normal(55, 15, N).clip(18, 95),
 43    "female":       rng.integers(0, 2, N),
 44})
 45
 46# True data-generating process:
 47#   * baseline rate of X depends on age, sex, and Y
 48#   * group B has a modest additive bump in the log-odds
 49#   * the group effect is AMPLIFIED among patients with preexisting Y
 50#     (this is the thing we want to detect)
 51eta = (
 52    -3.5
 53    + 0.04 * df["age"]
 54    - 0.3 * df["female"]
 55    + 0.5 * (df["group"] == "B")
 56    + 1.1 * df["preexist_Y"]
 57    + 0.8 * (df["group"] == "B") * df["preexist_Y"]   # interaction
 58)
 59df["condition_X"] = (rng.uniform(0, 1, N) < 1 / (1 + np.exp(-eta))).astype(int)
 60
 61print("Raw sample rates of condition X by cell:")
 62print(df.groupby(["group", "preexist_Y"])["condition_X"].mean().round(4))
 63print()
 64
 65# ---------------------------------------------------------------------------
 66# Fit the logit with the group × preexist_Y interaction + controls
 67# ---------------------------------------------------------------------------
 68fit = smf.logit(
 69    "condition_X ~ C(group) + preexist_Y + C(group):preexist_Y + age + female",
 70    data=df,
 71).fit(disp=False)
 72
 73print("=" * 84)
 74print("Logit model (coefficients are on the log-odds scale)")
 75print("=" * 84)
 76print(fit.summary().tables[1])
 77print()
 78
 79# ---------------------------------------------------------------------------
 80# DiD on the *probability* (response) scale — what the clinical question asks
 81# ---------------------------------------------------------------------------
 82# Margins(fit) uses analytic outer Jacobians via family.link.inverse_deriv
 83# when available (Logit qualifies), falling back to central finite
 84# differences otherwise. did() reuses predict()'s machinery, so it
 85# inherits the analytic path automatically. Set Margins(fit,
 86# analytic=False) to force FD if you ever want to cross-check.
 87M = Margins(fit)
 88did = M.did("group", "preexist_Y",
 89            group_levels=["A", "B"],
 90            condition_levels=[0, 1])
 91
 92print("=" * 84)
 93print("DiD on the probability scale, averaged over age and sex distribution")
 94print("=" * 84)
 95print(did)
 96
 97# ---------------------------------------------------------------------------
 98# Interpretation
 99# ---------------------------------------------------------------------------
100pA0 = did.cells.estimate[0]   # group=A, Y=0
101pA1 = did.cells.estimate[1]   # group=A, Y=1
102pB0 = did.cells.estimate[2]   # group=B, Y=0
103pB1 = did.cells.estimate[3]   # group=B, Y=1
104se_simple_Y0 = did.simple_effects.se[0]
105se_simple_Y1 = did.simple_effects.se[1]
106did_est, did_se = did.did.estimate[0], did.did.se[0]
107
108print()
109print("=" * 84)
110print("Plain-language summary")
111print("=" * 84)
112print(f"Condition X rate, group A, no preexisting Y : {pA0:.3%}")
113print(f"Condition X rate, group A, with Y           : {pA1:.3%}")
114print(f"Condition X rate, group B, no preexisting Y : {pB0:.3%}")
115print(f"Condition X rate, group B, with Y           : {pB1:.3%}")
116print()
117print(f"Rate difference (B - A) among NO-Y patients  : "
118      f"{(pB0 - pA0):+.3%}  (SE {se_simple_Y0:.3%})")
119print(f"Rate difference (B - A) among WITH-Y patients: "
120      f"{(pB1 - pA1):+.3%}  (SE {se_simple_Y1:.3%})")
121print()
122print(f"Difference-in-differences                   : "
123      f"{did_est:+.3%}  (SE {did_se:.3%})")
124print(f"  -> the B-vs-A gap is {abs(did_est):.3%} larger among patients "
125      f"with preexisting Y.")
126print(f"  -> 95% CI: ({did.did.ci_lower[0]:+.3%}, {did.did.ci_upper[0]:+.3%})")
127print(f"  -> p-value: {did.did.pvalues[0]:.4g}")
128
129# ---------------------------------------------------------------------------
130# Sensitivity: DiD at a specific patient profile (e.g. 60-year-old male)
131# ---------------------------------------------------------------------------
132print()
133print("=" * 84)
134print("DiD at a specific profile: 60-year-old male")
135print("=" * 84)
136did_profile = M.did(
137    "group", "preexist_Y",
138    group_levels=["A", "B"], condition_levels=[0, 1],
139    atexog={"age": 60, "female": 0},
140)
141print(did_profile.did)
142
143# ---------------------------------------------------------------------------
144# Bonus: plottable table of cell predictions with CIs
145# ---------------------------------------------------------------------------
146print()
147print("=" * 84)
148print("Cells with 95% CIs (suitable for a plot)")
149print("=" * 84)
150tbl = did.cells.summary().copy()
151print(tbl)
152
153# If you wanted to plot:
154#   import matplotlib.pyplot as plt
155#   fig, ax = plt.subplots()
156#   for g in ["A", "B"]:
157#       sub = tbl[tbl.index.str.contains(f"group={g}")]
158#       ax.errorbar([0, 1],
159#                   sub["prediction"].values,
160#                   yerr=(sub["prediction"] - sub["[95% CI lo]"]).values,
161#                   marker="o", label=f"group {g}", capsize=4)
162#   ax.set_xticks([0, 1]); ax.set_xticklabels(["no Y", "with Y"])
163#   ax.set_ylabel("P(condition X)"); ax.legend(); plt.show()