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:
Logit model with factor variables
Adjusted Predictions at the Means (APM)
Average Adjusted Predictions (AAP)
Predictions at Representative Values (APR)
Marginal Effects at the Means (MEM) — continuous
Average Marginal Effects (AME) — continuous
Discrete changes for dummy variables
Marginal Effects at Representative Values (MER)
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.