Replication scripts (archive)

The notebook-style dataset demos under Demos — end-to-end analyses are the recommended entry point. The script-style demos archived here ship as standalone .py files in demo/ and are kept for reference and exact numerical reproduction against the Williams (2012) paper.

Williams (2012) — Adjusted predictions and marginal effects

demo/williams_2012_demo.py replicates, on a simulated NHANES-like dataset, every core analysis from Richard Williams’ Stata Journal paper:

  1. Logit model with factor variables

  2. Adjusted Predictions at the Means (APM)

  3. Average Adjusted Predictions (AAP)

  4. Predictions at Representative Values (APR)

  5. Marginal Effects at the Means (MEM) — continuous

  6. Average Marginal Effects (AME) — continuous

  7. Discrete changes for dummy variables

  8. Marginal Effects at Representative Values (MER)

  9. OLS model for comparison

  1"""
  2Demo: Replicating analyses from Richard Williams (2012),
  3"Using the Margins Command to Estimate and Interpret Adjusted Predictions
  4and Marginal Effects", Stata Journal 12(2): 308-331.
  5
  6DOI: 10.1177/1536867X1201200209
  7
  8This script generates synthetic NHANES-like data and demonstrates the
  9core analyses from the paper using statsmodels + pymargins.
 10
 11Analyses covered:
 12  1. Logit model with factor variables
 13  2. Adjusted Predictions at the Means (APM)
 14  3. Average Adjusted Predictions (AAP)
 15  4. Predictions at Representative Values
 16  5. Marginal Effects at the Means (MEM) — continuous
 17  6. Average Marginal Effects (AME) — continuous
 18  7. Discrete changes for dummy variables
 19  8. Marginal Effects at Representative Values (MER)
 20  9. OLS model for comparison
 21"""
 22
 23import numpy as np
 24import pandas as pd
 25import statsmodels.api as sm
 26import statsmodels.formula.api as smf
 27
 28from pymargins import Margins
 29
 30# ---------------------------------------------------------------------------
 31# 0. Generate synthetic NHANES-like data
 32# ---------------------------------------------------------------------------
 33
 34
 35def make_data(n=5000, seed=42):
 36    """Synthetic health-survey data mimicking NHANES II structure."""
 37    rng = np.random.default_rng(seed)
 38
 39    # Demographics
 40    female = rng.binomial(1, 0.52, size=n)
 41    black = rng.binomial(1, 0.11, size=n)
 42    age = rng.integers(20, 75, size=n)
 43
 44    # Age groups (1=20-29, 2=30-39, ..., 6=70+)
 45    agegrp = pd.cut(
 46        age, bins=[19, 29, 39, 49, 59, 69, 100], labels=[1, 2, 3, 4, 5, 6]
 47    ).astype(int)
 48
 49    # BMI (correlated with age and sex)
 50    bmi = 22 + 0.15 * age + 1.5 * female + rng.normal(0, 4, size=n)
 51    bmi = np.clip(bmi, 15, 50)
 52
 53    # Diabetes risk (logit link)
 54    lp = (
 55        -4.0
 56        + 0.55 * black
 57        + 0.10 * female
 58        + 0.06 * age
 59        + 0.03 * bmi
 60        + 0.5 * (agegrp == 2).astype(float)
 61        + 0.9 * (agegrp == 3).astype(float)
 62        + 1.4 * (agegrp == 4).astype(float)
 63        + 2.0 * (agegrp == 5).astype(float)
 64        + 2.6 * (agegrp == 6).astype(float)
 65    )
 66    diabetes = rng.binomial(1, 1 / (1 + np.exp(-lp)))
 67
 68    # Continuous outcome (e.g., blood pressure) for OLS demo
 69    bp = (
 70        110
 71        + 0.4 * age
 72        + 2.5 * black
 73        + 1.2 * female
 74        + 0.5 * bmi
 75        + rng.normal(0, 8, size=n)
 76    )
 77
 78    df = pd.DataFrame(
 79        {
 80            "diabetes": diabetes,
 81            "bp": bp,
 82            "black": black,
 83            "female": female,
 84            "age": age,
 85            "agegrp": agegrp,
 86            "bmi": bmi,
 87        }
 88    )
 89    return df
 90
 91
 92if __name__ == "__main__":
 93    df = make_data(n=5000, seed=42)
 94    print("=" * 70)
 95    print(f"Synthetic NHANES-like data (n = {len(df):,})")
 96    print("=" * 70)
 97    print(df.describe())
 98    print()
 99
