import numpy as np
import pandas as pd
import expdpy as exanalyze_marginal_effects_plot
Run this page interactively in Google Colab — no install required:
This page is two things at once: an extended user guide for analyze_marginal_effects_plot — what it does, every argument, and everything it returns — and a testing environment that plants a known interaction and checks that the function traces it exactly. If a cell’s assert ever fails, the function is broken. Everything here describes associations; an interaction is not a causal claim.
What is a marginal effect in an interaction model?
When a model includes an interaction focal * moderator, the slope of the focal regressor is not one number. It is a line that slides as the moderator moves:
\[ ME(z) = b_{\text{focal}} + b_{\text{interaction}}\, z . \]
The marginal effect is that partial derivative \(\partial y / \partial x\) evaluated at moderator value \(z\). Its uncertainty comes from the delta method, which combines the variances and covariance of the two coefficients:
\[ \operatorname{Var}\!\big(ME(z)\big) = V_{ff} + z^{2} V_{ii} + 2 z\, V_{fi} . \]
Because the variance is quadratic in \(z\), the confidence band is narrowest near the centre of the data and widens toward the edges. A linear marginal effect crosses zero at most once, at the sign-flip point
\[ z^{\star} = -\,\frac{b_{\text{focal}}}{b_{\text{interaction}}}, \]
below and above which the focal regressor relates to the outcome in opposite directions. analyze_marginal_effects_plot fits the interaction model, traces \(ME(z)\) across the moderator’s observed range with the delta-method band, marks the zero line, and reports the average marginal effect (the slope evaluated at the sample-mean moderator).
1. The method in one cell
On the bundled kuznets panel: does the income–inequality gradient depend on a country’s trade openness? Trace the marginal effect of log GDP per capita across the range of the trade share.
from expdpy.data import load_kuznets, load_kuznets_data_def
df = ex.set_labels(load_kuznets(), load_kuznets_data_def(), set_panel=True)
res = ex.analyze_marginal_effects_plot(
df, dv="gini_regional", focal="log_gdp_pc", moderator="trade_share"
)
res.figThe curve crosses zero inside the data: at low trade openness the gradient is positive, at high openness it turns negative. The average marginal effect summarises it in one number:
print(f"average marginal effect = {res.ame:.5f} (se {res.ame_se:.5f})")average marginal effect = -0.00904 (se 0.00204)
≈ -0.009, distinguishable from zero — but, as the figure shows, that single average hides a sign change.
2. How the function works
Arguments
| argument | what it does | when to change it |
|---|---|---|
dv |
the dependent (outcome) variable | always set it |
focal |
the regressor whose marginal effect is traced | the variable of interest |
moderator |
the variable it is interacted with | the conditioning variable |
controls |
additional exogenous controls | to hold other covariates fixed |
feffects |
fixed effects absorbed by pyfixest | panel / group designs |
clusters |
cluster variable(s) → also widens the band | when errors are correlated within groups |
at |
explicit moderator values to evaluate | to read the effect at specific points |
n_points |
grid size when at is not given |
smoother / coarser curve (default 50) |
alpha |
band significance level | 0.05 → 95% (default) |
title |
figure title | for presentation |
What it returns
The result names the design and carries the grid behind the figure:
print("focal :", res.focal)
print("moderator :", res.moderator)
print("AME :", round(res.ame, 5), " AME se :", round(res.ame_se, 5))
res.df.head()focal : log_gdp_pc
moderator : trade_share
AME : -0.00904 AME se : 0.00204
| trade_share | me | se | ci_lower | ci_upper | |
|---|---|---|---|---|---|
| 0 | 0.197650 | 0.025357 | 0.004368 | 0.016796 | 0.033918 |
| 1 | 0.222617 | 0.023278 | 0.004132 | 0.015179 | 0.031378 |
| 2 | 0.247584 | 0.021200 | 0.003901 | 0.013554 | 0.028846 |
| 3 | 0.272551 | 0.019122 | 0.003674 | 0.011921 | 0.026323 |
| 4 | 0.297518 | 0.017043 | 0.003453 | 0.010276 | 0.023811 |
res.df has one row per grid point: the moderator value, the marginal effect me, its standard error se, and the band bounds ci_lower / ci_upper.
.interpret() reads it in plain language — it names the sign-flip point and the average effect, and keeps the language associational:
print(res.interpret())The marginal association of **log_gdp_pc** with the outcome is not constant — it depends on **trade_share** (their interaction).
Across the observed range it moves from 0.0254 at trade_share = 0.198 to -0.0765 at trade_share = 1.42.
The association switches sign near trade_share = 0.502: log_gdp_pc relates to the outcome in opposite directions below and above that point.
Averaged over the sample, the marginal association is -0.00904 (statistically significant at the 1% level).
_These are associations, not causal effects. A causal reading needs a research design — see `explain('correlation_vs_causation')`._
.explain() returns the concept explainer (the same as ex.explain("marginal_effects")):
res.explain()Marginal effects & interactions
What it is. In a model with an interaction term (focal * moderator), the slope of the focal regressor is not one number: it is b_focal + b_interaction * moderator, a line traced across the moderator’s range. The marginal effect is that partial derivative, and its confidence band (via the delta method) widens away from the centre of the data.
When to use it. Whenever a model includes an interaction and you want to read how the focal regressor’s association with the outcome changes as the moderator varies — and where, if anywhere, that association is distinguishable from zero.
Watch out for. - The marginal effect is only meaningful over the moderator’s observed range; extrapolating beyond it is unsupported. - Significance varies along the moderator — a marginal effect can be significant at some values and not others, so read the whole band, not one point. - Interactions describe associations; a causal reading still needs a research design.
See also: ols, fwl, correlation_vs_causation
References: Brambor, Clark & Golder (2006); Wooldridge, Introductory Econometrics, ch. 6
3. Does it recover the truth?
Plant a known interaction and check two things: that the traced me grid is exactly \(\hat{b}_{\text{focal}} + \hat{b}_{\text{interaction}}\, z\), and that the recovered sign-flip matches \(-b_{\text{focal}}/b_{\text{interaction}}\).
\[ y = a + b_f\, x + b_m\, z + b_{fz}\,(x z) + \varepsilon, \qquad z^{\star} = -\,b_f / b_{fz} . \]
With \(b_f = 0.5\) and \(b_{fz} = 0.4\) the true flip is at \(z^{\star} = -1.25\), inside the data.
B_F, B_M, B_FZ, A = 0.5, 0.3, 0.4, 1.0 # planted; flip at -B_F/B_FZ = -1.25
def me_dgp(*, n=3000, a=A, b_f=B_F, b_m=B_M, b_fz=B_FZ, seed=0):
"""Interaction DGP: y = a + b_f*x + b_m*z + b_fz*(x*z) + noise; x, z ~ N(0,1)."""
rng = np.random.default_rng(seed)
x = rng.normal(size=n)
z = rng.normal(size=n)
y = a + b_f * x + b_m * z + b_fz * (x * z) + rng.normal(0.0, 0.5, n)
return pd.DataFrame({"y": y, "x": x, "z": z})
syn = me_dgp(seed=0)
mres = ex.analyze_marginal_effects_plot(syn, dv="y", focal="x", moderator="z")
coef = {str(k): float(v) for k, v in mres.model.coef().items()}
b_f_hat, b_fz_hat = coef["x"], coef["x:z"]
grid = mres.df["z"].to_numpy()
me_closed_form = b_f_hat + b_fz_hat * grid # ME(z) by hand
flip_recovered = -b_f_hat / b_fz_hat # zero-crossing
check = pd.DataFrame({
"quantity": ["b_focal", "b_interaction", "sign-flip z*"],
"true": [B_F, B_FZ, -B_F / B_FZ],
"recovered": [b_f_hat, b_fz_hat, flip_recovered],
})
check["abs_error"] = (check["recovered"] - check["true"]).abs()
check.round(4)| quantity | true | recovered | abs_error | |
|---|---|---|---|---|
| 0 | b_focal | 0.50 | 0.5027 | 0.0027 |
| 1 | b_interaction | 0.40 | 0.3907 | 0.0093 |
| 2 | sign-flip z* | -1.25 | -1.2868 | 0.0368 |
# Coefficients recovered to within sampling error.
assert abs(b_f_hat - B_F) < 0.05
assert abs(b_fz_hat - B_FZ) < 0.05
# The plotted me grid IS b_focal + b_interaction*z, exactly (pure algebra, no estimation error).
np.testing.assert_allclose(mres.df["me"].to_numpy(), me_closed_form, atol=1e-9)
# The recovered sign-flip matches -b_focal/b_interaction (up to sampling error).
assert abs(flip_recovered - (-B_F / B_FZ)) < 0.1
# And the AME sits near b_focal, since mean z ~ 0.
assert abs(mres.ame - B_F) < 0.06
print("✅ me grid = b_focal + b_interaction*z; coefficients and sign-flip recovered")✅ me grid = b_focal + b_interaction*z; coefficients and sign-flip recovered
Why the band widens away from the centre
The delta-method variance is quadratic in the moderator, so the standard error is U-shaped — smallest near the middle of the data, largest at the edges:
narrow = mres.df["se"].idxmin()
print("narrowest band near z =", round(float(mres.df["z"].iloc[narrow]), 3),
"-> se =", round(float(mres.df["se"].iloc[narrow]), 4))
print("widest at the edges :", round(float(mres.df["se"].iloc[[0, -1]].max()), 4))
assert mres.df["se"].iloc[[0, -1]].max() > mres.df["se"].iloc[narrow]
print("✅ the band is narrowest in the centre and widens toward the edges")narrowest band near z = -0.014 -> se = 0.0093
widest at the edges : 0.0317
✅ the band is narrowest in the centre and widens toward the edges
4. The income–inequality gradient across trade openness (kuznets)
Back to real data. The marginal effect of development on regional inequality is positive at low trade openness and turns negative at high openness — the sign change the figure shows:
res.figprint(res.interpret())The marginal association of **log_gdp_pc** with the outcome is not constant — it depends on **trade_share** (their interaction).
Across the observed range it moves from 0.0254 at trade_share = 0.198 to -0.0765 at trade_share = 1.42.
The association switches sign near trade_share = 0.502: log_gdp_pc relates to the outcome in opposite directions below and above that point.
Averaged over the sample, the marginal association is -0.00904 (statistically significant at the 1% level).
_These are associations, not causal effects. A causal reading needs a research design — see `explain('correlation_vs_causation')`._
The interpret() text states the average effect (≈ -0.009, distinguishable from zero) and the sign-flip point itself. Read the whole band, not the single average: this is an association conditional on the model, not a causal effect.
See also
- The
analyze_marginal_effects_plotreference page — the full argument and return documentation. ex.explain("marginal_effects")— the concept explainer (alsores.explain()).- The bundled
load_kuznetsdataset and itsload_kuznets_data_defdictionary.
ex.explain("marginal_effects")Marginal effects & interactions
What it is. In a model with an interaction term (focal * moderator), the slope of the focal regressor is not one number: it is b_focal + b_interaction * moderator, a line traced across the moderator’s range. The marginal effect is that partial derivative, and its confidence band (via the delta method) widens away from the centre of the data.
When to use it. Whenever a model includes an interaction and you want to read how the focal regressor’s association with the outcome changes as the moderator varies — and where, if anywhere, that association is distinguishable from zero.
Watch out for. - The marginal effect is only meaningful over the moderator’s observed range; extrapolating beyond it is unsupported. - Significance varies along the moderator — a marginal effect can be significant at some values and not others, so read the whole band, not one point. - Interactions describe associations; a causal reading still needs a research design.
See also: ols, fwl, correlation_vs_causation
References: Brambor, Clark & Golder (2006); Wooldridge, Introductory Econometrics, ch. 6