Skip to main content

Estimated marginal means for Python — a native port of R's emmeans.

Project description

pymmeans

Estimated marginal means (EMMs) for Python — a native implementation of R's emmeans package, with no R dependency.

Status: Beta (v0.2.0). API stable across the OLS / GLM / MixedLM / GEE / Cox / Beta surface; 84/100 strict parity with R emmeans (88/100 if partially-supported items count — see docs/r_parity_matrix.md) validated against tests/r_reference/ CSVs at atol=1e-4 (and tighter — see vs-r.md). 313 unit tests on the public surface (pytest), 54% line coverage. Minor API polish still possible; the numerical surface is frozen.

Install

pip install pymmeans              # from PyPI
pip install "pymmeans[plot]"      # add matplotlib for plot() / emmip()
pip install "pymmeans[tutorial]"  # add pysofra + jupyter for the showcase notebook

For local development:

git clone https://github.com/jturner-uofl/pymmeans.git
cd pymmeans
uv venv && uv pip install -e ".[dev,plot]"

Quickstart

Fit a model, then ask for marginal means, pairwise comparisons, and a plot:

import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
from pymmeans import emmeans, pairs, plot

rng = np.random.default_rng(0)
df = pd.DataFrame({
    "fertilizer": np.tile(np.repeat(["lo", "med", "hi"], 30), 2),
    "sunlight":   np.repeat(["shade", "sun"], 90),
})
df["growth"] = (
    df["fertilizer"].map({"lo": 0.5, "med": 1.2, "hi": 1.8})
    + (df["sunlight"] == "sun") * 0.4
    + rng.normal(0, 0.3, 180)
)
model = smf.ols("growth ~ fertilizer * sunlight", data=df).fit()

print(emmeans(model, "fertilizer"))
# Output (with the seed above):
#   fertilizer    emmean       SE     df  lower_cl  upper_cl
# 0         hi   2.0071   0.0376  174.0    1.9328    2.0813
# 1         lo   0.6811   0.0376  174.0    0.6069    0.7554
# 2        med   1.4399   0.0376  174.0    1.3657    1.5142

print(pairs(emmeans(model, "fertilizer")))
#   contrast  estimate      SE     df  t_ratio  p_value
# 0  hi - lo    1.3259  0.0532  174.0  24.9230      0.0
# 1 hi - med    0.5671  0.0532  174.0  10.6596      0.0
# 2 lo - med   -0.7588  0.0532  174.0 -14.2633      0.0
# (p_value=0.0 is pandas' default display of underflowed p < ~1e-15;
#  use `summary(pairs(...))` or `as_r_frame(...)` for "<.0001" format.)

ax = plot(emmeans(model, "fertilizer", by="sunlight"))

Interaction plot via emmip:

Interaction plot example

Showcase notebook

A full walkthrough of every analytical surface — EMMs, contrasts, multiplicity adjustments, mixed-model Kenward-Roger / Satterthwaite df, parametric-bootstrap LRT, and the ML adapter — is at examples/pymmeans_showcase.ipynb with a self-contained HTML render at examples/pymmeans_showcase.html.

What's in v0.1

