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.
- py_flexplot-0.8.2.dist-info/METADATA +272 -0
- py_flexplot-0.8.2.dist-info/RECORD +16 -0
- py_flexplot-0.8.2.dist-info/WHEEL +5 -0
- py_flexplot-0.8.2.dist-info/licenses/LICENSE +21 -0
- py_flexplot-0.8.2.dist-info/top_level.txt +1 -0
- pyflexplot/__init__.py +60 -0
- pyflexplot/bluepill.py +461 -0
- pyflexplot/core.py +3410 -0
- pyflexplot/descriptives.py +287 -0
- pyflexplot/ebbr.py +148 -0
- pyflexplot/flex_nn.py +583 -0
- pyflexplot/ml.py +175 -0
- pyflexplot/quality.py +372 -0
- pyflexplot/sem.py +195 -0
- pyflexplot/stats.py +803 -0
- pyflexplot/uncertainty.py +185 -0
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))
|