pymmeans — the showcase¶

The missing post-estimation layer for Python.

Estimated marginal means (EMMs), pairwise contrasts with the full multiplicity-adjustment menu (Tukey, exact Dunnett, Šidák, Bonferroni, Holm, FDR, mvt), small-sample mixed-model inference (Kenward-Roger, Satterthwaite, parametric-bootstrap LRT), and prediction-surface averaging for any model with a .predict() method — under one R-emmeans-compatible API.

This showcase drives every analytical surface in pymmeans through one coherent question: how should we summarise a fitted model so the reader gets a publication-ready table?

Prerequisites: this notebook uses the tutorial (for pysofra table prettification) and parallel (for joblib-powered parametric bootstrap) extras. Install everything via:

pip install "pymmeans[plot,parallel,tutorial]"

Contents

  • Part I — Linear-model EMMs on warpbreaks
  • Part II — Multiplicity adjustments
  • Part III — Generalised linear models: logistic regression
  • Part IV — Mixed models: Kenward-Roger + Satterthwaite
  • Part V — Parametric-bootstrap LRT (the pbkrtest port)
  • Part VI — ML adapter: prediction-surface averaging
  • Part VII — Cross-validation receipts
  • Part VIII — Where next
In [1]:
import warnings
warnings.simplefilter("ignore")

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import statsmodels.formula.api as smf
import statsmodels.regression.mixed_linear_model as mlm
from statsmodels.datasets import get_rdataset

import pysofra as ps
import pymmeans
from pymmeans import (
    emmeans, pairs, cld, plot,
    apply_satterthwaite, apply_kenward_roger,
    krmodcomp, satmodcomp, pbmodcomp,
    from_predict, ml_emmeans, ml_pairs, bootstrap_ci,
)

rng = np.random.default_rng(20260523)

print(f"pymmeans {pymmeans.__version__}  /  pysofra {ps.__version__ if hasattr(ps, '__version__') else 'alpha'}")
pymmeans 0.1.5  /  pysofra 0.1.0a2

Part I — Linear-model EMMs on warpbreaks¶

Question: what is the predicted break count at every cell of a 2 × 3 factorial design — and which tension levels differ from each other?

The warpbreaks dataset is the canonical worked example in the R emmeans literature: counts of warp breaks in fabric, by wool type (A or B) and tension level (L, M, H). We fit the same OLS model R emmeans uses, then walk through marginal means, pairwise contrasts, and the compact letter display in turn.

In [2]:
wb = get_rdataset("warpbreaks").data
ps.tbl_summary(wb, by="wool", variables=["breaks", "tension"])
Out[2]:
CharacteristicA
N = 27
B
N = 27
breaks31.04 (15.85)25.26 (9.30)
tension
H9 (33.3%)9 (33.3%)
L9 (33.3%)9 (33.3%)
M9 (33.3%)9 (33.3%)
Mean (SD) for continuous variables.
n (%) for categorical variables.
In [3]:
fit_wb = smf.ols("breaks ~ wool * tension", data=wb).fit()
ps.tbl_regression(fit_wb, digits=2).set_caption("Warpbreaks OLS coefficients")
Out[3]:
Warpbreaks OLS coefficients
Variableβ95% CIp-value
wool[T.B]-5.78-16.15, 4.590.268
tension[T.L]20.009.63, 30.37<0.001
tension[T.M]-0.56-10.93, 9.810.915
wool[T.B]:tension[T.L]-10.56-25.22, 4.110.154
wool[T.B]:tension[T.M]10.56-4.11, 25.220.154
CI = 95% confidence interval.
Model: RegressionResultsWrapper (OLS).

I.a — Estimated marginal means¶

emmeans(fit, "tension", by="wool") returns the predicted breaks at every cell of the tension × wool grid, averaged in the EMM sense (equal-weight over the levels of factors not named in the call).

In [4]:
em_wb = emmeans(fit_wb, "tension", by="wool")
(em_wb.frame.style
    .format({"emmean": "{:.2f}", "SE": "{:.2f}", "df": "{:.0f}",
             "lower_cl": "{:.2f}", "upper_cl": "{:.2f}"})
    .hide(axis="index")
    .set_caption("EMM of breaks by tension within wool"))