100    # =====================================================================
101    # 1. Logit model with factor variables (Williams §2)
102    # =====================================================================
103    print("=" * 70)
104    print("1. LOGIT MODEL: diabetes ~ C(black) + C(female) + C(agegrp) + bmi + age")
105    print("=" * 70)
106
107    fit_logit = smf.glm(
108        "diabetes ~ C(black) + C(female) + C(agegrp) + bmi + age",
109        data=df,
110        family=sm.families.Binomial(),
111    ).fit()
112    print(fit_logit.summary())
113    print()
114
115    # =====================================================================
116    # 2. Adjusted Predictions at the Means (APM)  (Williams §3.1)
117    # =====================================================================
118    # In Stata: margins C(agegrp), atmeans
119    # All covariates held at their typical values (continuous = median,
120    # categorical = mode).  For models with factor variables this is the
121    # safest pymargins equivalent because patsy requires integer category
122    # codes; setting a dummy to its mean proportion (0.107) is not a valid
123    # level for patsy even though it is mathematically correct.
124    print("=" * 70)
125    print("2. ADJUSTED PREDICTIONS AT THE MEANS (APM)")
126    print("   Stata equivalent: margins agegrp, atmeans")
127    print("=" * 70)
128
129    m_apm = Margins.log_scale(fit_logit, at="typical")
130    apm = m_apm.predict(
131        atexog={"agegrp": list(range(1, 7))},
132    )
133    print(apm.summary(stars=True))
134    print()
135
136    # =====================================================================
137    # 3. Average Adjusted Predictions (AAP)  (Williams §3.2)
138    # =====================================================================
139    # In Stata: margins C(agegrp)
140    # Predictions averaged over the observed distribution of other covariates.
141    print("=" * 70)
142    print("3. AVERAGE ADJUSTED PREDICTIONS (AAP)")
143    print("   Stata equivalent: margins agegrp")
144    print("=" * 70)
145
146    m_aap = Margins.log_scale(fit_logit, at="overall")
147    aap = m_aap.predict(
148        atexog={"agegrp": list(range(1, 7))},
149    )
150    print(aap.summary(stars=True))
151    print()
152
153    # =====================================================================
154    # 4. Predictions at Representative Values  (Williams §3.3)
155    # =====================================================================
156    # In Stata: margins, at(age=(20 50 70)) atmeans
157    # Evaluate predictions for specific ages, holding others at means.
158    print("=" * 70)
159    print("4. PREDICTIONS AT REPRESENTATIVE VALUES")
160    print("   Stata equivalent: margins, at(age=(20 50 70)) atmeans")
161    print("=" * 70)
162
163    m_repr = Margins.log_scale(fit_logit, at="typical")
164    repr_pred = m_repr.predict(
165        atexog={"age": [20, 50, 70]},
166    )
167    print(repr_pred.summary(stars=True))
168    print()
169
170    # =====================================================================
171    # 5. Marginal Effects at the Means (MEM) — continuous  (Williams §4.1)
172    # =====================================================================
173    # In Stata: margins, dydx(age) atmeans
174    print("=" * 70)
175    print("5. MARGINAL EFFECTS AT THE MEANS (MEM) — age")
176    print("   Stata equivalent: margins, dydx(age) atmeans")
177    print("=" * 70)
178
179    m_mem = Margins.log_scale(fit_logit, at="typical")
180    mem_age = m_mem.dydx("age")
181    print(mem_age.summary(stars=True))
182    print()
183
184    # =====================================================================
185    # 6. Average Marginal Effects (AME) — continuous  (Williams §4.2)
186    # =====================================================================
187    # In Stata: margins, dydx(age)
188    print("=" * 70)
189    print("6. AVERAGE MARGINAL EFFECTS (AME) — age")
190    print("   Stata equivalent: margins, dydx(age)")
191    print("=" * 70)
192
193    m_ame = Margins.log_scale(fit_logit, at="overall")
194    ame_age = m_ame.dydx("age")
195    print(ame_age.summary(stars=True))
196    print()
197
198    # =====================================================================
199    # 7. Discrete changes for dummy variables  (Williams §4.3)
200    # =====================================================================
201    # For binary/discrete variables, Stata margins computes the discrete
202    # change (difference in predicted probability when flipping the dummy
203    # from 0 to 1), not a derivative.
204    #
205    # In Stata: margins, dydx(black)
206    # In pymargins: use contrasts() with two scenarios.
207    print("=" * 70)
208    print("7. DISCRETE CHANGE (MARGINAL EFFECT) — black")
209    print("   Stata equivalent: margins, dydx(black)")
210    print("=" * 70)
211
212    m_disc = Margins.log_scale(fit_logit, at="overall")
213    disc_black = m_disc.contrasts(
214        scenarios=[
215            {"atexog": {"black": 1}, "label": "black=1"},
216            {"atexog": {"black": 0}, "label": "black=0"},
217        ],
218        contrasts=[+1, -1],
219    )
220    print(disc_black.summary(stars=True))
221    print()
222
223    # Discrete change at the means
224    print("-" * 70)
225    print("7b. DISCRETE CHANGE AT THE MEANS — female")
226    print("    Stata equivalent: margins, dydx(female) atmeans")
227    print("-" * 70)
228
229    m_disc_mem = Margins.log_scale(fit_logit, at="typical")
230    disc_female = m_disc_mem.contrasts(
231        scenarios=[
232            {"atexog": {"female": 1}, "label": "female=1"},
233            {"atexog": {"female": 0}, "label": "female=0"},
234        ],
235        contrasts=[+1, -1],
236    )
237    print(disc_female.summary(stars=True))
238    print()
239
240    # =====================================================================
241    # 8. Marginal Effects at Representative Values (MER)  (Williams §4.4)
242    # =====================================================================
243    # In Stata: margins, dydx(black) at(female=(0 1))
244    # Compute the discrete change for black, separately for females and males.
245    print("=" * 70)
246    print("8. MARGINAL EFFECTS AT REPRESENTATIVE VALUES (MER)")
247    print("   Stata equivalent: margins, dydx(black) at(female=(0 1))")
248    print("=" * 70)
249
250    m_mer = Margins.log_scale(fit_logit, at="overall")
251    mer_female0 = m_mer.contrasts(
252        scenarios=[
253            {"atexog": {"black": 1, "female": 0}, "label": "black=1, female=0"},
254            {"atexog": {"black": 0, "female": 0}, "label": "black=0, female=0"},
255        ],
256        contrasts=[+1, -1],
257    )
258    mer_female1 = m_mer.contrasts(
259        scenarios=[
260            {"atexog": {"black": 1, "female": 1}, "label": "black=1, female=1"},
261            {"atexog": {"black": 0, "female": 1}, "label": "black=0, female=1"},
262        ],
263        contrasts=[+1, -1],
264    )
265    print("Effect of black for MALES (female=0):")
266    print(mer_female0.summary(stars=True))
267    print()
268    print("Effect of black for FEMALES (female=1):")
269    print(mer_female1.summary(stars=True))
270    print()
271
272    # =====================================================================
273    # 9. OLS model — compare with logit  (Williams §5)
274    # =====================================================================
275    print("=" * 70)
276    print("9. OLS MODEL: bp ~ C(black) + C(female) + age + bmi")
277    print("=" * 70)
278
279    fit_ols = smf.ols(
280        "bp ~ C(black) + C(female) + age + bmi",
281        data=df,
282    ).fit()
283    print(fit_ols.summary())
284    print()
285
286    # MEM for OLS (linear model: MEM = AME = coefficient)
287    m_ols = Margins.linear_scale(fit_ols, at="typical")
288    mem_ols_age = m_ols.dydx("age")
289    print("MEM of age in OLS model (should match coefficient):")
290    print(mem_ols_age.summary(stars=True))
291    print()
292
293    # AME for OLS (same as MEM in linear models)
294    m_ols_ame = Margins.linear_scale(fit_ols, at="overall")
295    ame_ols_age = m_ols_ame.dydx("age")
296    print("AME of age in OLS model:")
297    print(ame_ols_age.summary(stars=True))
298    print()
299
300    # =====================================================================
301    # 10. LaTeX / HTML export demos
302    # =====================================================================
303    print("=" * 70)
304    print("10. EXPORT EXAMPLES")
305    print("=" * 70)
306
307    print("--- LaTeX output for AME of age ---")
308    print(ame_age.to_latex(stars=True, caption="AME of Age on Diabetes Risk"))
309    print()
310
311    print("--- HTML output for AME of age ---")
312    print(ame_age.to_html(stars=True, caption="AME of Age on Diabetes Risk"))
313    print()
314
315    print("Demo complete.")