Feature Notes
emmeans(model, specs, by=, at=, level=, type=) OLS and GLM (Binomial / Poisson / Gamma). type="response" back-transforms via the inverse link with delta-method SEs.
pairs(emm, adjust=) Tukey (default), Bonferroni, Holm, Šidák, Dunnett / dunnettx / mvt, Scheffé, BH/fdr, BY, Hochberg, Hommel, none. Per-by-group families.
contrast(emm, method=, ref=) Default eff per R. Methods: eff, del.eff, pairwise, revpairwise, tukey, trt.vs.ctrl/1/k, poly (R emmeans::poly.emmc integer-scaled), consec, mean_chg, identity, helmert. Also accepts custom coefficient dicts/matrices. Default adjustments match R per method (eff/del.eff → fdr; consec/mean_chg → mvt; trt.vs.ctrl → dunnettx).
cld(emm) Compact letter display (Piepho 2004 / multcompView).
pwpp(emm) / pwpm(emm) Pairwise-p-value plot (Lenth-style) + matrix display.
summary(emm, infer=, side=, null=, delta=) R summary.emmGrid parity: toggle CI / test columns, one-sided CIs, non-zero null, TOST equivalence.
confint(emm) / test(emm) / update(emm, level=) R confint / test / update.emmGrid.
as_r_frame(emm) Return DataFrame with R-dot column names (lower.CL, t.ratio, p.value).
with emm_options(level=, adjust=) R emm_options() context manager (ContextVar-backed).
lsmeans / lsm / lstrends / lsmip / lsm_options / get_lsm_option R lsmeans package aliases — the deprecated predecessor of emmeans (still on CRAN as a transitional front-end). pymmeans covers the common script-level aliases plus lsmeans(ref_grid(...), "factor") workflows. See docs/r_parity_matrix.md for the per-function table.
emtrends(model, specs, var=) Slopes of a numeric covariate at each EMM grid point (link or response scale).
bootstrap_ci(emm, n_samples=, kind=) Parametric percentile CIs (default; samples β ~ N(β̂, V̂)) OR true non-parametric case bootstrap (kind="case"). Streaming P² percentiles (method="streaming") for constant-memory parametric bootstrap. Accepts ContrastResult, MLEMMResult, and EmmList inputs.
permutation_test(contrast) Label-shuffle p-values with Phipson-Smyth correction. Robust to mis-specified residual distributions.
effect_size(contrast, measure=) Cohen's d / Hedges' g (default) plus R-style effect_size SE/CI. measure="odds_ratio" / "risk_ratio" / "hazard_ratio" for binomial-logit / log-link / Cox PH contrasts.
eta_squared(model, alternative=) Per-term partial η² / Hays' ω² / Cohen's f with noncentral-F CIs. Matches R emmeans::joint_tests exactly and R effectsize::eta_squared for balanced designs.
joint_tests(model) Type III joint Wald F (or χ²) tests for every non-intercept term.
pairs(emm, simple=, max_contrasts=) Guard against the multi-factor-pairwise explosion footgun, plus simple= per-factor decomposition (R parity). contrast(simple=, combine=) for the same on non-pairwise methods.
ml_emmeans(info, specs, by=, at=) + ml_pairs + ml_contrast(method=) Marginal means for any ML model with .predict() (sklearn, xgboost, lightgbm, torch). Population-average prediction surface (g-computation).
apply_satterthwaite(emm) Replace df=∞ with Satterthwaite df for MixedLM fits using cov_re / re_formula= random effects and vc_formula= variance components (crossed / nested designs). Same coverage applies to apply_kenward_roger.
apply_kenward_roger(emm) KR-inflated vcov + KR df. Same cov_re / re_formula= / vc_formula= coverage as apply_satterthwaite. Cross-validated against pbkrtest::KRmodcomp; KR SE matches R's published vc_formula= output to 3 decimals.
krmodcomp / satmodcomp / pbmodcomp / kenward_roger_vcov / get_kr / ddf_lb The six headline pbkrtest functions ported to Python.
regrid_response(emm, bias_adjust=) LHS-transformation back-transform (np.log(y), np.sqrt(y), np.log1p(y), ...). Optional Jensen σ²/2 correction.
weights="proportional" / "outer" Weighted averaging over non-target factors (training-data marginals). Default is "equal" (uniform).
plot(emm), emmip(model, x=, by=) Forest and interaction plots (matplotlib).
statsmodels.MixedLM support EMMs and contrasts on fixed effects.
linearmodels panel / IV support Via from_linearmodels(result, data=df); explicit 1 + intercept required. PanelOLS strips EntityEffects / TimeEffects tokens. Formula-based IV2SLS / IVGMM / IVLIML work by stripping the [endog ~ instruments] block and reusing the IV-corrected result.params / result.cov.
Formula expressions C(col), np.log(x), etc. on the RHS are handled via the analytic path.
Estimability checks Rank-deficient designs are detected; non-estimable EMM / contrast rows surface as NaN with a clear warning.
Adapter protocol Plug in custom frameworks (PyMC, TSA, ...) via register_adapter(MyAdapter).
ML-model adapter (beyond R emmeans parity) from_predict(predict_fn, data, factors=, numerics=) brings marginal means to any model with .predict() (sklearn / xgboost / lightgbm / torch). Bootstrap CIs via bootstrap_ci(kind="case"). The closest R analogue is the marginaleffects package; pymmeans brings the same population-average prediction workflow to Python natively alongside its linear-model EMM machinery.

Beyond R parity — ML adapter example

import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from pymmeans import from_predict, ml_emmeans, ml_pairs, bootstrap_ci

# 0) Toy training data: 3 treatments × 2 sites + 2 numeric covariates
rng = np.random.default_rng(0)
df = pd.DataFrame({
    "treatment": np.repeat(["A", "B", "C"], 50),
    "site":      np.tile(np.repeat(["north", "south"], 25), 3),
    "age":       rng.uniform(20, 70, 150),
    "dose":      rng.uniform(0.0, 1.0, 150),
})
df["y"] = (
    df["treatment"].map({"A": 0.8, "B": 1.2, "C": 1.7})
    + 0.01 * df["age"]
    + 0.5 * df["dose"]
    + rng.normal(0, 0.1, 150)
)

# Pin a stable feature schema so sub-grid predictions always
# present the same columns sklearn saw at fit time.
_FEATURE_COLS = pd.get_dummies(
    df[["treatment", "site", "age", "dose"]],
).columns.tolist()