Out[4]:
EMM of breaks by tension within wool
tension wool emmean SE df lower_cl upper_cl
H A 24.56 3.65 48 17.22 31.89
L A 44.56 3.65 48 37.22 51.89
M A 24.00 3.65 48 16.67 31.33
H B 18.78 3.65 48 11.45 26.11
L B 28.22 3.65 48 20.89 35.55
M B 28.78 3.65 48 21.45 36.11

I.b — Pairwise contrasts with Tukey HSD¶

Within each wool level, which tension levels differ? pairs(em) produces all $k(k-1)/2$ pairwise differences with the requested multiplicity adjustment.

In [5]:
pr_wb = pairs(em_wb, adjust="tukey")
(pr_wb.frame.style
    .format({"estimate": "{:.2f}", "SE": "{:.2f}", "df": "{:.0f}",
             "t_ratio": "{:.2f}", "p_value": "{:.4f}"})
    .hide(axis="index")
    .set_caption("Pairwise Tukey contrasts of tension within wool"))
Out[5]:
Pairwise Tukey contrasts of tension within wool
contrast wool estimate SE df t_ratio p_value
H - L A -20.00 5.16 48 -3.88 0.0009
H - M A 0.56 5.16 48 0.11 0.9936
L - M A 20.56 5.16 48 3.99 0.0007
H - L B -9.44 5.16 48 -1.83 0.1704
H - M B -10.00 5.16 48 -1.94 0.1389
L - M B -0.56 5.16 48 -0.11 0.9936

I.c — Compact letter display¶

When reporting in a paper, the compact letter display (CLD) is often more readable than the full pairwise matrix: levels that share a letter are not significantly different at the chosen $\alpha$.

In [6]:
cld_wb = cld(emmeans(fit_wb, "tension"))
(cld_wb.style
    .format({"emmean": "{:.2f}", "SE": "{:.2f}", "df": "{:.0f}",
             "lower_cl": "{:.2f}", "upper_cl": "{:.2f}"})
    .hide(axis="index")
    .set_caption("Compact letter display: tension means"))
Out[6]:
Compact letter display: tension means
tension emmean SE df lower_cl upper_cl .group
H 21.67 2.58 48 16.48 26.85 a
M 26.39 2.58 48 21.20 31.57 a
L 36.39 2.58 48 31.20 41.57 b
In [7]:
ax = plot(em_wb)
ax.set_title("warpbreaks: EMMs of breaks by tension and wool")
plt.show()
No description has been provided for this image

Part II — Multiplicity adjustments¶

Question: with multiple pairwise comparisons in one table, which adjustments are honest — and how do their p-values differ?

pymmeans implements the full R emmeans adjustment menu: Tukey HSD, exact Dunnett (via the multivariate-$t$ cumulative distribution function), Šidák, Bonferroni, Holm, false-discovery rate, and the generic mvt integral. The point estimates and SEs are invariant to the choice of adjustment; only the p-values and CI half-widths shift.

In [8]:
em_tension = emmeans(fit_wb, "tension")
rows = []
for adj in ["tukey", "sidak", "bonferroni", "holm"]:
    pr = pairs(em_tension, adjust=adj).frame.assign(adjust=adj)
    rows.append(pr[["adjust", "contrast", "estimate", "SE", "t_ratio", "p_value"]])
comparison = pd.concat(rows, ignore_index=True)
(comparison.style
    .format({"estimate": "{:.2f}", "SE": "{:.2f}",
             "t_ratio": "{:.2f}", "p_value": "{:.4f}"})
    .hide(axis="index")
    .set_caption("Same contrasts, four multiplicity adjustments"))
Out[8]:
Same contrasts, four multiplicity adjustments
adjust contrast estimate SE t_ratio p_value
tukey H - L -14.72 3.65 -4.04 0.0006
tukey H - M -4.72 3.65 -1.29 0.4049
tukey L - M 10.00 3.65 2.74 0.0229
sidak H - L -14.72 3.65 -4.04 0.0006
sidak H - M -4.72 3.65 -1.29 0.4910
sidak L - M 10.00 3.65 2.74 0.0254
bonferroni H - L -14.72 3.65 -4.04 0.0006
bonferroni H - M -4.72 3.65 -1.29 0.6046
bonferroni L - M 10.00 3.65 2.74 0.0257
holm H - L -14.72 3.65 -4.04 0.0006
holm H - M -4.72 3.65 -1.29 0.2015
holm L - M 10.00 3.65 2.74 0.0171

