Multinomial logit¶
Download the script: multinomial.py
import polars as pl
from logit_stream import etable, mlogit_stream
from logit_stream.simulate import RACES, simulate_people
people = simulate_people(200_000, seed=1)
X = "age10 + female + log_income + owner + urban"
fit = mlogit_stream(f"race ~ {X}", people, levels=RACES, vcov="hetero",
cluster=["puma", "state"])
Parameterization¶
The coefficients of each non-base level are its log-odds against the base
level, named "<level>:<covariate>" in one flat vector, equation by equation:
Black:Intercept, Black:age10, …, then Hispanic:Intercept, … This is
statsmodels’ MNLogit and Stata’s mlogit parameterization, and the
coefficients and model-based standard errors match statsmodels’ to rounding.
coef_matrix() shows them as a table, a column per non-base level:
┌─────────────┬───────────┬───────────┬───────────┬───────────┬─────────────┐
│ Coefficient ┆ Black ┆ Hispanic ┆ Asian ┆ AIAN ┆ Multiracial │
╞═════════════╪═══════════╪═══════════╪═══════════╪═══════════╪═════════════╡
│ Intercept ┆ 2.204253 ┆ 1.982456 ┆ -4.930454 ┆ 0.811849 ┆ -2.574906 │
│ age10 ┆ -0.096926 ┆ -0.238137 ┆ -0.052053 ┆ -0.026215 ┆ -0.294412 │
│ female ┆ 0.044251 ┆ 0.007279 ┆ 0.026936 ┆ 0.00196 ┆ 0.037175 │
│ log_income ┆ -0.351537 ┆ -0.304331 ┆ 0.23807 ┆ -0.448976 ┆ -0.031082 │
│ owner ┆ -0.505478 ┆ -0.269263 ┆ -0.096139 ┆ 0.116991 ┆ -0.334384 │
│ urban ┆ 0.858253 ┆ 0.579504 ┆ 0.972178 ┆ -0.84106 ┆ 0.311796 │
└─────────────┴───────────┴───────────┴───────────┴───────────┴─────────────┘
The urban coefficients are close to those simulate_people draws from
(truth_people(): 0.9, 0.6, 1.0, -0.8, 0.3); they are not exactly the
truth’s because the simulation adds PUMA effects that this model omits.
scikit-learn’s LogisticRegression uses the symmetric parameterization (a
coefficient vector for every level); without a penalty the differences of its
coefficients from the base level’s are these. With a penalty the two
parameterizations penalize different things, so the penalized fits differ
(see l2).
The outcome can be strings, a categorical or Enum, or integers. Its levels
are its distinct values in the estimation sample, sorted (an Enum by its
categories), unless levels= gives them; the first is the base. A level of
levels= that does not occur is an error, as is an observed value not among
them.
Standard errors¶
vcov= takes "iid" (the inverse of the information matrix), "hetero"
(the sandwich, times N / (N - 1)) or {"CRV1": var} (clustered, times
G / (G - 1)) – Stata’s small-sample conventions for maximum-likelihood
estimators. statsmodels’ cov_type="HC0" and "cluster" (with
use_correction=False) are the same matrices without those factors.
cluster= computes further clusterings at fit time, for with_vcov:
fit.with_vcov({"CRV1": "state"}).tidy()
┌──────────────────┬──────────┬──────────┬───────────┬────────────┐
│ coefficient ┆ iid ┆ hetero ┆ CRV1 puma ┆ CRV1 state │
╞══════════════════╪══════════╪══════════╪═══════════╪════════════╡
│ Black:Intercept ┆ 0.081302 ┆ 0.081387 ┆ 0.151528 ┆ 0.161009 │
│ Black:age10 ┆ 0.004644 ┆ 0.004638 ┆ 0.004493 ┆ 0.004303 │
│ Black:female ┆ 0.012923 ┆ 0.012924 ┆ 0.011748 ┆ 0.012406 │
│ Black:log_income ┆ 0.00774 ┆ 0.007737 ┆ 0.014042 ┆ 0.014054 │
│ Black:owner ┆ 0.020758 ┆ 0.020735 ┆ 0.021796 ┆ 0.019453 │
│ Black:urban ┆ 0.013743 ┆ 0.013731 ┆ 0.029272 ┆ 0.029564 │
└──────────────────┴──────────┴──────────┴───────────┴────────────┘
The PUMA effects the model omits are shared within PUMAs, so clustering doubles the standard errors of the intercept, income and urban coefficients, which vary mostly between PUMAs. The iid and robust variances each cost nothing beyond the fit and one more read of the data respectively; the clustered ones keep a cluster x parameter array of score sums in memory.
Multi-way clustering, {"CRV1": "state+age"}, is inclusion-exclusion
over the one-way clusterings state, age and state^age (each computed in
the same read), every term scaled by G / (G - 1) with the smaller one-way
G, and df G - 1: hdfe-stream’s and pyfixest’s convention. It can have
negative variances, which a warning reports (clustering by a variable with
two values, such as urban, is a quick way to get them).
The cluster jackknife, {"CRV3": "state"}, is (G - 1) / G times the sum
over clusters of d_g d_g’, d_g the change in the estimates from dropping
cluster g (MacKinnon, Nielsen and Webb’s CV3). For a likelihood the exact
d_g would take G refits, so it is one Newton step from the estimates,
-(H - H_g)^-1 s_g, from each cluster’s score and information: one more read,
and G x P^2 numbers in memory. With 12 clusters of 200 rows the standard
errors were within 2% of the refitted jackknife’s (tested). One-way, for
the multinomial and ordered logit without fixed effects or random
intercepts. With 50 states, the three differ little:
┌──────────────────┬────────────┬────────────────┬────────────┐
│ coefficient ┆ CRV1 state ┆ CRV1 state+age ┆ CRV3 state │
╞══════════════════╪════════════╪════════════════╪════════════╡
│ Black:Intercept ┆ 0.161009 ┆ 0.162748 ┆ 0.165879 │
│ Black:age10 ┆ 0.004303 ┆ 0.003041 ┆ 0.004313 │
│ Black:female ┆ 0.012406 ┆ 0.012039 ┆ 0.012438 │
│ ...
Weights¶
weights="w" multiplies each row’s log-likelihood by w. With
weights_type="fweights" each row counts as w observations (n_obs is
their sum, and the fit is exactly that of the data with each row repeated);
with the default "aweights" the weights change the estimates but not the
count of observations, and are rescaled to average 1 (Stata’s convention for
maximum likelihood), so that the iid vcov and the log-likelihood do not
depend on their scale. Survey weights are analytic weights with a robust or
clustered vcov – the same standard errors as Stata’s [pweight].
Frequency weights make a table of counts as good as the rows it counts: with only categorical covariates, the 200,000 people collapse to 48 rows,
counts = (people.with_columns(n=pl.lit(1)).group_by("race", "female", "urban", "owner")
.agg(pl.col("n").sum()))
fw = mlogit_stream("race ~ female + urban + owner", counts, levels=RACES, weights="n",
weights_type="fweights")
frequency weights: 48 rows, n_obs 200,000; largest difference from the row-level fit 1.0e-15 (se 1.3e-16)
Several models and a table¶
pyfixest’s multiple-estimation syntax works (csw(), sw(), several
outcomes), and returns a LogitMulti; etable makes a regression table of
results, through pyfixest’s maketables:
multi = mlogit_stream("race ~ csw(age10 + female, log_income, owner + urban)", people,
levels=RACES)
etable(list(multi.all_fitted_models.values()), type="md", keep=["Black:"])
| | ('race', '(1)') | ('race', '(2)') | ('race', '(3)') |
|:-------------------------------|:------------------|:------------------|:------------------|
| ('coef', 'Black × Intercept') | -0.779*** | 2.451*** | 2.204*** |
| | (0.024) | (0.080) | (0.081) |
| ('coef', 'Black × age10') | -0.142*** | -0.125*** | -0.097*** |
| | (0.004) | (0.004) | (0.005) |
| ...
Penalized fits¶
l2= subtracts a ridge penalty l2/2 * sum(beta^2) from the log-likelihood,
on every coefficient but the intercepts. It is the cure for separated
coefficients (see below), and with two levels it is scikit-learn’s
LogisticRegression(C=1/l2) exactly. The reported standard errors are then
those of the penalized fit (the inverse of the penalized information), which
understate the uncertainty of a biased estimator; use them with care.
l2=100 moves Black:urban from 0.858 to 0.840 here. For a binary
outcome, or a multinomial one, firth=True is the other cure (see binary
outcomes).
Collinearity and separation¶
A covariate that is a linear combination of others (an indicator of a level
that never occurs, a variable that duplicates another) is found from the
first step’s information matrix, dropped from every equation with a warning,
and listed in collin_vars, as pyfixest does:
dup = people.with_columns(urban2=2 * pl.col("urban"))
mlogit_stream(f"race ~ {X} + urban2", dup, levels=RACES).collin_vars # ['urban2']
A covariate that separates the outcome – every row with some level of an
indicator has the same outcome, or never has some outcome – has no finite
estimate. Newton’s method then converges linearly instead of quadratically,
with the estimate drifting toward infinity; the fit warns when a coefficient
grows implausibly large with an exploding standard error, or when it took
more than 15 steps. Merge the level, drop the covariate, or fit with l2=
or firth=True.
Options¶
option |
default |
|
|---|---|---|
|
|
the Newton decrement at which to stop (see how it works) |
|
|
the most Newton steps |
|
|
where the blocks live between steps ( |
|
|
the blocks’ precision on disk ( |
|
|
the size of a block |
|
|
the most memory a chunk’s working arrays take |
|
|
|
|
|
|
The full script: examples/multinomial.py
"""The multinomial logit: parameterization, standard errors, weights,
penalized fits, collinearity and separation.
python examples/multinomial.py
"""
import polars as pl
from logit_stream import etable, mlogit_stream
from logit_stream.simulate import RACES, simulate_people, truth_people
people = simulate_people(200_000, seed=1)
X = "age10 + female + log_income + owner + urban"
# ---- coefficients: "<level>:<covariate>", each level against the base
# (the first of levels=); the formula's covariates enter every equation
fit = mlogit_stream(f"race ~ {X}", people, levels=RACES, vcov="hetero",
cluster=["puma", "state"], verbose=False)
print(fit.tidy().head(7))
print(fit.coef_matrix())
# against the coefficients simulate_people draws from (it adds PUMA effects,
# which the model omits, so these are not exactly the truth's)
truth = truth_people()["race"]
print({k: (round(fit.coef()[k], 3), v) for k, v in truth.items() if k.endswith("urban")})
# ---- standard errors: one fit computes the robust vcov and the clusterings
# asked for in cluster=; with_vcov switches between them
ses = pl.DataFrame({
"coefficient": fit.coefnames,
"iid": fit.with_vcov("iid").se,
"hetero": fit.se,
"CRV1 puma": fit.with_vcov({"CRV1": "puma"}).se,
"CRV1 state": fit.with_vcov({"CRV1": "state"}).se,
})
print(ses.filter(pl.col("coefficient").str.starts_with("Black:")))
# multi-way clustering (inclusion-exclusion) and the cluster jackknife
two_way = mlogit_stream(f"race ~ {X}", people, levels=RACES,
vcov={"CRV1": "state+age"}, verbose=False)
jack = mlogit_stream(f"race ~ {X}", people, levels=RACES, vcov={"CRV3": "state"},
verbose=False)
print(pl.DataFrame({"coefficient": fit.coefnames[:6],
"CRV1 state": fit.with_vcov({"CRV1": "state"}).se[:6],
"CRV1 state+age": two_way.se[:6], "CRV3 state": jack.se[:6]}))
# ---- weights: analytic (rescaled to average 1) or frequency
w = mlogit_stream(f"race ~ {X}", people, levels=RACES, weights="weight",
vcov="hetero", verbose=False)
print(f"analytic weights: n_obs {w.n_obs:,.0f}, Black:urban {w.coef()['Black:urban']:.4f} "
f"(se {w.se[w.coefnames.index('Black:urban')]:.4f})")
counts = (people.with_columns(n=pl.lit(1)).group_by("race", "female", "urban", "owner")
.agg(pl.col("n").sum()))
fw = mlogit_stream("race ~ female + urban + owner", counts, levels=RACES, weights="n",
weights_type="fweights", verbose=False)
rows = mlogit_stream("race ~ female + urban + owner", people, levels=RACES, verbose=False)
print(f"frequency weights: {counts.height} rows, n_obs {fw.n_obs:,.0f}; largest "
f"difference from the row-level fit {abs(fw.beta - rows.beta).max():.1e} "
f"(se {abs(fw.se - rows.se).max():.1e})")
# ---- several models in one call, and a regression table
multi = mlogit_stream("race ~ csw(age10 + female, log_income, owner + urban)", people,
levels=RACES, verbose=False)
etable(list(multi.all_fitted_models.values()), type="md", keep=["Black:"]) # prints
# ---- a ridge penalty, and collinear covariates
ridge = mlogit_stream(f"race ~ {X}", people, levels=RACES, l2=100.0, verbose=False)
print("ridge vs ML, Black:urban:", round(ridge.coef()["Black:urban"], 4),
round(fit.coef()["Black:urban"], 4))
dup = people.with_columns(urban2=2 * pl.col("urban"))
col = mlogit_stream(f"race ~ {X} + urban2", dup, levels=RACES, verbose=False)
print("collinear, dropped:", col.collin_vars)