analyze_marginal_effects_plot

Run this page interactively in Google Colab — no install required:
Open In Colab

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).

import numpy as np
import pandas as pd

import expdpy as ex

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.fig

The 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.fig
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')`._

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_plot reference page — the full argument and return documentation.
  • ex.explain("marginal_effects") — the concept explainer (also res.explain()).
  • The bundled load_kuznets dataset and its load_kuznets_data_def dictionary.
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