Tukey is the standard for pairwise comparisons of $k > 2$ levels (it integrates the studentised-range distribution exactly). Šidák is mildly more conservative; Bonferroni more conservative still. Holm is a step-down procedure that adapts to the number of rejections. All four agree on the qualitative conclusion on warpbreaks: low tension differs from medium and high, while medium and high do not differ from each other.

Part III — Generalised linear models: logistic regression¶

Question: how do EMMs work when the link function is not the identity, and how do we back-transform to the response scale?

Logistic regression returns log-odds; the reader usually wants probabilities. EMMs default to the link scale (log-odds for the logit link); passing type="response" applies the inverse link and propagates the SE via the delta method. Bias-adjusted back-transforms are available for log-link models — see Section 2.5 of the manuscript.

In [9]:
n = 300
df_log = pd.DataFrame({
    "treatment": rng.choice(["placebo", "low", "high"], n),
    "age":       rng.uniform(30, 75, n),
})
logits = (
    df_log["treatment"].map({"placebo": -1.0, "low": -0.2, "high": 0.8})
    + 0.02 * (df_log["age"] - 50)
)
df_log["event"] = (rng.uniform(0, 1, n) < 1 / (1 + np.exp(-logits))).astype(int)

fit_log = smf.logit("event ~ treatment + age", data=df_log).fit(disp=False)
ps.tbl_regression(fit_log, exponentiate=True, digits=2).set_caption("Logistic fit (odds ratios)")
Out[9]:
Logistic fit (odds ratios)
VariableOR95% CIp-value
treatment[T.low]0.470.26, 0.840.010
treatment[T.placebo]0.150.08, 0.28<0.001
age1.011.00, 1.030.140
OR = exponentiated coefficient; CI = 95% confidence interval.
Model: BinaryResultsWrapper (Logit).
In [10]:
em_link = emmeans(fit_log, "treatment")
em_resp = emmeans(fit_log, "treatment", type="response")

stacked = pd.concat([
    em_link.frame.assign(scale="link (log-odds)"),
    em_resp.frame.assign(scale="response (probability)"),
])[["scale", "treatment", "emmean", "SE", "lower_cl", "upper_cl"]]
(stacked.style
    .format({"emmean": "{:.3f}", "SE": "{:.3f}",
             "lower_cl": "{:.3f}", "upper_cl": "{:.3f}"})
    .hide(axis="index")
    .set_caption("Logit EMMs on both scales"))
Out[10]:
Logit EMMs on both scales
scale treatment emmean SE lower_cl upper_cl
link (log-odds) high 0.900 0.206 0.497 1.303
link (log-odds) low 0.142 0.212 -0.274 0.558
link (log-odds) placebo -0.991 0.233 -1.447 -0.535
response (probability) high 0.711 0.042 0.622 0.786
response (probability) low 0.535 0.053 0.432 0.636
response (probability) placebo 0.271 0.046 0.191 0.369

Part IV — Mixed models: Kenward-Roger + Satterthwaite¶

Question: how do you get honest small-sample degrees of freedom for a fixed effect in a linear mixed model?

Linear mixed models with random effects have no exact small-sample distribution for the fixed-effect $t$-statistic. R lmerTest (Satterthwaite df) and R pbkrtest (Kenward-Roger df + adjusted vcov) close that gap; pymmeans ports both, with floating-point parity against the R reference (Part VII). The canonical sleepstudy example uses random slopes per subject across days of sleep deprivation.

In [11]:
sleep = get_rdataset("sleepstudy", "lme4").data
fit_rs = mlm.MixedLM.from_formula(
    "Reaction ~ Days", groups="Subject", re_formula="~ Days", data=sleep,
).fit(reml=True)

em_wald = emmeans(fit_rs, "Days", at={"Days": [0, 5, 9]})
em_satt = apply_satterthwaite(em_wald)
em_kr   = apply_kenward_roger(em_wald)

side = pd.concat([
    em_wald.frame.assign(method="Wald (df = ∞)"),
    em_satt.frame.assign(method="Satterthwaite"),
    em_kr.frame.assign(method="Kenward-Roger"),
])[["method", "Days", "emmean", "SE", "df", "lower_cl", "upper_cl"]]
(side.style
    .format({"emmean": "{:.2f}", "SE": "{:.2f}",
             "df": "{:.1f}", "lower_cl": "{:.2f}", "upper_cl": "{:.2f}"})
    .hide(axis="index")
    .set_caption("sleepstudy: Wald vs Satterthwaite vs Kenward-Roger"))
