py-flexplot 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyflexplot/stats.py ADDED
@@ -0,0 +1,803 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ import statsmodels.formula.api as smf
4
+ from scipy import stats
5
+ from typing import Any, Dict, List, Optional
6
+
7
+
8
+ def _check_statsmodels_attrs(model, attrs):
9
+ """Raise ValueError if *model* is missing any of the listed attributes."""
10
+ missing = [a for a in attrs if not hasattr(model, a)]
11
+ if missing:
12
+ raise ValueError(
13
+ f"Model is missing required attributes for model_comparison: {missing}"
14
+ )
15
+
16
+
17
+ def model_comparison(model1, model2, return_pred_difference=False):
18
+ """
19
+ Statistically compares the fits of two statsmodels results.
20
+
21
+ Returns a tuple ``(DataFrame, p_value)`` where the DataFrame carries
22
+ per-model AIC, BIC, LogLik, R-squared, adjusted R-squared, and Bayes
23
+ factor (computed from BIC via the Kass & Raftery 1995 approximation).
24
+ Pass ``return_pred_difference=True`` for a third element: a pandas
25
+ Series of quantiles (0/25/50/75/100%) of the two models' in-sample
26
+ prediction differences — R's ``model.comparison()`` returns this as
27
+ its ``pred.difference`` component (v0.8.0+).
28
+
29
+ The Bayes factor is attached to the more likely model (BIC-wise):
30
+ the model with the lower BIC gets a BF ≥ 1 in its row, the other
31
+ model gets 1/BF. This mirrors R's ``flexplot::model.comparison()``
32
+ behavior.
33
+
34
+ Non-nested models (v0.8.0+): AIC / BIC / Bayes factor / R² are valid
35
+ for both nested and non-nested pairs; the likelihood-ratio p-value is
36
+ only defined for nested models. When the two models' predictor sets
37
+ are not a subset-superset pair, ``p_value`` is ``None`` (v0.7.x
38
+ raised ``ValueError`` in that case — behavior change, documented).
39
+
40
+ Parameters
41
+ ----------
42
+ model1, model2 : statsmodels regression results
43
+ The models to compare. Nested-ness is detected via the models'
44
+ ``exog_names`` (one set must contain the other).
45
+ return_pred_difference : bool, default False
46
+ When True, return ``(DataFrame, p_value, pred_difference)`` where
47
+ ``pred_difference`` is a Series of quantiles of prediction
48
+ differences, or None if predictions couldn't be aligned.
49
+ """
50
+ required = ("aic", "bic", "llf", "df_model")
51
+ _check_statsmodels_attrs(model1, required)
52
+ _check_statsmodels_attrs(model2, required)
53
+
54
+ # Bayes factor for model1 over model2 (Kass & Raftery 1995 approximation
55
+ # from BIC): BF_{1,2} = exp((BIC_2 - BIC_1) / 2). Values > 1 favor model1.
56
+ bf_raw = float(np.exp((model2.bic - model1.bic) / 2.0))
57
+
58
+ # Attach the larger BF to the model with the lower BIC. The convention
59
+ # here matches R's model_comparison_table(): the better model gets
60
+ # BF >= 1; the worse model gets 1/BF.
61
+ if model1.bic <= model2.bic:
62
+ bf_col = [bf_raw, 1.0 / bf_raw]
63
+ else:
64
+ bf_col = [1.0 / bf_raw, bf_raw]
65
+
66
+ res = pd.DataFrame(
67
+ {
68
+ "AIC": [model1.aic, model2.aic],
69
+ "BIC": [model1.bic, model2.bic],
70
+ "LogLik": [model1.llf, model2.llf],
71
+ },
72
+ index=["Model 1", "Model 2"],
73
+ )
74
+
75
+ # R-squared and adjusted R-squared columns when available (OLS / GLM).
76
+ extras = {}
77
+ if hasattr(model1, "rsquared") and hasattr(model2, "rsquared"):
78
+ extras["R.squared"] = [float(model1.rsquared), float(model2.rsquared)]
79
+ if hasattr(model1, "rsquared_adj") and hasattr(model2, "rsquared_adj"):
80
+ extras["Adj.R.squared"] = [
81
+ float(model1.rsquared_adj),
82
+ float(model2.rsquared_adj),
83
+ ]
84
+ extras["BayesFactor"] = bf_col
85
+ if extras:
86
+ res = pd.concat([res, pd.DataFrame(extras, index=res.index)], axis=1)
87
+
88
+ # Order so the larger (less constrained) model is subtracted from the
89
+ # smaller (more constrained) one, yielding a positive LR statistic with a
90
+ # positive df difference.
91
+ if model2.llf >= model1.llf:
92
+ lr_stat = 2 * (model2.llf - model1.llf)
93
+ df_diff = int(round(model2.df_model - model1.df_model))
94
+ else:
95
+ lr_stat = 2 * (model1.llf - model2.llf)
96
+ df_diff = int(round(model1.df_model - model2.df_model))
97
+
98
+ # Nesting detection (v0.8.0+): R's model.comparison() supports non-nested
99
+ # models (AIC/BIC/BF need no nesting); the LRT p-value is only defined
100
+ # when one model's predictors are a subset of the other's. When models
101
+ # are not nested (or the df difference is degenerate), we return
102
+ # p_value=None instead of raising (v0.7.x raised ValueError).
103
+ names1 = set(getattr(model1.model, "exog_names", None) or [])
104
+ names2 = set(getattr(model2.model, "exog_names", None) or [])
105
+ nested = bool(names1) and bool(names2) and (
106
+ names1.issubset(names2) or names2.issubset(names1)
107
+ )
108
+
109
+ if nested and df_diff > 0:
110
+ p_val = float(1 - stats.chi2.cdf(lr_stat, df_diff))
111
+ else:
112
+ p_val = None
113
+
114
+ # --- pred.difference (v0.8.0+, R-parity) ---
115
+ # R's model.comparison() returns list(statistics=..., pred.difference=...);
116
+ # the pred.difference component holds quantiles of the two models'
117
+ # in-sample prediction differences. Computed here via no-arg predict()
118
+ # (statsmodels returns in-sample fitted values), available on all
119
+ # RegressionResults. Non-fatal on failure (set to None).
120
+ pred_difference = None
121
+ try:
122
+ p1 = np.asarray(model1.predict(), dtype=float)
123
+ p2 = np.asarray(model2.predict(), dtype=float)
124
+ if p1.shape == p2.shape and p1.size > 0:
125
+ diff = pd.Series(p1 - p2)
126
+ pred_difference = diff.quantile([0.0, 0.25, 0.5, 0.75, 1.0])
127
+ except Exception:
128
+ pred_difference = None
129
+
130
+ if return_pred_difference:
131
+ return res, p_val, pred_difference
132
+ return res, p_val
133
+
134
+
135
+ def eta_squared(model, level: float = 0.95, typ: int = 3):
136
+ """Compute partial eta-squared (η²_p) per predictor in a fitted OLS model.
137
+
138
+ A port of R's ``sjstats::eta_sq()`` / ``fifer::eta_squared()``. For each
139
+ non-intercept term in the model, returns the partial eta-squared:
140
+
141
+ η²_p = (SS_effect / df_effect) / (SS_effect / df_effect + SS_resid / df_resid)
142
+ = (F * df1) / (F * df1 + df2)
143
+
144
+ where F is the per-term F-statistic, df1 is the term's df (1 for a
145
+ single coefficient, k for a categorical), and df2 is the residual df.
146
+
147
+ Partial eta-squared estimates the variance in y explained by each
148
+ predictor *after* controlling for all other predictors. It's bounded
149
+ in [0, 1] but can exceed R² when predictors are correlated (it's a
150
+ separate concept from semi-partial R² which is bounded above by R²).
151
+
152
+ Method (v0.7.5+):
153
+ - Use ``statsmodels.stats.anova.anova_lm(model, typ=typ)`` to get
154
+ type-I, II, or III sums of squares per term. Default ``typ=3``
155
+ matches R's ``car::Anova(..., type=3)`` semantics.
156
+ - For each term, compute η²_p from the per-term F.
157
+ - Compute CI via the same non-central-F inversion as
158
+ ``_r_squared_ci()`` applied to η²_p.
159
+
160
+ Parameters
161
+ ----------
162
+ model : statsmodels.regression.linear_model.RegressionResults
163
+ A fitted OLS model (or any model with ``.fvalue``, ``.f_pvalue``,
164
+ ``.df_model``, ``.df_resid``, ``.model.exog_names`` attributes).
165
+ level : float, default 0.95
166
+ Coverage probability for the per-predictor CI.
167
+ typ : int, default 3
168
+ Type of sums of squares. 1 = sequential, 2 = hierarchical,
169
+ 3 = marginal (R's default). Type III is the most common choice
170
+ for unbalanced designs and matches ``car::Anova(type=3)``.
171
+
172
+ Returns
173
+ -------
174
+ pandas.DataFrame
175
+ Indexed by predictor name (excluding intercept). Columns:
176
+ - ``eta_sq`` : partial eta-squared
177
+ - ``eta_sq_ci_low`` : CI lower bound (or ``None`` if degenerate)
178
+ - ``eta_sq_ci_high`` : CI upper bound (or ``None`` if degenerate)
179
+ - ``F`` : per-term F-statistic
180
+ - ``p_value`` : per-term p-value
181
+ - ``df`` : per-term degrees of freedom
182
+ """
183
+ if not hasattr(model, "df_model") or not hasattr(model, "df_resid"):
184
+ raise TypeError(
185
+ "eta_squared requires a statsmodels regression result; "
186
+ "got an object with no df_model / df_resid attributes."
187
+ )
188
+
189
+ # Identify the predictor names (excluding intercept).
190
+ exog_names = getattr(getattr(model, "model", None), "exog_names", None)
191
+ if exog_names is None:
192
+ raise TypeError(
193
+ "eta_squared requires a statsmodels model with .model.exog_names."
194
+ )
195
+ predictors = [n for n in exog_names if n != "Intercept"]
196
+
197
+ if not predictors:
198
+ return pd.DataFrame(
199
+ columns=["eta_sq", "eta_sq_ci_low", "eta_sq_ci_high", "F", "p_value", "df"]
200
+ )
201
+
202
+ # Compute per-term SS via statsmodels' anova_lm. Type III SS requires
203
+ # the model to have been fitted with `data` so the design matrix can
204
+ # be reconstructed; if that fails, fall back to a manual approach.
205
+ try:
206
+ from statsmodels.stats.anova import anova_lm
207
+ anova_tbl = anova_lm(model, typ=typ)
208
+ except Exception:
209
+ # statsmodels raises if the model wasn't fit with `data=` and we
210
+ # can't recover the term-level SS. Fall back to the legacy
211
+ # single-row computation rather than crashing.
212
+ if not hasattr(model, "fvalue"):
213
+ return pd.DataFrame(
214
+ columns=["eta_sq", "eta_sq_ci_low", "eta_sq_ci_high", "F", "p_value", "df"]
215
+ )
216
+ F = float(model.fvalue)
217
+ df1 = int(model.df_model)
218
+ df2 = int(model.df_resid)
219
+ nobs = int(model.nobs)
220
+ eta2 = (F * df1) / (F * df1 + df2)
221
+ ci = _r_squared_ci(r2=eta2, df_model=df1, nobs=nobs, level=level)
222
+ return pd.DataFrame(
223
+ {
224
+ "eta_sq": [eta2],
225
+ "eta_sq_ci_low": [ci[0] if ci is not None else None],
226
+ "eta_sq_ci_high": [ci[1] if ci is not None else None],
227
+ "F": [F],
228
+ "p_value": [float(model.f_pvalue)],
229
+ "df": [df1],
230
+ },
231
+ index=["model"],
232
+ )
233
+
234
+ # Drop the residual row; only term rows contribute to per-predictor η².
235
+ if "df" in anova_tbl.columns and "F" in anova_tbl.columns:
236
+ # Newer statsmodels uses lowercase; older uses uppercase. Be lenient.
237
+ pass
238
+ term_rows = anova_tbl.drop(index="Residual", errors="ignore")
239
+
240
+ nobs = int(model.nobs)
241
+ df_resid = float(anova_tbl.loc["Residual", "df"]) if "Residual" in anova_tbl.index else float(model.df_resid)
242
+
243
+ rows = []
244
+ for term, row in term_rows.iterrows():
245
+ # Skip the intercept row.
246
+ if term in {"Intercept", "C(Intercept)"}:
247
+ continue
248
+ F_term = float(row["F"])
249
+ df_term = float(row["df"])
250
+ p_term = float(row["PR(>F)"]) if "PR(>F)" in row else float("nan")
251
+ eta2 = (F_term * df_term) / (F_term * df_term + df_resid)
252
+ ci = _r_squared_ci(r2=eta2, df_model=int(df_term), nobs=nobs, level=level)
253
+ rows.append({
254
+ "eta_sq": eta2,
255
+ "eta_sq_ci_low": ci[0] if ci is not None else None,
256
+ "eta_sq_ci_high": ci[1] if ci is not None else None,
257
+ "F": F_term,
258
+ "p_value": p_term,
259
+ "df": df_term,
260
+ })
261
+ if not rows:
262
+ return pd.DataFrame(
263
+ columns=["eta_sq", "eta_sq_ci_low", "eta_sq_ci_high", "F", "p_value", "df"]
264
+ )
265
+ return pd.DataFrame(rows, index=list(term_rows.index.drop(["Intercept", "Residual"], errors="ignore"))[:len(rows)])
266
+
267
+
268
+ def _r_squared_ci(r2: float, df_model: int, nobs: int, level: float = 0.95):
269
+ """Confidence interval for R-squared via non-central-F inversion.
270
+
271
+ Method (Olkin & Finn, 1995; matching R's ``MBESS::ci.R2()``):
272
+
273
+ Given an observed R², a CI for the population R² (ρ²) is found by
274
+ inverting the non-central F distribution. The test statistic
275
+
276
+ F_obs = (R² / k) / ((1 - R²) / (n - k - 1))
277
+
278
+ follows a non-central F distribution with ``(k, n - k - 1, λ)``
279
+ degrees of freedom and non-centrality parameter
280
+ ``λ = n * ρ² / (1 - ρ²)`` under the alternative that the population
281
+ R² equals ρ².
282
+
283
+ The CI endpoints solve for λ at each tail:
284
+
285
+ - **Lower bound** ρ²_L: the noncentral F upper-tail P-value at F_obs
286
+ equals α/2 (i.e., F_obs sits in the *lower* tail of the
287
+ distribution, so the population R² is *smaller* than observed).
288
+ - **Upper bound** ρ²_U: the noncentral F upper-tail P-value at F_obs
289
+ equals 1 - α/2 (i.e., F_obs sits in the *upper* tail, so the
290
+ population R² is *larger*).
291
+
292
+ We invert for λ at each tail via bisection on the survival function,
293
+ then recover ρ² via ``ρ² = λ / (n + λ)``.
294
+
295
+ Edge cases:
296
+ - R² very close to 1.0: the upper bound collapses; the CI is
297
+ ``(lo, 1.0)``.
298
+ - R² very close to 0.0: the lower bound collapses; the CI is
299
+ ``(0.0, hi)``.
300
+ - Invalid inputs (negative R², R² >= 1, df_model < 1, nobs <= k+1):
301
+ return ``None``.
302
+
303
+ Parameters
304
+ ----------
305
+ r2 : float
306
+ Observed R-squared from a fitted OLS model.
307
+ df_model : int
308
+ Number of model parameters (excluding the intercept).
309
+ nobs : int
310
+ Number of observations used in the fit.
311
+ level : float
312
+ Coverage probability (default 0.95).
313
+
314
+ Returns
315
+ -------
316
+ tuple of (lo, hi) or None
317
+ ``None`` indicates the inputs were invalid; otherwise a
318
+ ``(lo, hi)`` tuple with both bounds in [0.0, 1.0].
319
+ """
320
+ if not (0.0 <= r2 < 1.0) or df_model < 1 or nobs <= df_model + 1:
321
+ return None
322
+
323
+ from scipy.stats import ncf, f as f_dist
324
+
325
+ k = df_model
326
+ n = nobs
327
+ df1, df2 = k, n - k - 1
328
+ alpha = 1.0 - level
329
+
330
+ # Observed F statistic.
331
+ f_obs = (r2 / k) / ((1.0 - r2) / df2) if r2 < 1.0 else float("inf")
332
+
333
+ def _upper_tail_p(lam: float) -> float:
334
+ """P(F >= f_obs) under noncentral F(df1, df2, lambda).
335
+
336
+ For lambda == 0, scipy's ncf.sf returns a buggy negative value
337
+ on some versions; we fall back to the central F.sf in that
338
+ case.
339
+ """
340
+ if lam == 0.0:
341
+ return float(f_dist.sf(f_obs, df1, df2))
342
+ return float(ncf.sf(f_obs, df1, df2, lam))
343
+
344
+ def _solve_lambda_for_upper_tail_p(p_target: float) -> Optional[float]:
345
+ """Find lambda such that _upper_tail_p(lambda) = p_target.
346
+
347
+ The upper-tail P(F >= f_obs) under ncf(df1, df2, lambda) is
348
+ monotonically *increasing* in lambda (as lambda grows, the
349
+ distribution shifts right past f_obs). So we want a small
350
+ lambda for small p_target, and a large lambda for large
351
+ p_target.
352
+ """
353
+ p0 = _upper_tail_p(0.0)
354
+ if p_target <= p0:
355
+ # p_target is at or below the central-F upper-tail P-value:
356
+ # the solution is at lambda = 0 (population R² = 0).
357
+ return 0.0
358
+ if p_target >= 1.0:
359
+ # p_target is at or above 1; can never be reached (upper-
360
+ # tail P is bounded above by 1). Return None to indicate
361
+ # an open-ended CI.
362
+ return None
363
+ # Bracket: at lambda=0, upper-tail P is p0 (small). At large
364
+ # lambda, upper-tail P approaches 1.
365
+ lo = 0.0
366
+ hi = 1.0
367
+ while _upper_tail_p(hi) < p_target and hi < 1e10:
368
+ hi *= 10.0
369
+ if _upper_tail_p(hi) < p_target:
370
+ # Cannot reach p_target; CI is open-ended.
371
+ return None
372
+ for _ in range(100):
373
+ mid = 0.5 * (lo + hi)
374
+ if _upper_tail_p(mid) < p_target:
375
+ lo = mid
376
+ else:
377
+ hi = mid
378
+ return 0.5 * (lo + hi)
379
+
380
+ lambda_lo = _solve_lambda_for_upper_tail_p(alpha / 2.0) # smaller λ -> smaller R²
381
+ lambda_hi = _solve_lambda_for_upper_tail_p(1.0 - alpha / 2.0) # larger λ -> larger R²
382
+
383
+ def _lambda_to_r2(lam: Optional[float]) -> Optional[float]:
384
+ if lam is None:
385
+ return None
386
+ if lam == 0.0:
387
+ return 0.0
388
+ return lam / (n + lam)
389
+
390
+ rho_lo = _lambda_to_r2(lambda_lo)
391
+ rho_hi = _lambda_to_r2(lambda_hi)
392
+
393
+ if rho_lo is None:
394
+ rho_lo = 0.0
395
+ if rho_hi is None:
396
+ rho_hi = 1.0
397
+
398
+ rho_lo = max(0.0, min(1.0, rho_lo))
399
+ rho_hi = max(0.0, min(1.0, rho_hi))
400
+ return (rho_lo, rho_hi)
401
+
402
+
403
+ def estimates(model, mc: bool = True):
404
+ """
405
+ R-parity note (v0.8.0): ``mc`` mirrors R's estimates(mc=) — when False,
406
+ comparison-dependent outputs (``semi.p.r2``, ``mean_differences``) are
407
+ skipped (set to None). Factor-level estimates still compute.
408
+ Compute a structured effect-size report for a fitted OLS model.
409
+
410
+ A port of R's ``fifer::estimates()`` / ``flexplot::estimates.lm()``.
411
+ Returns a dict with:
412
+
413
+ - ``r.squared`` (float): model R-squared.
414
+ - ``adj.r.squared`` (float): adjusted R-squared.
415
+ - ``r.squared.ci`` (tuple or None): ``(lo, hi)`` 95% CI for R-squared via
416
+ non-central F inversion (``statsmodels.stats.correlation.cov_nl``).
417
+ - ``sigma`` (float): residual standard error.
418
+ - ``n`` (int): number of observations used by the fit.
419
+ - ``coef`` (pd.DataFrame): coefficients with name, estimate, std.
420
+ error, t-statistic, p-value, and 95% CI from ``model.conf_int()``.
421
+ - ``standardized`` (pd.Series): standardized betas for the predictors
422
+ (excludes the intercept), computed as
423
+ ``b_j * sd(x_j) / sd(y)``.
424
+ - ``semi.p.r2`` (pd.Series): semi-partial R-squared for each
425
+ predictor, computed by fitting reduced models
426
+ (``y ~ x_other``) and measuring the R-squared drop from the full
427
+ model.
428
+ - ``factors`` (list[str]): names of factor (categorical) predictors.
429
+ - ``numbers`` (list[str]): names of numeric predictors.
430
+ - ``formula`` (str): the fitted formula, when accessible via
431
+ ``model.model.formula``.
432
+
433
+ Notes
434
+ -----
435
+ Cohen's d / factor pairwise differences and standardized betas for
436
+ categorical predictors are NOT yet implemented (planned for v0.7.0).
437
+ Random-effects / mixed-model ``estimates()`` is also deferred.
438
+
439
+ Examples
440
+ --------
441
+ >>> import statsmodels.formula.api as smf
442
+ >>> import pandas as pd
443
+ >>> import numpy as np
444
+ >>> rng = np.random.default_rng(0)
445
+ >>> df = pd.DataFrame({
446
+ ... "y": rng.normal(size=80),
447
+ ... "x1": rng.normal(size=80),
448
+ ... "x2": rng.normal(size=80),
449
+ ... })
450
+ >>> fit = smf.ols("y ~ x1 + x2", data=df).fit()
451
+ >>> est = estimates(fit)
452
+ >>> est["r.squared"] >= 0 # doctest: +SKIP
453
+ True
454
+ >>> "x1" in est["standardized"].index # doctest: +SKIP
455
+ True
456
+ """
457
+ if not hasattr(model, "rsquared"):
458
+ raise TypeError(
459
+ f"estimates() expects a fitted OLS-like model with a "
460
+ f"'rsquared' attribute; got {type(model).__name__}."
461
+ )
462
+
463
+ out: Dict[str, Any] = {}
464
+
465
+ # --- Model-level statistics -----------------------------------------
466
+ out["r.squared"] = float(model.rsquared)
467
+ out["adj.r.squared"] = float(model.rsquared_adj)
468
+ out["sigma"] = float(np.sqrt(model.mse_resid))
469
+ out["n"] = int(model.nobs)
470
+
471
+ # --- R-squared CI via non-central F inversion ------------------------
472
+ # statsmodels does not export cov_nl; use scipy's F distribution and
473
+ # the standard non-centrality-parameter inversion to bracket R².
474
+ out["r.squared.ci"] = _r_squared_ci(
475
+ float(model.rsquared),
476
+ int(model.df_model),
477
+ int(model.nobs),
478
+ )
479
+
480
+ # --- Coefficient table with CIs ------------------------------------
481
+ try:
482
+ conf = model.conf_int(alpha=0.05)
483
+ except Exception:
484
+ conf = None
485
+
486
+ coef_df = pd.DataFrame({
487
+ "name": list(model.params.index),
488
+ "estimate": model.params.to_numpy(),
489
+ "std.error": model.bse.to_numpy(),
490
+ "t": model.tvalues.to_numpy(),
491
+ "p.value": model.pvalues.to_numpy(),
492
+ })
493
+ if conf is not None:
494
+ coef_df["ci.lower"] = conf.iloc[:, 0].to_numpy()
495
+ coef_df["ci.upper"] = conf.iloc[:, 1].to_numpy()
496
+ out["coef"] = coef_df.set_index("name")
497
+
498
+ # --- Recover original frame + formula for the harder computations ----
499
+ inner = getattr(model, "model", None)
500
+ frame = getattr(getattr(inner, "data", None), "frame", None)
501
+ formula_str = getattr(inner, "formula", None)
502
+ if isinstance(formula_str, str):
503
+ out["formula"] = formula_str
504
+
505
+ coef_names = [n for n in model.params.index if n != "Intercept"]
506
+
507
+ # --- Standardized betas (predictors only, no intercept) ------------
508
+ std_betas: Dict[str, float] = {}
509
+ if inner is not None and len(coef_names) > 0:
510
+ try:
511
+ exog = getattr(inner, "exog", None)
512
+ endog = getattr(inner, "endog", None)
513
+ if exog is not None and endog is not None and exog.shape[1] >= 2:
514
+ # First exog column is the intercept (constant); drop it.
515
+ pred_cols = exog[:, 1:]
516
+ if pred_cols.shape[1] == len(coef_names):
517
+ x_std = pred_cols.std(axis=0, ddof=1)
518
+ y_std = float(np.std(endog, ddof=1))
519
+ if y_std > 0:
520
+ for name, x_s in zip(coef_names, x_std):
521
+ std_betas[name] = float(
522
+ model.params[name] * x_s / y_std
523
+ )
524
+ except Exception:
525
+ pass
526
+ out["standardized"] = pd.Series(std_betas, dtype=float)
527
+
528
+ # --- Semi-partial R-squared per predictor ----------------------------
529
+ # Approach: drop one predictor at a time and measure the R-squared
530
+ # drop from the full model. semi.p.r2[j] = R2(full) - R2(reduced j).
531
+ # Gated on mc= (R's 'should model comparisons be performed');
532
+ # comparison-dependent outputs are skipped when mc=False.
533
+ semi_p: Dict[str, float] = {}
534
+ if (
535
+ mc
536
+ and frame is not None
537
+ and isinstance(formula_str, str)
538
+ and " ~ " in formula_str
539
+ and len(coef_names) > 1
540
+ ):
541
+ full_r2 = float(model.rsquared)
542
+ outcome, predictors = formula_str.split(" ~ ", 1)
543
+ outcome = outcome.strip()
544
+ predictor_list = [
545
+ p.strip() for p in predictors.split(" + ") if p.strip()
546
+ ]
547
+ for pred in coef_names:
548
+ other = [p for p in predictor_list if p != pred]
549
+ if not other:
550
+ continue
551
+ try:
552
+ reduced_formula = f"{outcome} ~ {' + '.join(other)}"
553
+ reduced_fit = smf.ols(reduced_formula, data=frame).fit()
554
+ semi_p[pred] = full_r2 - float(reduced_fit.rsquared)
555
+ except Exception:
556
+ semi_p[pred] = float("nan")
557
+ out["semi.p.r2"] = pd.Series(semi_p, dtype=float)
558
+
559
+ # --- Factor vs numeric split -----------------------------------------
560
+ factors: List[str] = []
561
+ numbers: List[str] = []
562
+ if frame is not None:
563
+ import re as _re
564
+ seen = set()
565
+ for term in coef_names:
566
+ # Strip C(...) and [T.x] / [level] annotations that
567
+ # statsmodels uses to denote categorical terms.
568
+ base = _re.sub(r"^C\(([^)]+)\).*$", r"\1", term)
569
+ if base in frame.columns and base not in seen:
570
+ seen.add(base)
571
+ col = frame[base]
572
+ if (
573
+ col.dtype == object
574
+ or str(col.dtype).startswith("category")
575
+ or (col.dtype.kind in ("i", "u") and col.nunique() <= 5)
576
+ ):
577
+ factors.append(base)
578
+ else:
579
+ numbers.append(base)
580
+ out["factors"] = factors
581
+ out["numbers"] = numbers
582
+
583
+ # --- Factor-level estimates + mean differences (v0.8.0, R-parity) ---
584
+ # R's estimates() prints two additional tables for factor predictors:
585
+ # - "Estimates for Factors": per-level fitted means (holding other
586
+ # predictors at reference values) with CIs.
587
+ # - "Mean Differences": pairwise level contrasts with CIs and
588
+ # Cohen's d.
589
+ factor_estimates_df = None
590
+ mean_diff_df = None
591
+ if frame is not None and factors and hasattr(model, "get_prediction"):
592
+ try:
593
+ alpha_ci = 0.05
594
+ _outcome_name = None
595
+ if formula_str is not None and isinstance(formula_str, str) and " ~ " in formula_str:
596
+ _outcome_name = formula_str.split(" ~ ", 1)[0].strip()
597
+ if _outcome_name is None or _outcome_name not in frame.columns:
598
+ _outcome_name = None
599
+
600
+ def _level_param_vector(base: str, level) -> Dict[str, float]:
601
+ """Indicator vector over params for C(base)[T.level]."""
602
+ candidates = [n for n in model.params.index if n.startswith(f"C({base})[")]
603
+ vec = {n: 0.0 for n in model.params.index}
604
+ if f"C({base})[T.{level}]" in candidates:
605
+ vec[f"C({base})[T.{level}]"] = 1.0
606
+ return vec
607
+
608
+ levels_by_base: Dict[str, list] = {}
609
+ for base in factors:
610
+ col = frame[base]
611
+ levels_by_base[base] = sorted(col.dropna().unique().tolist())
612
+
613
+ # Reference row: numeric -> mean; factor -> first level.
614
+ def _base_row() -> Dict[str, object]:
615
+ row: Dict[str, object] = {}
616
+ for c in frame.columns:
617
+ col = frame[c]
618
+ if pd.api.types.is_numeric_dtype(col):
619
+ row[c] = float(col.mean())
620
+ else:
621
+ row[c] = col.dropna().unique()[0] if col.dropna().size else None
622
+ return row
623
+
624
+ est_rows = []
625
+ diff_rows = []
626
+ for base in factors:
627
+ if base not in frame.columns:
628
+ continue
629
+ # 1) Per-level fitted means via get_prediction grid.
630
+ level_means = {}
631
+ for level in levels_by_base[base]:
632
+ grid = _base_row()
633
+ if grid is None:
634
+ break
635
+ grid[base] = level
636
+ grid_df = pd.DataFrame([grid])
637
+ pred = model.get_prediction(grid_df)
638
+ sf = pred.summary_frame(alpha=alpha_ci)
639
+ est_rows.append({
640
+ "variable": base,
641
+ "level": level,
642
+ "estimate": float(sf["mean"].iloc[0]),
643
+ "ci.lower": float(sf["mean_ci_lower"].iloc[0]),
644
+ "ci.upper": float(sf["mean_ci_upper"].iloc[0]),
645
+ })
646
+ level_means[level] = float(sf["mean"].iloc[0])
647
+
648
+ # 2) Pairwise contrasts with CI via param contrast vectors.
649
+ params_vec = np.asarray(model.params)
650
+ cov = np.asarray(model.cov_params())
651
+ df_resid = float(getattr(model, "df_resid", np.nan))
652
+ for i, l1 in enumerate(levels_by_base[base]):
653
+ for l2 in levels_by_base[base][i + 1:]:
654
+ v1 = _level_param_vector(base, l1)
655
+ v2 = _level_param_vector(base, l2)
656
+ contrast = np.array([v1.get(n, 0.0) - v2.get(n, 0.0)
657
+ for n in model.params.index])
658
+ diff = float(contrast @ params_vec)
659
+ se = float(np.sqrt(contrast @ cov @ contrast))
660
+ t_crit = stats.t.ppf(0.975, df_resid) if df_resid == df_resid else 1.96
661
+ # Cohen's d: diff / pooled within-level SD of raw y.
662
+ if _outcome_name is None:
663
+ y1 = y2 = pd.Series(dtype=float)
664
+ else:
665
+ y1 = frame.loc[frame[base] == l1, _outcome_name].dropna()
666
+ y2 = frame.loc[frame[base] == l2, _outcome_name].dropna()
667
+ if len(y1) > 1 and len(y2) > 1:
668
+ s1, s2 = y1.std(ddof=1), y2.std(ddof=1)
669
+ n1, n2 = len(y1), len(y2)
670
+ pooled = float(np.sqrt(((n1 - 1) * s1 ** 2 + (n2 - 1) * s2 ** 2) / (n1 + n2 - 2))) if (n1 + n2 - 2) > 0 else np.nan
671
+ else:
672
+ pooled = float("nan")
673
+ cohens_d = diff / pooled if pooled and not np.isnan(pooled) and pooled != 0 else float("nan")
674
+ diff_rows.append({
675
+ "variable": base,
676
+ "comparison": f"{l1} - {l2}",
677
+ "difference": diff,
678
+ "ci.lower": diff - t_crit * se,
679
+ "ci.upper": diff + t_crit * se,
680
+ "cohens.d": cohens_d,
681
+ })
682
+
683
+ if est_rows:
684
+ factor_estimates_df = pd.DataFrame(est_rows)
685
+ if diff_rows:
686
+ mean_diff_df = pd.DataFrame(diff_rows)
687
+ except Exception:
688
+ factor_estimates_df = None
689
+ mean_diff_df = None
690
+ out["factor_estimates"] = factor_estimates_df
691
+ out["mean_differences"] = mean_diff_df if mc else None
692
+
693
+ return out
694
+
695
+
696
+ def p_format(p: float, digits: int = 3):
697
+ """
698
+ Ported from fifer: Formats p-values (e.g., <.001).
699
+ """
700
+ if p < 0.001:
701
+ return "<.001"
702
+ return f"{p:.{digits}f}".replace("0.", ".")
703
+
704
+
705
+ def eliminated_columns(df: pd.DataFrame, threshold: float = 0.5):
706
+ """
707
+ Ported from fifer: Removes columns with too many missing values.
708
+ """
709
+ na_count = df.isna().sum() / len(df)
710
+ to_keep = na_count[na_count <= threshold].index
711
+ return df[to_keep]
712
+
713
+
714
+ def color_table(df: pd.DataFrame, cmap: str = "viridis"):
715
+ """
716
+ Ported from fifer: Returns a styled pandas dataframe.
717
+ """
718
+ return df.style.background_gradient(cmap=cmap)
719
+
720
+
721
+ def standardized_beta(model):
722
+ """Compute standardized (beta) coefficients for a fitted OLS model.
723
+
724
+ Standalone accessor (v0.8.0+) mirroring R's ``flexplot::standardized.beta()``.
725
+ Same values as ``estimates(model)["standardized"]`` — this standalone
726
+ form is convenient when only the betas are needed.
727
+
728
+ Standardized beta for coefficient j: ``b_j * sd(x_j) / sd(y)``.
729
+
730
+ Note: for categorical predictors (patsy dummies in the design matrix),
731
+ ``sd(x_j)`` is the SD of the 0/1 indicator column — R reports these
732
+ differently; treat dummy-column betas with care.
733
+
734
+ Returns
735
+ -------
736
+ pd.Series
737
+ Indexed by non-intercept predictor name.
738
+ """
739
+ if not hasattr(model, "params"):
740
+ raise TypeError(
741
+ "standardized_beta requires a statsmodels regression result "
742
+ "with .params."
743
+ )
744
+ exog_names = list(getattr(model.model, "exog_names", None) or [])
745
+ if not exog_names:
746
+ raise TypeError("standardized_beta requires .model.exog_names.")
747
+
748
+ y = np.asarray(model.model.endog, dtype=float)
749
+ X = np.asarray(model.model.exog, dtype=float)
750
+ sd_y = float(np.std(y, ddof=1))
751
+ out = {}
752
+ for i, name in enumerate(exog_names):
753
+ if name in ("Intercept", "const"):
754
+ continue
755
+ sd_x = float(np.std(X[:, i], ddof=1)) if X.shape[0] > 1 else np.nan
756
+ if sd_y == 0 or not np.isfinite(sd_x) or sd_x == 0:
757
+ out[name] = np.nan
758
+ else:
759
+ param_i = model.params.iloc[i] if hasattr(model.params, "iloc") else model.params[i]
760
+ out[name] = float(param_i * sd_x / sd_y)
761
+ return pd.Series(out, dtype=float)
762
+
763
+
764
+ def rsq_change(reduced_model, full_model):
765
+ """Change in R-squared from reduced to full model (semi-partial R²).
766
+
767
+ Standalone accessor (v0.8.0+) mirroring R's ``flexplot::rsq.change()``.
768
+ Positive values indicate the extra predictors in ``full_model`` explain
769
+ that share of variance beyond ``reduced_model``.
770
+
771
+ Returns
772
+ -------
773
+ float
774
+ ``full_model.rsquared - reduced_model.rsquared``.
775
+ """
776
+ if not hasattr(reduced_model, "rsquared") or not hasattr(full_model, "rsquared"):
777
+ raise TypeError(
778
+ "rsq_change requires two statsmodels results exposing .rsquared."
779
+ )
780
+ return float(full_model.rsquared) - float(reduced_model.rsquared)
781
+
782
+
783
+ def bf_bic(model1, model2):
784
+ """Bayes factor for model1 over model2, from BICs (Kass & Raftery 1995).
785
+
786
+ Standalone accessor (v0.8.0+) mirroring R's ``flexplot::bf.bic()``:
787
+
788
+ BF_12 = exp((BIC_2 - BIC_1) / 2)
789
+
790
+ Values > 1 favor model1; < 1 favor model2. Same computation as the
791
+ ``BayesFactor`` column inside ``model_comparison()``.
792
+
793
+ Returns
794
+ -------
795
+ float
796
+ """
797
+ for name, model in (("model1", model1), ("model2", model2)):
798
+ if not hasattr(model, "bic"):
799
+ raise TypeError(
800
+ f"bf_bic requires statsmodels results with .bic; "
801
+ f"{name} is missing it."
802
+ )
803
+ return float(np.exp((model2.bic - model1.bic) / 2.0))