Predictions, comparisons and slopes after a model¶
sp.predictions, sp.comparisons, sp.slopes and their averaged
versions, plus sp.hypotheses, use the vocabulary of R's
marginaleffects package. A user (or an agent) who knows that package
gets the same quantities, under the same names and the same defaults.
sp.margins stays as the Stata-style entry point. Both are built on one
design rebuilt from the fitted formula, so interactions and terms such as
I(x**2) are differentiated through.
They work after sp.regress, sp.logit, sp.probit, sp.cloglog,
sp.poisson, sp.nbreg and sp.glm.
import statspai as sp
fit = sp.logit("y ~ treat * x + z + C(region)", data=df)
sp.avg_predictions(fit, by="region") # average Pr(y = 1) by region
sp.avg_comparisons(fit, "treat") # model-implied ATE (g-formula)
sp.avg_slopes(fit) # average marginal effects
sp.slopes(fit, "x", slope="eyex") # unit-level elasticities
sp.avg_comparisons(fit, {"x": "iqr"}) # third against first quartile
What each function returns¶
Each call returns a DataFrame with estimate, std_error, statistic,
p_value, conf_low and conf_high. Unit-level calls keep the data's
columns, one row per row. Averaged calls keep only term, contrast and
any by= columns.
| Function | Quantity |
|---|---|
predictions / avg_predictions |
Fitted value on the response (or type="link") scale |
comparisons / avg_comparisons |
Prediction at a high value minus (or over) a low value |
slopes / avg_slopes |
Derivative of the prediction; eyex, eydx, dyex elasticities |
hypotheses |
Any smooth function of coefficients or of a table's rows |
Which change is contrasted¶
- A factor (
C(g), strings, categories): every level against the first. - A numeric 0/1 variable: 1 against 0, in
slopesas well. - Another numeric variable: an increase of 1 from each row's own value.
variables={"x": 5}changes the step."sd"and"2sd"contrast mean + sd/2 against mean - sd/2 (and +/- sd) for every row, which is what marginaleffects computes although its label readsx + sd/2."iqr"is the third against the first quartile,"minmax"the maximum against the minimum, and(low, high)two fixed values.
comparison="ratio" and "lnratio" are per row. Once averaged (the
avg_ functions or by=), they become the ratio of the average
predictions ("ratioavg", "lnratioavg"), as in marginaleffects.
Defaults that differ from sp.margins¶
Averages are unweighted and inference uses the normal distribution,
which are marginaleffects' defaults. sp.margins follows Stata, using the
fit's weights and the t distribution after regress. Pass wts=True and
df="model" to get Stata's numbers. In that case sp.avg_slopes equals
sp.margins to rounding, and a test checks it.
Limited-dependent-variable models¶
After mlogit, ologit, oprobit, tobit, intreg, heckman
(method='ml'), heckprobit and ivprobit the prediction is not one index
through a link, so these functions refuse and point to sp.margins /
sp.margins_at, which follow Stata's margins there. predict= names
Stata's statistic and defaults to Stata's choice:
fit = sp.tobit(df, formula="y ~ x1 + x2 + C(g)", ll=0)
sp.margins(fit, data=df) # d xb / dx (Stata default)
sp.margins(fit, data=df, predict="e(0,.)") # d E(y | y > 0) / dx
sp.margins_at(fit, data=df, at={"x1": [0, 1]}, predict="ystar(0,.)")
ol = sp.ologit("y ~ x1 + x2", data=df)
sp.margins(ol, data=df) # one row per variable and outcome
sp.margins(ol, data=df, category=2) # Stata predict(outcome(2))
iv = sp.ivprobit(df, y="y", x=["x1"], endog="d", instruments=["z"])
sp.margins(iv, data=df, predict="pr") # average structural function
The ivprobit default is the linear prediction (as Stata 18); pr is
Stata's average structural function, which holds each row's reduced-form
error at its observed value, and pr fixedasf is Phi(x'b).
Testing functions of estimates¶
sp.hypotheses(fit, "b2 = b3") # coefficients by position
sp.hypotheses(fit, "x = 2 * z") # or by name
tab = sp.avg_comparisons(fit, "region")
sp.hypotheses(tab, "b1 - b2 = 0") # rows of a table
Each table carries the Jacobian of its estimates with respect to the
coefficients (tab.attrs["jacobian"], read it with np.asarray). That is
how a test of two rows uses their joint covariance; a subset of the rows
(tab.iloc[[0, 2]]) keeps the link to its own rows. The expression is
parsed with the same restricted grammar as sp.nlcom and is never run as
code.
A list of hypotheses gives one row each. joint= runs one Wald test of
several restrictions instead, linear or not (Stata testnl):
sp.hypotheses(fit, ["x = 0", "z = 0"]) # two rows
sp.hypotheses(fit, joint=["x", "z"]) # F on (2, n - k)
sp.hypotheses(fit, ["exp(x) = 2", "x * z = 0.1"], joint=True, joint_test="chisq")
With joint_test="f" (the default) the statistic is W / q on q and
the fit's residual degrees of freedom, also after a logit, as
marginaleffects reports it.
Fixed-effects models¶
After sp.feols / sp.fepois / sp.feglm with absorbed effects
(y ~ x | firm + year) the absorbed effects are held at their estimates
and enter the linear index like an offset, as marginaleffects does on a
fixest model. fit.fixef() returns them (normalised as fixest reports
them) and fit.predict(newdata=...) adds them; a level the fit never saw
predicts NaN with a warning.
A slope or a difference of a linear model does not depend on the effects
and keeps its standard error. A prediction, or any quantity of a Poisson
or logit model, does. After a Poisson fit (sp.fepois,
sp.feglm(family="poisson")) averages count the effects as parameters,
as Stata's margins after poisson y x i.g or ppmlhdfe ..., d does:
sp.avg_slopes / avg_predictions / avg_comparisons (and their by=
averages), sp.margins, sp.margins_at, sp.contrast and
sp.pwcompare report that standard error, with the fit's own small-sample
factor (iid, robust or one-way clustered fits). It equals Stata's once that
factor is accounted for (tests/test_margins_fe_delta.py). Unit-level
tables, method="mem", two-way clustering and absorbed logit / probit /
OLS predictive margins still need vcov=False, which returns the
estimates alone (marginaleffects refuses all of these on a fixest model).
For a logit or probit with group effects, Stata's margins after
logit y x i.g is the incidental-parameter estimator; fit C(g) terms if
that is the target.
Evidence¶
On a shared CSV, tests/reference_parity/test_marginaleffects_r_parity.py
compares every function with R marginaleffects 0.32.0 across lm,
logit, probit and Poisson fits with interactions, factors and squared
terms. Estimates agree to 1e-8 or better. Standard errors agree to 2e-5,
the precision of marginaleffects' numerical Jacobian; ours is analytic.
The R package is GPL-3 and is used only as a black box. Two reference
details matter when you compare by hand:
- R
glmtakes the covariance from the expected information. For a probit (a non-canonical link) this differs fromsp.probit, which like Stata uses the observed information (1.7% in standard errors here).sp.glm(..., information="expected")reproduces R. - At its default
epsilon = 1e-8, Rglmreports a covariance about 1e-5 away from the inverse information at its own estimates. The fixtures are generated withepsilon = 1e-14.