Out[11]:
sleepstudy: Wald vs Satterthwaite vs Kenward-Roger
method Days emmean SE df lower_cl upper_cl
Wald (df = ∞) 0.000000 251.41 6.82 inf 238.03 264.78
Wald (df = ∞) 5.000000 303.74 9.58 inf 284.96 322.52
Wald (df = ∞) 9.000000 345.61 14.63 inf 316.94 374.28
Satterthwaite 0.000000 251.41 6.82 17.0 237.01 265.80
Satterthwaite 5.000000 303.74 9.58 17.0 283.53 323.96
Satterthwaite 9.000000 345.61 14.63 17.0 314.75 376.47
Kenward-Roger 0.000000 251.41 6.82 17.0 237.01 265.80
Kenward-Roger 5.000000 303.74 9.58 17.0 283.53 323.96
Kenward-Roger 9.000000 345.61 14.63 17.0 314.75 376.47

On the well-determined sleepstudy fit ($n = 180$), Satterthwaite and Kenward-Roger agree to two decimals on both SE and df. The practical change from Wald is the move from $z_{0.975} = 1.96$ to $t_{0.975,\,17} \approx 2.11$ — a 7.6 % CI widening. At smaller sample sizes the K-R adjustment also inflates the vcov itself, picking up the additional small-sample bias correction documented in Kenward & Roger (1997).

Part V — Parametric-bootstrap LRT (the pbkrtest port)¶

Question: when is the asymptotic F or $\chi^2$ approximation suspect — and what is the alternative?

For small samples, boundary fits, or models with complex random-effects structure, the asymptotic $\chi^2$ distribution for the likelihood-ratio statistic is known to be conservative. R pbkrtest::PBmodcomp and pymmeans.pbmodcomp replace the asymptotic distribution with a parametric bootstrap from the fitted null model: simulate $N$ replicates under $H_0$, refit both models on each, compare the observed LRT to the empirical null. krmodcomp (Kenward-Roger F) and satmodcomp (Satterthwaite F) are the fast asymptotic counterparts.

In [12]:
large_ri = mlm.MixedLM.from_formula(
    "Reaction ~ Days", groups="Subject", data=sleep,
).fit(reml=True)
small_ri = mlm.MixedLM.from_formula(
    "Reaction ~ 1", groups="Subject", data=sleep,
).fit(reml=True)

kr  = krmodcomp(large_ri, small_ri)
sat = satmodcomp(large_ri, small_ri)
pb  = pbmodcomp(large_ri, small_ri, n_sim=200, seed=20260523, silent=True, n_jobs=-1)

ftest_table = pd.DataFrame([
    ["krmodcomp (Kenward-Roger F)",  f"{kr.F:.3f}",  f"{kr.ndf}",  f"{kr.ddf:.2f}",  f"{kr.p_value:.2e}"],
    ["satmodcomp (Satterthwaite F)", f"{sat.F:.3f}", f"{sat.ndf}", f"{sat.ddf:.2f}", f"{sat.p_value:.2e}"],
    ["pbmodcomp (bootstrap LRT)",    f"{pb.lrt_obs:.3f}", f"{pb.df}", "—", f"{pb.p_value:.4f}  (boot)"],
], columns=["test", "statistic", "ndf", "ddf", "p-value"])
(ftest_table.style
    .hide(axis="index")
    .set_caption("Reaction ~ Days vs Reaction ~ 1  (random intercept only)"))
Out[12]:
Reaction ~ Days vs Reaction ~ 1 (random intercept only)
test statistic ndf ddf p-value
krmodcomp (Kenward-Roger F) 169.401 1 161.00 6.41e-27
satmodcomp (Satterthwaite F) 169.401 1 161.00 6.41e-27
pbmodcomp (bootstrap LRT) 116.462 1 — 0.0051 (boot)

All three tests reject the intercept-only null overwhelmingly. The parametric bootstrap is more conservative than the F approximation at small $n$; on this well-determined dataset the asymptotic agreement is essentially perfect. The added value of the bootstrap is the cases where it disagrees — boundary fits, very small $n$, or designs where the F denominator df is itself near zero.

Part VI — ML adapter: prediction-surface averaging¶

