GLM Model Classes¶
Direct GLM model access outside of Polars expressions.
All GLM classes (Logistic, Poisson, NegativeBinomial, Tweedie, Probit, Cloglog) accept a lambda_=0.0 kwarg for L2 (ridge) penalty applied inside the IRLS update. The sklearn-style LogisticRegression class exposes the same penalty via C = 1 / lambda_ and an explicit penalty choice.
Common Interface¶
from polars_statistics import Logistic, Poisson, NegativeBinomial, Tweedie, Probit, Cloglog
model = Logistic(with_intercept=True)
model.fit(X, y)
# Properties
model.coefficients # np.ndarray
model.intercept # float or None
model.deviance # float
model.null_deviance # float
model.aic # float
model.bic # float
model.model_info # dict — {"model_type": str, "family": str | None, "link": str}
# Predict
predictions = model.predict(X_new)
probs = model.predict_proba(X_new) # For classification models
Inference getters (every GLM class): std_errors, t_statistics (Wald z, normal
reference for Logistic/Probit/Cloglog/LogisticRegression; t on the residual df for
Poisson/NegativeBinomial/Tweedie/Gamma, matching R's summary.glm), p_values,
conf_interval_lower, conf_interval_upper (feature coefficients), plus
intercept_std_error, intercept_t_statistic, intercept_p_value, intercept_conf_interval
for the intercept term — read directly from upstream's GlmInferenceResult, never
recomputed locally. Every getter is None unless compute_inference=True (and the
intercept_* getters are additionally None without an intercept). confidence_level
(default 0.95) controls the width of every conf_interval_*/intercept_conf_interval.
R equivalent: summary.glm's coefficient table / confint.default.
compute_inference/confidence_level constructor kwargs: available on Logistic,
Poisson, Probit, Cloglog, NegativeBinomial, Tweedie, Gamma, LogisticRegression,
and PenalizedGLM (not GLMM/PSpline, which have no such kwarg).
compute_inference=False skips the inference computation entirely (every getter above
returns None), which is faster when only coefficients/predict are needed.
Probit/Cloglog/NegativeBinomial/Tweedie/Gamma gained these two kwargs in 0.8.0 (Logistic,
Poisson, and LogisticRegression already had them).
.augment(x, y, residual_type=None): Logistic, Poisson, Probit, Cloglog,
LogisticRegression, NegativeBinomial, Tweedie, and Gamma (not PenalizedGLM, GLMM,
or PSpline) expose R broom::augment-style per-observation diagnostics (fitted,
residual, std_residual, stud_residual, leverage, cooks_d, dffits, plus a trailing
resid_type key) as a dict of numpy arrays — see
ps.augment for the full
field semantics and R equivalence. The instance's own fitted parameters (link, theta,
var_power, etc.) are always used, so .augment() always describes exactly what that
instance was fit with.
model_info (0.8.0): every class on this page (Logistic, Poisson, Probit, Cloglog,
NegativeBinomial, Tweedie, Gamma, LogisticRegression, PenalizedGLM, GLMM, PSpline)
exposes model_info -> dict — {"model_type": str, "family": str | None, "link": str}. The
model_type distinguishes classes that share a family/link — e.g. Logistic/Probit/
Cloglog all report family="binomial" but model_type "binomial" with link "logit"/
"probit"/"cloglog" respectively, while LogisticRegression reports model_type="logistic"
(the separate sklearn-style solver). See Linear Model Classes for the
full cross-family model_type table.
Logistic¶
Logistic regression for binary classification.
from polars_statistics import Logistic
model = Logistic(
lambda_: float = 0.0, # L2 regularization
with_intercept: bool = True,
)
model.fit(X, y) # y: binary (0/1)
predictions = model.predict(X_new) # Class predictions
probabilities = model.predict_proba(X_new) # Probability estimates
LogisticRegression¶
Sklearn-style logistic regression. Distinct from Logistic: uses inverse-strength regularization C = 1 / lambda_ and an explicit penalty choice.
from polars_statistics import LogisticRegression
model = LogisticRegression(
penalty: str = "l2", # "l2" or "none"
C: float = 1.0, # Inverse of regularization strength
threshold: float = 0.5,
with_intercept: bool = True,
max_iter: int = 100,
tol: float = 1e-8,
compute_inference: bool = True,
confidence_level: float = 0.95,
)
model.fit(X, y) # y: binary (0/1)
# Methods
classes = model.predict(X_new) # 0/1 predictions
probs = model.predict_proba(X_new) # Probability estimates
scores = model.decision_function(X_new) # Linear scores (log-odds)
accuracy = model.score(X_new, y_new) # Mean accuracy
# Properties
model.coefficients # np.ndarray
model.intercept # float or None
model.n_iter # int — IRLS iterations until convergence
Poisson¶
Poisson regression for count data.
from polars_statistics import Poisson
model = Poisson(
lambda_: float = 0.0,
with_intercept: bool = True,
)
model.fit(X, y) # y: non-negative counts
NegativeBinomial¶
Negative Binomial regression for overdispersed count data.
from polars_statistics import NegativeBinomial
model = NegativeBinomial(
theta: float | None = None, # Dispersion; None = estimate
estimate_theta: bool = True,
lambda_: float = 0.0,
with_intercept: bool = True,
estimate_dispersion: bool = False,
compute_inference: bool = True,
confidence_level: float = 0.95,
)
model.fit(X, y)
# Additional properties
model.theta_estimated # Estimated dispersion parameter (theta)
model.dispersion # float — SE-scaling dispersion factor (see below)
compute_inference/confidence_level (0.8.0): see Common Interface above.
estimate_dispersion (default False): controls the dispersion factor used to
scale the coefficient standard errors — not the negative-binomial theta
(overdispersion) parameter, which is always estimated/used as usual via
theta/estimate_theta. With the default estimate_dispersion=False,
model.dispersion == 1.0 and std_errors match R summary.glm / MASS::glm.nb's
default (dispersion fixed at 1). With estimate_dispersion=True, dispersion is
the Pearson chi2/df estimate and std_errors are scaled by its square root —
validated against MASS::glm.nb 7.3.65 (dispersion 0.8951608253323096); the
coefficient point estimates are unaffected either way.
Behaviour change (anofox-regression 0.5.15 #49): before this release, standard
errors were always scaled by the Pearson dispersion when it exceeded 1 — now the
default (estimate_dispersion=False) always uses dispersion 1, matching R's own
default and requiring the explicit opt-in for the previous behaviour.
Also changed (0.5.14 #42): log_likelihood/aic now match R's glm/glm.nb
values (the full negative-binomial log-likelihood, not a Gaussian approximation);
aic counts theta as an estimated parameter only when estimate_theta=True.
Tweedie¶
Tweedie GLM for flexible variance structures.
from polars_statistics import Tweedie
model = Tweedie(
var_power: float = 1.5,
link_power: float | None = None, # None = canonical link (1 - var_power)
lambda_: float = 0.0,
with_intercept: bool = True,
compute_inference: bool = True,
confidence_level: float = 0.95,
)
model.fit(X, y)
model.std_errors # np.ndarray | None — Pearson-dispersion SEs
model.p_values # np.ndarray | None
model.aic # float | None — see note below
model.bic # float | None
model.intercept_std_error, model.intercept_t_statistic # float | None
model.intercept_p_value, model.intercept_conf_interval # float | None, tuple[float, float] | None
link_power/compute_inference/confidence_level (0.8.0): link_power=None (default)
uses the canonical link 1 - var_power; pass an explicit value (e.g. 0.0 for the log link)
to override it — this is the same link_power kwarg ps.augment(model="tweedie", ...) uses
to reproduce a specific fit. compute_inference/confidence_level — see
Common Interface above. Tweedie also exposes .gamma(),
.gaussian(), .inverse_gaussian() builder methods that return a new Tweedie fixed to
var_power 2.0/0.0/3.0 respectively, with the same with_intercept/max_iter/tol/
lambda_/compute_inference/confidence_level kwargs.
aic/bic are NaN for non-closed-form var_power. A closed-form Tweedie
density (hence a well-defined likelihood and aic/bic) exists only for
var_power in {0.0, 1.0, 2.0, 3.0} (Gaussian, Poisson, Gamma, Inverse Gaussian).
The default var_power=1.5 (compound Poisson-Gamma) has no closed-form
density, so aic/bic are NaN by design — not a bug. std_errors/p_values
use the Pearson-dispersion estimate and are populated regardless of var_power.
p_values since 0.8.0 (anofox-regression 0.5.20 #66): every Wald p_value
(Tweedie and Gamma included) is a t-statistic on the residual df, exactly as R's
summary.glm, computed via the survival function so it never underflows to
exactly 0. Previously Tweedie/Gamma p_values could be None/NaN.
Probit¶
Probit regression for binary classification.
from polars_statistics import Probit
model = Probit(
lambda_: float = 0.0,
with_intercept: bool = True,
max_iter: int = 100,
tol: float = 1e-6,
compute_inference: bool = True,
confidence_level: float = 0.95,
)
model.fit(X, y) # y: binary (0/1)
compute_inference/confidence_level (0.8.0): see
Common Interface above — std_errors/t_statistics/p_values/
conf_interval_lower/conf_interval_upper/intercept_std_error/intercept_t_statistic/
intercept_p_value/intercept_conf_interval are populated by default.
Standard error precision since 0.8.0 (anofox-regression 0.5.20 #66): the probit
link now uses statrs's erfc/erfc_inv instead of an Abramowitz-Stegun
approximation, so std_errors (and the derived t_statistics/p_values) match
R's summary.glm(binomial(link = "probit")) to full precision — previously off in
the 5th digit.
Cloglog¶
Complementary log-log regression for binary classification.
from polars_statistics import Cloglog
model = Cloglog(
lambda_: float = 0.0,
with_intercept: bool = True,
max_iter: int = 100,
tol: float = 1e-6,
compute_inference: bool = True,
confidence_level: float = 0.95,
)
model.fit(X, y) # y: binary (0/1)
compute_inference/confidence_level (0.8.0): see Common Interface above.
Gamma¶
Gamma GLM (log link) for positive, right-skewed response data. Suitable for cost, duration, or concentration data where variance scales with the mean squared.
from polars_statistics import Gamma
model = Gamma(
lambda_: float = 0.0, # L2 regularization
with_intercept: bool = True,
compute_inference: bool = True,
confidence_level: float = 0.95,
)
model.fit(X, y) # y: strictly positive
# Properties
model.coefficients # np.ndarray
model.intercept # float or None
model.converged # bool
model.std_errors # np.ndarray | None — Pearson-dispersion SEs (matching R summary.glm)
model.p_values # np.ndarray | None
model.aic # float | None — always closed-form for Gamma (var_power is fixed at 2)
model.bic # float | None
model.intercept_std_error, model.intercept_t_statistic # float | None (0.8.0)
model.intercept_p_value, model.intercept_conf_interval # float | None, tuple[float, float] | None (0.8.0)
# Predict
predictions = model.predict(X_new) # Predicted means (exp(X @ coef + intercept))
eta = model.predict_eta(X_new) # Linear predictor (log scale)
Pearson-dispersion standard errors (anofox-regression 0.5.14 #43): std_errors
now match R summary.glm(family = Gamma)'s Pearson chi2/df dispersion estimate
(previously a different scaling). Unlike the generic Tweedie class, Gamma has
no var_power parameter — its variance function is fixed at the Gamma case
(var_power = 2), so aic/bic are always well-defined (never NaN).
Example:
import numpy as np
from polars_statistics import Gamma
X = np.column_stack([np.random.normal(0, 1, 200)])
y = np.exp(0.5 * X[:, 0] + np.random.normal(0, 0.3, 200))
model = Gamma()
model.fit(X, y)
print(f"AIC: {model.aic:.4f}, std_errors: {model.std_errors}")
PenalizedGLM¶
Family-generic penalized GLM (IRLS with explicit priors) — one engine shared by Poisson,
Binomial, Logistic, Negative Binomial, Gamma and Tweedie. Without priors and with
lambda_ = 0.0 this is a plain maximum-likelihood GLM matching R's glm / MASS::glm.nb;
with Gaussian priors it returns the posterior mode (as arm::bayesglm with fixed prior
scales). See Penalized GLM for the full families/links,
dispersion, priors, and covariance-type semantics.
from polars_statistics import PenalizedGLM
model = PenalizedGLM(
family="poisson", # "poisson", "binomial", "logistic", "negative_binomial", "gamma", "tweedie"
link=None, # defaults to the family's canonical link
theta=None, # Negative Binomial dispersion; None = estimated
power=None, # Tweedie variance power in [1, 2]; default 1.5
threshold=0.5, # Logistic classification threshold
lambda_=0.0, # Uniform L2 ridge penalty
estimate_dispersion=False, # Poisson-only quasipoisson scaling
priors=None, # per-coefficient priors (flat / normal / laplace)
vcov_type="laplace", # "laplace", "sandwich", "naive"
add_intercept=True,
max_iter=100,
tol=1e-8,
compute_inference=True,
confidence_level=0.95,
)
model.fit(x, y, offset=None)
predictions = model.predict(x_new, offset=None) # response scale
eta = model.predict_linear(x_new) # linear predictor
Fit statistics¶
model.coefficients # np.ndarray — NaN for columns dropped as constant/aliased
model.intercept # float | None
model.family # str — lower-case family name
model.link # str — lower-case link name
model.deviance # float
model.null_deviance # float
model.pseudo_r_squared # float — 1 - deviance / null_deviance
model.log_likelihood # float
model.aic # float
model.bic # float
model.dispersion # float — see Dispersion below
model.theta # float | None — Negative Binomial only
model.accuracy # float | None — Logistic only
model.iterations # int
model.converged # bool
model.n_observations # int — rows with a non-finite y/x/offset are dropped
Inference¶
model.std_errors, model.z_values, model.p_values # np.ndarray | None — feature coefficients
model.conf_interval_lower, model.conf_interval_upper # np.ndarray | None
model.intercept_std_error, model.intercept_z_value # float | None — read directly
model.intercept_p_value, model.intercept_conf_interval # from upstream's GlmInferenceResult,
# never recomputed
Every inference getter is None unless compute_inference=True (and the intercept_*
getters are additionally None without an intercept).
Covariance¶
model.vcov # np.ndarray | None — scaled so sqrt(diag(vcov)) == std_errors (like R's vcov(glm))
model.information # np.ndarray | None — upstream's unscaled penalized observed information
model.matrix_parameters # list[int | None] | None — None = intercept row/column, int = feature index
vcov is scaled by dispersion for every family except Negative Binomial (whose dispersion
getter reports theta, not a covariance scale, so no scaling is applied there).
Separation and fitted values¶
model.has_separation # bool — family="binomial"/"logistic" only; always False otherwise
model.separated_predictors # list[int] — 0-based feature indices involved
model.separation_warning # str | None
model.fitted_values # np.ndarray — fitted means over the rows used
model.valid_rows # list[int] — indices into the original rows that were used
Example¶
import numpy as np
from polars_statistics import PenalizedGLM
np.random.seed(0)
n = 80
x1 = np.random.normal(0, 1, n)
x2 = np.random.normal(0, 1, n)
mu = np.exp(0.3 + 0.5 * x1 - 0.2 * x2)
y = np.random.poisson(mu).astype(float)
X = np.column_stack([x1, x2])
model = PenalizedGLM(family="poisson", estimate_dispersion=True).fit(X, y)
print(model.coefficients, model.intercept)
print(model.dispersion, model.aic, model.bic)
print(model.matrix_parameters, model.vcov.shape)
Validated against R glm / MASS::glm.nb / negative.binomial(theta) on the
anofox-regression 0.5.22 R reference fixture.
See Also: Survival (AFT) · Penalized GLM expressions
GLMM¶
Generalized Linear Mixed Model — extends GLM with random effects for grouped/clustered data.
GLMM cannot be constructed directly; use one of the six family factory methods below, each
of which returns a new (unfitted) GLMM.
from polars_statistics import GLMM
model = GLMM.gaussian(
with_intercept: bool = True,
reml: bool = True, # REML vs ML; default differs per family — see below
max_iter: int = 100,
tol: float = 1e-8,
random_intercept: bool = True,
random_slopes: list[int] | None = None, # feature indices with a random slope
random_slopes_per_factor: dict | None = None, # per-factor random slopes (crossed fits)
theta_max: float = 1000.0, # upper bound for the variance-component search
)
model.fit(x, y, group, offset=None) # group: 1D array of group labels (int or str)
model.fit_crossed(x, y, groups, offset=None) # groups: list of 1D arrays, one per factor
predictions = model.predict_fixed(x, offset=None) # fixed-effects-only prediction
# Properties
model.coefficients # np.ndarray — fixed effects (alias: fixed_effects)
model.intercept # float or None
model.random_effects # dict — per-group random intercept estimates
model.n_observations # int
model.n_groups # int
model.model_info # dict — {"model_type": "glmm", "family": str, "link": str}
# Wald inference
model.z_values # np.ndarray — fixed_effects / std_errors
model.p_values # np.ndarray — 2 * (1 - Phi(|z|))
model.conf_int(level=0.95) # tuple[np.ndarray, np.ndarray] — (lower, upper)
Family factories: GLMM.gaussian(...) (reml=True default), GLMM.poisson(...),
GLMM.binomial(...), GLMM.gamma(...) (log link), GLMM.tweedie(power=1.5, ...) (log link),
GLMM.negative_binomial(nb_theta=None, ...) (log link; nb_theta=None estimates it, matching
glmer.nb) — all reml=False by default except gaussian. Each accepts the same
with_intercept/max_iter/tol/random_intercept/random_slopes/
random_slopes_per_factor/theta_max structural kwargs, validated by one shared internal
constructor (mutual exclusivity of random_slopes/random_slopes_per_factor, no-intercept-
no-slope, theta_max bounds — invalid combinations raise ValueError, never panic).
offset (0.8.0): fit/fit_crossed/predict_fixed all accept offset=None — a known
per-observation quantity added to the linear predictor on the link scale (coefficient fixed at
1), matching offset() in an lme4 formula for every family.
random_slopes_per_factor (0.8.0): gives each crossed factor (from fit_crossed) its own
independent random slopes, matching lme4's (x | a) + (1 | b) syntax. It is a list of
per-factor feature-index lists, positionally aligned with fit_crossed's groups argument —
e.g. random_slopes_per_factor=[[0], []] with groups=[factor_a, factor_b] gives factor_a
a random slope on feature 0 while factor_b keeps only a random intercept. A single-factor
fit has no "per factor" distinction, so the factor accessors below return None.
lme4-validated getters (0.8.0): nb_theta (vs. the common theta dispersion getter —
nb_theta is None for every non-negative-binomial family), random_cov (the
random-effects covariance matrix), random_effects_matrix (BLUPs, one row per group/factor
level), random_corr (correlation matrix derived from random_cov), n_random_effects,
n_factors (1 for a single-factor fit, more for fit_crossed), is_reml.
Per-factor accessors (0.8.0, crossed fits only): factor_random_cov(f),
factor_random_sd(f), factor_random_effects_matrix(f) — each takes a 0-based factor index
f and returns None for a single-factor fit or an out-of-range index. factors() lists
each factor's metadata as list[dict].
Wald inference matches lme4's standard inference (validated against lme4
2.0.1): z_values == fixed_effects / std_errors; conf_int(level)'s midpoint
equals fixed_effects and its half-width equals z_(1-alpha/2) * std_errors
(e.g. 1.959963984540054 * std_errors for the default level=0.95). Invalid
level values (0.0, 1.0, outside (0, 1), or NaN) give all-NaN bounds.
log_likelihood for non-Gaussian families (poisson, binomial) now includes
the saturated-model term, matching lme4's reported log-likelihood exactly.
Validated against lme4 2.0.1 on anofox-regression 0.5.22's
glmm_families_validation.rs fixture: negative_binomial/gamma/tweedie families
(glmer.nb, glmer(family=MASS::negative.binomial(theta)), glmer(Gamma(log)),
glmer(statmod::tweedie(power))), offset(), and random_slopes_per_factor ((x|a)+(1|b)).
Example — binomial family:
import numpy as np
from polars_statistics import GLMM
groups = np.repeat(np.arange(10), 20) # 10 groups, 20 obs each
X = np.random.normal(0, 1, (200, 2))
y = (X[:, 0] + np.random.normal(0, 0.5, 200) > 0).astype(float)
model = GLMM.binomial()
model.fit(X, y, groups)
Example — negative binomial with offset and crossed factors with per-factor slopes:
import numpy as np
from polars_statistics import GLMM
n = 200
X = np.random.normal(0, 1, (n, 1))
factor_a = np.repeat(np.arange(10), 20)
factor_b = np.tile(np.arange(4), 50)
offset = np.random.uniform(0.5, 2.0, n)
mu = offset * np.exp(0.3 + 0.5 * X[:, 0])
y = np.random.negative_binomial(5, 5 / (5 + mu)).astype(float)
model = GLMM.negative_binomial(random_slopes_per_factor=[[0], []])
model.fit_crossed(X, y, [factor_a, factor_b], offset=np.log(offset))
print(model.nb_theta, model.n_factors, model.factor_random_sd(0))
PSpline¶
Penalized spline (P-spline) regression — fits a smooth function of one variable using B-spline basis functions with a difference-based roughness penalty.
from polars_statistics import PSpline
model = PSpline(
n_knots: int = 20, # Number of interior knots
degree: int = 3, # B-spline degree (3 = cubic)
lambda_: float = 1.0, # Smoothing penalty
with_intercept: bool = True,
)
model.fit(x, y) # x, y: 1D numpy arrays
# Properties
model.coefficients # np.ndarray — B-spline coefficients
model.intercept # float or None
model.knots # np.ndarray — knot locations
model.r_squared # float
model.n_observations # int
# Predict
smooth = model.predict(x_new)
Example:
import numpy as np
from polars_statistics import PSpline
x = np.linspace(0, 2 * np.pi, 100)
y = np.sin(x) + np.random.normal(0, 0.1, 100)
model = PSpline(n_knots=15, lambda_=0.1)
model.fit(x, y)
smooth = model.predict(x)
Class Summary¶
| Class | Parameters |
|---|---|
Logistic |
lambda_, with_intercept |
LogisticRegression |
penalty, C, threshold, with_intercept, max_iter, tol, compute_inference, confidence_level |
Poisson |
lambda_, with_intercept |
NegativeBinomial |
theta, estimate_theta, lambda_, with_intercept |
Tweedie |
var_power, lambda_, with_intercept |
Probit |
lambda_, with_intercept |
Cloglog |
lambda_, with_intercept |
Gamma |
lambda_, with_intercept |
PenalizedGLM |
family, link, theta, power, threshold, lambda_, estimate_dispersion, priors, vcov_type, add_intercept, max_iter, tol, compute_inference, confidence_level |
GLMM |
family, link, with_intercept, max_iter, tol |
PSpline |
n_knots, degree, lambda_, with_intercept |