Williams (2012) — Inference scales

demo/williams_2012_demo_scales.py reruns the same analyses on the log, logit, and lift inference scales, demonstrating how a single session locks in \((\phi, \phi^{-1})\) for the whole audit trail and how the κ diagnostic flags when the chosen scale is poorly linearized.

  1"""
  2Demo: Scales and contrasts — RR, direct ratio, and lift.
  3
  4Uses the same synthetic NHANES-like data as williams_2012_demo.py.
  5Demonstrates three ways to quantify the effect of a binary covariate:
  6
  7  1. Risk Ratio (RR)      — log_scale:  exp(log(p1) - log(p0))
  8  2. Direct Ratio         — linear_scale + evaluate:  p1 / p0
  9  3. Lift (RR - 1)        — log_scale result minus 1
 10
 11Reference implementations:
 12  - StatsModels: manual point estimates
 13  - R marginaleffects: delta-method SEs and CIs
 14"""
 15
 16import jax
 17
 18jax.config.update("jax_enable_x64", True)
 19
 20import numpy as np
 21import pandas as pd
 22import statsmodels.api as sm
 23import statsmodels.formula.api as smf
 24
 25from pymargins import Margins
 26
 27
 28def make_data(n=5000, seed=42):
 29    """Synthetic health-survey data mimicking NHANES II structure."""
 30    rng = np.random.default_rng(seed)
 31    female = rng.binomial(1, 0.52, size=n)
 32    black = rng.binomial(1, 0.11, size=n)
 33    age = rng.integers(20, 75, size=n)
 34    agegrp = pd.cut(
 35        age, bins=[19, 29, 39, 49, 59, 69, 100], labels=[1, 2, 3, 4, 5, 6]
 36    ).astype(int)
 37    bmi = 22 + 0.15 * age + 1.5 * female + rng.normal(0, 4, size=n)
 38    bmi = np.clip(bmi, 15, 50)
 39    lp = (
 40        -4.0
 41        + 0.55 * black
 42        + 0.10 * female
 43        + 0.06 * age
 44        + 0.03 * bmi
 45        + 0.5 * (agegrp == 2).astype(float)
 46        + 0.9 * (agegrp == 3).astype(float)
 47        + 1.4 * (agegrp == 4).astype(float)
 48        + 2.0 * (agegrp == 5).astype(float)
 49        + 2.6 * (agegrp == 6).astype(float)
 50    )
 51    diabetes = rng.binomial(1, 1 / (1 + np.exp(-lp)))
 52    bp = (
 53        110
 54        + 0.4 * age
 55        + 2.5 * black
 56        + 1.2 * female
 57        + 0.5 * bmi
 58        + rng.normal(0, 8, size=n)
 59    )
 60    return pd.DataFrame(
 61        {
 62            "diabetes": diabetes,
 63            "bp": bp,
 64            "black": black,
 65            "female": female,
 66            "age": age,
 67            "agegrp": agegrp,
 68            "bmi": bmi,
 69        }
 70    )
 71
 72
 73def _print_section(title):
 74    print("=" * 70)
 75    print(title)
 76    print("=" * 70)
 77
 78
 79if __name__ == "__main__":
 80    df = make_data(n=5000, seed=42)
 81
 82    fit_logit = smf.glm(
 83        "diabetes ~ C(black) + C(female) + C(agegrp) + bmi + age",
 84        data=df,
 85        family=sm.families.Binomial(),
 86    ).fit()
 87
 88    # =====================================================================
 89    # 1. Risk Ratio via log_scale
 90    # =====================================================================
 91    _print_section("1. RISK RATIO — log_scale")
 92    print("   Inference: log(p1) - log(p0)   |   Reported: exp(...)")
 93    print()
 94
 95    m_rr = Margins.log_scale(fit_logit, at="overall", kappa_threshold=float("inf"))
 96    rr_black = m_rr.contrasts(
 97        scenarios=[
 98            {"atexog": {"black": 1}, "label": "black=1"},
 99            {"atexog": {"black": 0}, "label": "black=0"},
100        ],
101        contrasts=[+1, -1],
102    )
103    print(rr_black.summary(stars=True))
104    print()
105
106    rr_female = m_rr.contrasts(
107        scenarios=[
108            {"atexog": {"female": 1}, "label": "female=1"},
109            {"atexog": {"female": 0}, "label": "female=0"},
110        ],
111        contrasts=[+1, -1],
112    )
113    print(rr_female.summary(stars=True))
114    print()
115
116    # =====================================================================
117    # 2. Direct Ratio via linear_scale + evaluate()
118    # =====================================================================
119    _print_section("2. DIRECT RATIO — linear_scale + evaluate(p[0]/p[1])")
120    print("   Inference: p1 / p0 directly   |   Delta-method SE on ratio scale")
121    print()
122
123    m_ratio = Margins.linear_scale(
124        fit_logit, at="overall", kappa_threshold=float("inf")
125    )
126    ratio_black = m_ratio.evaluate(
127        scenarios=[
128            {"atexog": {"black": 1}, "label": "black=1"},
129            {"atexog": {"black": 0}, "label": "black=0"},
130        ],
131        compose=lambda p: p[0] / p[1],
132    )
133    print(ratio_black.summary(stars=True))
134    print()
135
136    ratio_female = m_ratio.evaluate(
137        scenarios=[
138            {"atexog": {"female": 1}, "label": "female=1"},
139            {"atexog": {"female": 0}, "label": "female=0"},
140        ],
141        compose=lambda p: p[0] / p[1],
142    )
143    print(ratio_female.summary(stars=True))
144    print()
145
146    # =====================================================================
147    # 3. True Lift = RR - 1
148    # =====================================================================
149    _print_section("3. TRUE LIFT (RR - 1)")
150    print("   Computed from log_scale RR:  lift = RR - 1")
151    print()
152
153    lift_black_est = float(rr_black.estimate) - 1.0
154    lift_black_ci = (
155        float(rr_black.conf_int_lower) - 1.0,
156        float(rr_black.conf_int_upper) - 1.0,
157    )
158    print(
159        f"black:  lift = {lift_black_est:.4f}   CI = ({lift_black_ci[0]:.4f}, {lift_black_ci[1]:.4f})"
160    )
161
162    lift_female_est = float(rr_female.estimate) - 1.0
163    lift_female_ci = (
164        float(rr_female.conf_int_lower) - 1.0,
165        float(rr_female.conf_int_upper) - 1.0,
166    )
167    print(
168        f"female: lift = {lift_female_est:.4f}   CI = ({lift_female_ci[0]:.4f}, {lift_female_ci[1]:.4f})"
169    )
170    print()
171
172    # =====================================================================
173    # Compact reference table
174    # =====================================================================
175    _print_section("REFERENCE TABLE — All Scales")
176    print(f"{'Scale':<20} {'black':>12} {'female':>12}")
177    print("-" * 46)
178    print(
179        f"{'RR (log_scale)':<20} {float(rr_black.estimate):>12.4f} {float(rr_female.estimate):>12.4f}"
180    )
181    print(
182        f"{'Direct ratio':<20} {float(ratio_black.estimate):>12.4f} {float(ratio_female.estimate):>12.4f}"
183    )
184    print(f"{'True lift (RR-1)':<20} {lift_black_est:>12.4f} {lift_female_est:>12.4f}")
185    print()
186    print("Demo complete.")

Cross-checks

The companion files demo/williams_2012_demo_statsmodels.py, demo/williams_2012_demo_marginaleffects_r.R, and demo/williams_2012_demo_scales_marginaleffects_r.R reproduce the same numbers using statsmodels.get_margeff and the R marginaleffects package. These exist to pin pymargins to externally agreed answers at the precision both tools agree on.