Question: what does "predicted yield at fertilizer = high" mean when the model is a random forest, and how do we put a confidence interval on it?

Tree-ensemble models — random forests, gradient-boosted trees, neural networks — fit nonlinear interactions natively but have no analytic standard error on their predictions. pymmeans.from_predict wraps any callable predict_fn(data) -> ndarray and produces EMM-style population-average summaries; bootstrap_ci(em, kind="case", refit_fn=...) quantifies uncertainty by refitting the model on each row-resampled bootstrap draw.

The dataset below is synthetic but constructed with a crop-specific quadratic response to rainfall and a quadratic temperature optimum — exactly the kind of pattern an additive linear baseline cannot capture without manually adding interaction and polynomial terms.

In [13]:
from sklearn.ensemble import RandomForestRegressor

n = 200
crops = rng.choice(["wheat", "corn", "rice", "barley"], n)
fertilizer = rng.choice(["low", "med", "high"], n)
rainfall = rng.uniform(200.0, 1200.0, n)
temperature = rng.uniform(12.0, 32.0, n)
crop_rain_opt  = {"wheat": 500, "corn": 800, "rice": 1000, "barley": 600}
crop_intercept = {"wheat": 2500, "corn": 3200, "rice": 4500, "barley": 2800}
fert_effect    = {"low": 0.0, "med": 600.0, "high": 1100.0}
yield_kg = np.array([
    crop_intercept[c] + fert_effect[f]
    - 1.8 * (r - crop_rain_opt[c]) ** 2 / 1000.0
    - 35 * (t - 22.0) ** 2 / 10.0
    for c, f, r, t in zip(crops, fertilizer, rainfall, temperature)
]) + rng.normal(0, 120, n)
df_yield = pd.DataFrame({
    "fertilizer":  pd.Categorical(fertilizer, ["low", "med", "high"]),
    "crop":        pd.Categorical(crops),
    "rainfall":    rainfall,
    "temperature": temperature,
    "yield_kg_ha": yield_kg,
})
ps.tbl_summary(
    df_yield, by="fertilizer",
    variables=["crop", "rainfall", "temperature", "yield_kg_ha"],
).set_caption("Synthetic agronomic dataset")
Out[13]:
Synthetic agronomic dataset
Characteristiclow
N = 70
med
N = 61
high
N = 69
crop
barley17 (24.3%)13 (21.3%)12 (17.4%)
corn19 (27.1%)21 (34.4%)21 (30.4%)
rice17 (24.3%)11 (18.0%)14 (20.3%)
wheat17 (24.3%)16 (26.2%)22 (31.9%)
rainfall687.25 (274.96)742.85 (271.58)744.95 (260.14)
temperature21.64 (5.51)20.91 (5.84)22.37 (5.87)
yield_kg_ha2951.52 (769.64)3502.89 (771.44)3970.80 (746.70)
Mean (SD) for continuous variables.
n (%) for categorical variables.
In [14]:
fit_ols = smf.ols(
    "yield_kg_ha ~ C(fertilizer) + C(crop) + rainfall + temperature",
    data=df_yield,
).fit()
em_ols = emmeans(fit_ols, "fertilizer")

feature_cols = pd.get_dummies(
    df_yield[["fertilizer", "crop", "rainfall", "temperature"]],
).columns.tolist()
def featurize(d):
    return pd.get_dummies(
        d[["fertilizer", "crop", "rainfall", "temperature"]]
    ).reindex(columns=feature_cols, fill_value=0)

rf = RandomForestRegressor(
    n_estimators=100, random_state=0, n_jobs=-1, oob_score=True,
).fit(featurize(df_yield), df_yield["yield_kg_ha"])
print(f"RF train R^2 = {rf.score(featurize(df_yield), df_yield['yield_kg_ha']):.3f}")
print(f"RF OOB R^2   = {rf.oob_score_:.3f}")

info = from_predict(
    predict_fn=lambda d: rf.predict(featurize(d)),
    data=df_yield, factors=["fertilizer", "crop"],
    numerics=["rainfall", "temperature"], response="yield_kg_ha",
    refit_fn=lambda sample: (
        (lambda fitted: lambda d: fitted.predict(featurize(d)))(
            RandomForestRegressor(
                n_estimators=100, random_state=0, n_jobs=-1,
            ).fit(featurize(sample), sample["yield_kg_ha"])
        )
    ),
)
em_rf = bootstrap_ci(ml_emmeans(info, "fertilizer"),
                     kind="case", n_samples=100, seed=20260523)