def featurize(d):
    return (
        pd.get_dummies(d[["treatment", "site", "age", "dose"]])
        .reindex(columns=_FEATURE_COLS, fill_value=0)
    )

X_train, y_train = featurize(df), df["y"]

# 1) Train any sklearn-style model
rf = RandomForestRegressor(random_state=0).fit(X_train, y_train)

# 2) Wrap as a pymmeans target via the predict callable
info = from_predict(
    predict_fn=lambda d: rf.predict(featurize(d)),
    data=df,
    factors=["treatment", "site"],
    numerics=["age", "dose"],
    refit_fn=lambda sample: (lambda fitted: lambda d: fitted.predict(featurize(d)))(
        RandomForestRegressor(random_state=0)
        .fit(featurize(sample), sample["y"])
    ),
)

# 3) Marginal means via prediction-surface averaging (g-computation)
em = ml_emmeans(info, "treatment")
#   treatment  emmean
# 0         A   1.531
# 1         B   1.939
# 2         C   2.464

# 4) Pairwise contrasts
ml_pairs(em)

# 5) Case-bootstrap CIs (refits on each resample for proper variance)
em_with_ci = bootstrap_ci(em, n_samples=500, kind="case", seed=0)
#   treatment  emmean     SE  lower_cl  upper_cl
# 0         A   1.531  0.025     1.483     1.582
# 1         B   1.939  0.022     1.899     1.983
# 2         C   2.464  0.028     2.402     2.509

R emmeans is restricted to models with a tractable β + V representation. pymmeans's prediction-surface averaging extends the marginal-effects workflow to any ML model with a .predict() method, sharing the same pairs / contrast / bootstrap_ci / effect_size / summary machinery as the linear-model path. The closest R-ecosystem analogue is marginaleffects, which also supports ML models; pymmeans brings the same population-average prediction workflow to Python without leaving the EMM toolbox.

Validation

313 public-surface unit tests pass (1 skipped — arviz optional). 18 R-emmeans reference fits are cross-validated to atol=1e-4 (warpbreaks, pigs, ToothGrowth, InsectSprays, neuralgia binomial GLM response-scale, plus the broader reference suite). See tests/test_vs_r.py, tests/r_reference/, and docs/r_parity_matrix.md for the feature-level parity inventory.

Performance vs R

See docs/PERFORMANCE_REPORT.md for full numbers. Highlights:

Scenario R emmeans pymmeans v0.1
emmeans on n=1K OLS 0.023 s 0.002 s (~11.5× faster)
emmeans on n=500K OLS 0.178 s 0.023 s (~7.7× faster)
GitHub issue #282 (46M-row grid) refuses / OOM 0.021 s (only pymmeans completes)
Pairwise k=20 (Tukey) 0.016 s 0.018 s (≈ 1×)
Pairwise k=50 (Tukey) 0.060 s 0.101 s (1.7× slower)
Pairwise k=100 (Tukey) 0.561 s 0.416 s (1.3× faster)
Pairwise k=200 (Tukey) 9.704 s 1.961 s (4.9× faster)

pymmeans wins on the EMM side via analytic marginalization (computing L_marg as a Kronecker product of marginal factor codings, no grid materialization). Tukey uses a numerically correct hybrid of generalized Gauss-Laguerre (df < 400) and adaptive Gauss-Legendre (df ≥ 400, where SciPy's roots_genlaguerre overflows) — beating R at large k where Tukey is most painful, slightly slower at moderate k.

Documentation

A mkdocs-material site builds from docs/ (run mkdocs serve after pip install -e ".[docs]").

License

GPL-3.0-or-later, matching the R emmeans package.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pymmeans-0.2.1.tar.gz (1.1 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pymmeans-0.2.1-py3-none-any.whl (363.2 kB view details)

Uploaded Python 3

File details

Details for the file pymmeans-0.2.1.tar.gz.

File metadata

  • Download URL: pymmeans-0.2.1.tar.gz
  • Upload date:
  • Size: 1.1 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.15

File hashes

Hashes for pymmeans-0.2.1.tar.gz
Algorithm Hash digest
SHA256 8647ee4f6d0df1cf1968141ddfe56eacf25b8b2952cca3753178cc8b1e709169
MD5 c8c2ad5191b28e9e8d86655db5d04b33
BLAKE2b-256 0fc6d993b9d9eca8b0f77475ae920fd269a36f119e1a74660d6e7b1bebda20c1

See more details on using hashes here.

File details

Details for the file pymmeans-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: pymmeans-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 363.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.15

File hashes

Hashes for pymmeans-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 ec5fea2c999355a811fac53139766b75134430ee9771524d5b7ef986d6590a29
MD5 e1942624932d7857a8557cbdc476134a
BLAKE2b-256 b847ab23a7c4f52f5eb0d4110c801e2cc34d1b7cc9f4cd73afa0bbf76e37dae8

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page