RF train R^2 = 0.993
RF OOB R^2   = 0.949
In [15]:
side_by_side = pd.merge(
    em_ols.frame.rename(columns={
        "emmean": "OLS_emm", "lower_cl": "OLS_lo", "upper_cl": "OLS_hi",
    })[["C(fertilizer)", "OLS_emm", "OLS_lo", "OLS_hi"]]
    .rename(columns={"C(fertilizer)": "fertilizer"}),
    em_rf.frame.rename(columns={
        "emmean": "RF_emm", "lower_cl": "RF_lo", "upper_cl": "RF_hi",
    })[["fertilizer", "RF_emm", "RF_lo", "RF_hi"]],
    on="fertilizer",
)
(side_by_side.style
    .format({c: "{:.1f}" for c in side_by_side.columns if c != "fertilizer"})
    .hide(axis="index")
    .set_caption("Fertilizer EMMs: misspecified OLS vs random forest (case-bootstrap CIs)"))
Out[15]:
Fertilizer EMMs: misspecified OLS vs random forest (case-bootstrap CIs)
fertilizer OLS_emm OLS_lo OLS_hi RF_emm RF_lo RF_hi
low 2948.0 2887.8 3008.3 2900.6 2788.9 3030.3
med 3580.9 3515.8 3646.0 3567.3 3456.8 3725.0
high 4041.8 3980.4 4103.2 3988.5 3850.2 4071.6

The random forest captures the crop × rainfall interaction the additive OLS cannot, which both shifts the marginal estimates (RF runs 2.7 – 3.8 % higher than OLS at every fertilizer level) and widens the CIs (RF intervals are 1.7 – 2.1 × wider than the OLS Wald intervals). The case-resampling bootstrap is doing the work no parametric SE could: refitting the forest on each resample and reading off the percentile interval of the resulting EMM distribution.

Part VII — Cross-validation receipts¶

The math agrees with the R gold standard. pymmeans is validated against the canonical R post-estimation stack on a committed reference suite of CSVs (see tests/r_reference/):

R reference Test domain Tolerance
emmeans (Lenth, 2016) — five canonical fits warpbreaks, pigs, ToothGrowth, InsectSprays, neuralgia atol < 1e-4
emmeans patsy basis terms bs(x, df=3), cr(x, df=4), spline × factor interactions atol < 1e-4
lme4 + lmerTest — Satterthwaite df sleepstudy random-slopes fit atol < 1e-3 (finite-difference Hessian noise)
pbkrtest — six headline functions vcovAdj, getKR, Lb_ddf, KRmodcomp, SATmodcomp, PBmodcomp atol < 1e-4 (random-intercept SE has a documented ~2.6% residual)
marginaleffects (Arel-Bundock+, 2024) avg_predictions, comparisons on GLM atol < 1e-3
survey (Lumley, 2004) design-corrected EMM vcov (Gaussian survey) atol < 1e-7

Every reference value is regenerable from the committed R scripts in tests/r_reference/*.R under R 4.6 + the cited package versions. The public-surface validation suite runs in approximately one minute via pytest.

Part VIII — Where next¶

We've driven every analytical surface in pymmeans through one coherent worked example. The same machinery extends to:

  • Survey-weighted designs: from_survey accepts a SurveyDesign (Lumley-style) and produces design-corrected EMM vcov at floating-point parity with R survey::svyglm + R emmeans.
  • Bayesian posteriors: from_pymc and posterior_emmeans integrate with arviz and PyMC; posterior EMMs become a derived quantity of the trace.
  • Cumulative-link ordinal and multinomial logit: OrderedModel and MNLogit from statsmodels dispatch through ordinal_emmeans and multinom_emmeans.
  • Trends (derivatives at focal points): emtrends returns the slope of the regression surface in a focal numeric, at user-specified levels of the by-factors.
  • Custom L matrices: the low-level contrast() entry point accepts any row matrix; cld() and pwpm() handle the post-hoc letter display and pairwise p-value matrix.
  • Beyond statsmodels: from_linearmodels for panel-data fits; from_predict for any callable predictor — PyTorch, XGBoost, LightGBM, or custom black boxes.

For the R-parity function-by-function map between pymmeans and R emmeans / lsmeans / pbkrtest, see docs/r_parity_matrix.md.