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/ml.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ml: Random-forest (and sklearn-estimator) wrappers for py-flexplot.
|
|
3
|
+
|
|
4
|
+
This module provides thin adapters that make scikit-learn estimators
|
|
5
|
+
(``RandomForestRegressor``, ``RandomForestClassifier``, ``GradientBoosting*``,
|
|
6
|
+
and any estimator with a ``.predict()`` method) usable with py-flexplot's
|
|
7
|
+
visualization API: ``compare_fits()``, ``flexplot(overlay=...)``,
|
|
8
|
+
``visualize()``, and ``estimates()``.
|
|
9
|
+
|
|
10
|
+
It does NOT fit models. Its job is to wrap an already-trained estimator
|
|
11
|
+
with the metadata required to make it a first-class citizen of
|
|
12
|
+
py-flexplot's visualization API:
|
|
13
|
+
|
|
14
|
+
* ``RFAdapter`` -- thin wrapper bundling an sklearn regressor/classifier
|
|
15
|
+
with column-name metadata so ``compare_fits()`` and friends can call
|
|
16
|
+
``.predict()`` and get a properly-aligned ``pandas.Series``.
|
|
17
|
+
|
|
18
|
+
Why an adapter?
|
|
19
|
+
---------------
|
|
20
|
+
scikit-learn estimators expose ``.predict(X)`` but not the predictor
|
|
21
|
+
names; py-flexplot's visualization layer needs to know which columns
|
|
22
|
+
the model used so it can build evaluation DataFrames without losing
|
|
23
|
+
row alignment. ``RFAdapter`` carries that metadata alongside the
|
|
24
|
+
fitted model.
|
|
25
|
+
|
|
26
|
+
Usage example::
|
|
27
|
+
|
|
28
|
+
from sklearn.ensemble import RandomForestRegressor
|
|
29
|
+
from pyflexplot.ml import RFAdapter
|
|
30
|
+
|
|
31
|
+
rf = RandomForestRegressor(n_estimators=200, random_state=0).fit(X, y)
|
|
32
|
+
fit = RFAdapter(rf, response_var="y", predictor_names=list(X.columns))
|
|
33
|
+
|
|
34
|
+
# Now use it in compare_fits:
|
|
35
|
+
from pyflexplot import compare_fits
|
|
36
|
+
p = compare_fits("y ~ x1 + x2", data=df, model1=statsmodels_fit, model2=fit)
|
|
37
|
+
|
|
38
|
+
This is intentionally a thin glue layer — R-flexplot's
|
|
39
|
+
``flexplot::compare.fits()`` accepted any R model with a ``predict()``
|
|
40
|
+
method, and that's the surface this module recreates in Python.
|
|
41
|
+
|
|
42
|
+
Optional dependency
|
|
43
|
+
-------------------
|
|
44
|
+
scikit-learn is **not** a declared dependency of py-flexplot; the
|
|
45
|
+
adapter raises a clear ``ImportError`` at import time if sklearn is
|
|
46
|
+
missing, and py-flexplot's core surface works fine without it.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
from typing import Any, List, Optional, Sequence
|
|
53
|
+
|
|
54
|
+
import numpy as np
|
|
55
|
+
import pandas as pd
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
from sklearn.base import BaseEstimator # noqa: F401
|
|
59
|
+
_SKLEARN_AVAILABLE = True
|
|
60
|
+
except Exception: # pragma: no cover -- environment without sklearn
|
|
61
|
+
_SKLEARN_AVAILABLE = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _check_sklearn_available():
|
|
65
|
+
if not _SKLEARN_AVAILABLE:
|
|
66
|
+
raise ImportError(
|
|
67
|
+
"scikit-learn is required for pyflexplot.ml; install with "
|
|
68
|
+
"`pip install scikit-learn`."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class RFAdapter:
|
|
74
|
+
"""Adapter that makes an sklearn estimator usable with py-flexplot.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
estimator : sklearn.base.BaseEstimator
|
|
79
|
+
A fitted scikit-learn estimator with a ``predict(X)`` method.
|
|
80
|
+
``RandomForestRegressor`` / ``RandomForestClassifier`` are the
|
|
81
|
+
primary targets, but any regressor / classifier works.
|
|
82
|
+
response_var : str
|
|
83
|
+
Name of the response variable in the original DataFrame. Used by
|
|
84
|
+
py-flexplot to build evaluation DataFrames.
|
|
85
|
+
predictor_names : list of str
|
|
86
|
+
Column names of the predictors used during fitting. Order matters;
|
|
87
|
+
must match the column order in the X matrix passed to ``.fit()``.
|
|
88
|
+
|
|
89
|
+
Notes
|
|
90
|
+
-----
|
|
91
|
+
The adapter deliberately does not subclass any sklearn base class to
|
|
92
|
+
avoid surprising scikit-learn's ``check_is_fitted`` machinery. It is
|
|
93
|
+
a pure metadata wrapper.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
estimator: Any
|
|
97
|
+
response_var: str
|
|
98
|
+
predictor_names: List[str]
|
|
99
|
+
|
|
100
|
+
def __post_init__(self):
|
|
101
|
+
_check_sklearn_available()
|
|
102
|
+
if not isinstance(self.predictor_names, (list, tuple)):
|
|
103
|
+
raise TypeError(
|
|
104
|
+
f"predictor_names must be a list/tuple of strings; "
|
|
105
|
+
f"got {type(self.predictor_names).__name__}."
|
|
106
|
+
)
|
|
107
|
+
if any(not isinstance(n, str) for n in self.predictor_names):
|
|
108
|
+
raise TypeError("predictor_names must all be strings.")
|
|
109
|
+
if not isinstance(self.response_var, str):
|
|
110
|
+
raise TypeError(
|
|
111
|
+
f"response_var must be a string; got {type(self.response_var).__name__}."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def predict(self, X):
|
|
115
|
+
"""Predict on a DataFrame or 2-D array.
|
|
116
|
+
|
|
117
|
+
Accepts either a ``pandas.DataFrame`` (column-aligned by name when
|
|
118
|
+
possible) or a 2-D ``numpy.ndarray``. Returns a 1-D
|
|
119
|
+
``numpy.ndarray``.
|
|
120
|
+
"""
|
|
121
|
+
if isinstance(X, pd.DataFrame):
|
|
122
|
+
# Align by name when the columns match; otherwise fall back to
|
|
123
|
+
# positional access (the caller has positional data).
|
|
124
|
+
missing = [c for c in self.predictor_names if c not in X.columns]
|
|
125
|
+
if not missing:
|
|
126
|
+
X_arr = X[self.predictor_names].to_numpy()
|
|
127
|
+
else:
|
|
128
|
+
X_arr = X.to_numpy()
|
|
129
|
+
else:
|
|
130
|
+
X_arr = np.asarray(X)
|
|
131
|
+
return np.asarray(self.estimator.predict(X_arr))
|
|
132
|
+
|
|
133
|
+
def predict_df(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
134
|
+
"""Predict on a DataFrame and return a single-column DataFrame.
|
|
135
|
+
|
|
136
|
+
The returned column is named ``pred_<response_var>`` to match the
|
|
137
|
+
convention used by ``compare_fits(return_preds=True)``.
|
|
138
|
+
"""
|
|
139
|
+
preds = self.predict(data)
|
|
140
|
+
return pd.DataFrame({f"pred_{self.response_var}": preds})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def make_rf_adapter(
|
|
144
|
+
estimator: Any,
|
|
145
|
+
data: pd.DataFrame,
|
|
146
|
+
response_var: str,
|
|
147
|
+
predictor_names: Optional[Sequence[str]] = None,
|
|
148
|
+
) -> RFAdapter:
|
|
149
|
+
"""Convenience constructor that pulls predictor names from a DataFrame.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
estimator
|
|
154
|
+
A fitted sklearn estimator.
|
|
155
|
+
data : pd.DataFrame
|
|
156
|
+
The training DataFrame. Used to infer ``predictor_names`` if not
|
|
157
|
+
provided.
|
|
158
|
+
response_var
|
|
159
|
+
Name of the response column.
|
|
160
|
+
predictor_names
|
|
161
|
+
Explicit list of predictor column names. If ``None`` (default),
|
|
162
|
+
uses ``[c for c in data.columns if c != response_var]``.
|
|
163
|
+
|
|
164
|
+
Returns
|
|
165
|
+
-------
|
|
166
|
+
RFAdapter
|
|
167
|
+
"""
|
|
168
|
+
_check_sklearn_available()
|
|
169
|
+
if predictor_names is None:
|
|
170
|
+
predictor_names = [c for c in data.columns if c != response_var]
|
|
171
|
+
return RFAdapter(
|
|
172
|
+
estimator=estimator,
|
|
173
|
+
response_var=response_var,
|
|
174
|
+
predictor_names=list(predictor_names),
|
|
175
|
+
)
|
pyflexplot/quality.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Auto data-quality diagnostics for regression-style formulas (C: power feature).
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
- :func:`diagnose`: run a diagnostic suite on a flexplot formula + data.
|
|
5
|
+
- :func:`format_summary`: pretty-print a diagnosis dict as a one-paragraph
|
|
6
|
+
terminal/email/log-friendly summary.
|
|
7
|
+
|
|
8
|
+
Design notes
|
|
9
|
+
------------
|
|
10
|
+
Diagnostics are designed to surface "why might my fit be off" rather than to
|
|
11
|
+
gate-keep model usage. Every diagnostic returns raw test statistics and
|
|
12
|
+
p-values alongside a plain-English interpretation; users can drill in
|
|
13
|
+
themselves if they want.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Dict, List, Optional, cast
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --- Internal helpers ---------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _safe_numeric(s: pd.Series) -> Optional[np.ndarray]:
|
|
28
|
+
"""Return ``s`` as a float array, or None if conversion fails."""
|
|
29
|
+
try:
|
|
30
|
+
return s.to_numpy(dtype=float)
|
|
31
|
+
except (ValueError, TypeError):
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _missingness(data: pd.DataFrame, columns: List[str]) -> Dict[str, Any]:
|
|
36
|
+
"""Per-column missing counts and overall pattern heuristic."""
|
|
37
|
+
per_col = {c: int(data[c].isna().sum()) for c in columns if c in data.columns}
|
|
38
|
+
total_rows = len(data)
|
|
39
|
+
any_missing = sum(per_col.values())
|
|
40
|
+
complete_cases = int(data[list(per_col.keys())].dropna().shape[0])
|
|
41
|
+
|
|
42
|
+
# Pattern heuristic: if missingness is concentrated in one column it's
|
|
43
|
+
# likely non-random; spread across all columns suggests MCAR.
|
|
44
|
+
if any_missing == 0:
|
|
45
|
+
pattern = "none"
|
|
46
|
+
elif max(per_col.values(), default=0) > 0.5 * any_missing:
|
|
47
|
+
pattern = "concentrated (likely MNAR/MAR)"
|
|
48
|
+
else:
|
|
49
|
+
pattern = "spread (likely MCAR)"
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
"per_column": per_col,
|
|
53
|
+
"total_missing": any_missing,
|
|
54
|
+
"complete_cases": complete_cases,
|
|
55
|
+
"rows": total_rows,
|
|
56
|
+
"pattern": pattern,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _outliers(
|
|
61
|
+
y: np.ndarray, X: np.ndarray, threshold: Optional[float] = None
|
|
62
|
+
) -> Dict[str, Any]:
|
|
63
|
+
"""Cook's distance for a fitted OLS model.
|
|
64
|
+
|
|
65
|
+
Returns the count of influential points (Cook's D > 4/n) and their row
|
|
66
|
+
indices in the input data (after any internal NaN filtering).
|
|
67
|
+
"""
|
|
68
|
+
import statsmodels.api as sm
|
|
69
|
+
|
|
70
|
+
n = max(len(y), 1)
|
|
71
|
+
if threshold is None:
|
|
72
|
+
threshold = 4.0 / n
|
|
73
|
+
|
|
74
|
+
Xc = sm.add_constant(X, has_constant="add")
|
|
75
|
+
model = sm.OLS(y, Xc).fit()
|
|
76
|
+
infl = model.get_influence()
|
|
77
|
+
cooks_d, _ = infl.cooks_distance
|
|
78
|
+
influential_idx = np.where(cooks_d > threshold)[0].tolist()
|
|
79
|
+
return {
|
|
80
|
+
"n_outliers": len(influential_idx),
|
|
81
|
+
"indices": influential_idx,
|
|
82
|
+
"max_cooks_d": float(np.max(cooks_d)),
|
|
83
|
+
"threshold": float(threshold),
|
|
84
|
+
"method": "Cook's distance > 4/n",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _linearity_test(residuals: np.ndarray, fitted: np.ndarray) -> Dict[str, Any]:
|
|
89
|
+
"""Ramsey RESET test for functional form misspecification.
|
|
90
|
+
|
|
91
|
+
Augmented regression: add fitted^2 and fitted^3 to the design and test
|
|
92
|
+
whether the added terms are jointly zero. A small p-value indicates
|
|
93
|
+
non-linearity.
|
|
94
|
+
"""
|
|
95
|
+
import statsmodels.api as sm
|
|
96
|
+
|
|
97
|
+
X_aug = np.column_stack([fitted, fitted ** 2, fitted ** 3])
|
|
98
|
+
Xc = sm.add_constant(X_aug, has_constant="add")
|
|
99
|
+
model = sm.OLS(residuals, Xc).fit()
|
|
100
|
+
# The F-test is for whether the augmented terms have zero coefficients
|
|
101
|
+
# (excluding the constant).
|
|
102
|
+
r_matrix = np.zeros((3, Xc.shape[1]))
|
|
103
|
+
r_matrix[0, 1] = 1 # fitted^1
|
|
104
|
+
r_matrix[1, 2] = 1 # fitted^2
|
|
105
|
+
r_matrix[2, 3] = 1 # fitted^3
|
|
106
|
+
try:
|
|
107
|
+
test = model.f_test(r_matrix)
|
|
108
|
+
statistic = float(np.squeeze(test.fvalue))
|
|
109
|
+
p_value = float(np.squeeze(test.pvalue))
|
|
110
|
+
except Exception:
|
|
111
|
+
statistic, p_value = float("nan"), float("nan")
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
"test": "Ramsey RESET",
|
|
115
|
+
"statistic": statistic,
|
|
116
|
+
"p_value": p_value,
|
|
117
|
+
"reject_linearity": bool(p_value < 0.05) if not np.isnan(p_value) else None,
|
|
118
|
+
"interpretation": (
|
|
119
|
+
"Reject linearity at alpha=0.05; functional form may be misspecified."
|
|
120
|
+
if (p_value < 0.05 if not np.isnan(p_value) else False)
|
|
121
|
+
else "Fail to reject linearity at alpha=0.05."
|
|
122
|
+
),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _heteroscedasticity_test(
|
|
127
|
+
residuals: np.ndarray, fitted: np.ndarray, X_with_const: np.ndarray
|
|
128
|
+
) -> Dict[str, Any]:
|
|
129
|
+
"""Breusch-Pagan test for non-constant error variance.
|
|
130
|
+
|
|
131
|
+
Returns the LM statistic, p-value, and an interpretation.
|
|
132
|
+
"""
|
|
133
|
+
from statsmodels.stats.diagnostic import het_breuschpagan
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
lm, lm_pvalue, fvalue, f_pvalue = het_breuschpagan(residuals, X_with_const)
|
|
137
|
+
except Exception:
|
|
138
|
+
return {
|
|
139
|
+
"test": "Breusch-Pagan",
|
|
140
|
+
"statistic": float("nan"),
|
|
141
|
+
"p_value": float("nan"),
|
|
142
|
+
"reject_homoscedasticity": None,
|
|
143
|
+
"interpretation": "Test could not be computed.",
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
"test": "Breusch-Pagan",
|
|
147
|
+
"statistic": float(lm),
|
|
148
|
+
"p_value": float(lm_pvalue),
|
|
149
|
+
"reject_homoscedasticity": bool(lm_pvalue < 0.05),
|
|
150
|
+
"interpretation": (
|
|
151
|
+
"Reject homoscedasticity at alpha=0.05; variance is non-constant."
|
|
152
|
+
if lm_pvalue < 0.05
|
|
153
|
+
else "Fail to reject homoscedasticity at alpha=0.05."
|
|
154
|
+
),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --- Public API ---------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def diagnose(
|
|
162
|
+
formula: str,
|
|
163
|
+
data: pd.DataFrame,
|
|
164
|
+
verbose: bool = True,
|
|
165
|
+
outlier_threshold: Optional[float] = None,
|
|
166
|
+
) -> Dict[str, Any]:
|
|
167
|
+
"""Run a data-quality diagnostic on a flexplot formula + data.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
formula : str
|
|
172
|
+
Flexplot formula of the form ``y ~ x1 + x2 [+ ...]``. Only the
|
|
173
|
+
outcome and predictors after ``~`` are used.
|
|
174
|
+
data : pd.DataFrame
|
|
175
|
+
Non-empty data frame holding the referenced columns.
|
|
176
|
+
verbose : bool, default True
|
|
177
|
+
If True, prints a one-paragraph summary to stdout.
|
|
178
|
+
outlier_threshold : float, default 4.0 / n
|
|
179
|
+
Cook's distance cutoff. Default is the conventional ``4/n`` value
|
|
180
|
+
(applied automatically as ``4.0 / n_complete``). Pass an explicit
|
|
181
|
+
float to override.
|
|
182
|
+
|
|
183
|
+
Returns
|
|
184
|
+
-------
|
|
185
|
+
dict
|
|
186
|
+
Structured diagnostics with keys:
|
|
187
|
+
|
|
188
|
+
- ``n_obs`` (int), ``n_complete`` (int), ``columns`` (list[str])
|
|
189
|
+
- ``missing`` (dict): per-column counts and pattern heuristic
|
|
190
|
+
- ``outliers`` (dict): Cook's D count and threshold
|
|
191
|
+
- ``linearity`` (dict): Ramsey RESET test
|
|
192
|
+
- ``heteroscedasticity`` (dict): Breusch-Pagan test
|
|
193
|
+
|
|
194
|
+
Raises
|
|
195
|
+
------
|
|
196
|
+
ValueError
|
|
197
|
+
If the formula has no outcome or no predictors.
|
|
198
|
+
|
|
199
|
+
Examples
|
|
200
|
+
--------
|
|
201
|
+
Quiet mode (returns the dict without printing):
|
|
202
|
+
|
|
203
|
+
>>> import pandas as pd
|
|
204
|
+
>>> import numpy as np
|
|
205
|
+
>>> from pyflexplot.quality import diagnose
|
|
206
|
+
>>> rng = np.random.default_rng(0)
|
|
207
|
+
>>> df = pd.DataFrame({
|
|
208
|
+
... "y": rng.normal(size=200),
|
|
209
|
+
... "x": rng.normal(size=200),
|
|
210
|
+
... })
|
|
211
|
+
>>> diag = diagnose("y ~ x", data=df, verbose=False)
|
|
212
|
+
>>> diag["linearity"]["reject_linearity"]
|
|
213
|
+
False
|
|
214
|
+
>>> "missing" in diag and "outliers" in diag
|
|
215
|
+
True
|
|
216
|
+
|
|
217
|
+
Notes
|
|
218
|
+
-----
|
|
219
|
+
Designed to surface *why* a fit might be off, not to gate-keep model
|
|
220
|
+
usage. All test statistics and p-values are returned alongside the
|
|
221
|
+
plain-English interpretation so users can drill in themselves. The
|
|
222
|
+
pattern heuristic (`` none`` / ``concentrated`` / ``spread``) is a
|
|
223
|
+
rough first cut; for missing-data formal tests, see ``statsmodels``.
|
|
224
|
+
"""
|
|
225
|
+
from .core import parse_flexplot_formula
|
|
226
|
+
|
|
227
|
+
variables = parse_flexplot_formula(formula)
|
|
228
|
+
y_name = variables["y"]
|
|
229
|
+
x_names = [v for v in variables["all_x"] if v]
|
|
230
|
+
if not x_names:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
f"diagnose() requires a formula with at least one predictor; got {formula!r}."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Drop any predictors that aren't numeric — non-numeric predictors
|
|
236
|
+
# (categorical color, given groups) belong to the formula but not the
|
|
237
|
+
# regression design matrix.
|
|
238
|
+
numeric_x_names = [
|
|
239
|
+
c for c in x_names if c in data.columns
|
|
240
|
+
and pd.api.types.is_numeric_dtype(data[c])
|
|
241
|
+
]
|
|
242
|
+
if not numeric_x_names:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"diagnose() found no numeric predictors in formula {formula!r}; "
|
|
245
|
+
f"predictors={x_names}."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
columns = [y_name] + numeric_x_names
|
|
249
|
+
# Strip any column not actually present (parse_flexplot_formula is permissive).
|
|
250
|
+
columns = [c for c in columns if c in data.columns]
|
|
251
|
+
if len(columns) < 2:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f"diagnose() needs at least one predictor present in data; got columns={columns}."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
missing_summary = _missingness(data, columns)
|
|
257
|
+
|
|
258
|
+
# Fit on the complete-case subset.
|
|
259
|
+
complete = data[[y_name] + numeric_x_names].dropna()
|
|
260
|
+
n_complete = len(complete)
|
|
261
|
+
if n_complete < 4:
|
|
262
|
+
out: Dict[str, Any] = {
|
|
263
|
+
"n_obs": int(len(data)),
|
|
264
|
+
"n_complete": n_complete,
|
|
265
|
+
"columns": columns,
|
|
266
|
+
"missing": missing_summary,
|
|
267
|
+
"outliers": {
|
|
268
|
+
"n_outliers": 0, "indices": [], "max_cooks_d": None,
|
|
269
|
+
"threshold": (
|
|
270
|
+
outlier_threshold if outlier_threshold is not None
|
|
271
|
+
else 4.0 / max(n_complete, 1)
|
|
272
|
+
),
|
|
273
|
+
"method": "Cook's distance > 4/n",
|
|
274
|
+
},
|
|
275
|
+
"linearity": {
|
|
276
|
+
"test": "Ramsey RESET",
|
|
277
|
+
"statistic": None, "p_value": None, "reject_linearity": None,
|
|
278
|
+
"interpretation": "Not enough complete cases to run the test.",
|
|
279
|
+
},
|
|
280
|
+
"heteroscedasticity": {
|
|
281
|
+
"test": "Breusch-Pagan",
|
|
282
|
+
"statistic": None, "p_value": None, "reject_homoscedasticity": None,
|
|
283
|
+
"interpretation": "Not enough complete cases to run the test.",
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
if verbose:
|
|
287
|
+
print(format_summary(out))
|
|
288
|
+
return out
|
|
289
|
+
|
|
290
|
+
y = _safe_numeric(complete[y_name])
|
|
291
|
+
x_arrays = [_safe_numeric(complete[c]) for c in numeric_x_names]
|
|
292
|
+
if y is None or any(arr is None for arr in x_arrays):
|
|
293
|
+
raise ValueError("Complete numeric cases could not be converted to arrays.")
|
|
294
|
+
X = np.column_stack([cast(np.ndarray, arr) for arr in x_arrays])
|
|
295
|
+
|
|
296
|
+
# Re-fit on the complete-case subset to get residuals/fitted.
|
|
297
|
+
import statsmodels.api as sm
|
|
298
|
+
|
|
299
|
+
Xc = sm.add_constant(X, has_constant="add")
|
|
300
|
+
fit = sm.OLS(y, Xc).fit()
|
|
301
|
+
residuals = fit.resid
|
|
302
|
+
fitted = fit.fittedvalues
|
|
303
|
+
|
|
304
|
+
outlier_summary = _outliers(
|
|
305
|
+
y, X,
|
|
306
|
+
threshold=(
|
|
307
|
+
outlier_threshold
|
|
308
|
+
if outlier_threshold is not None
|
|
309
|
+
else 4.0 / max(n_complete, 1)
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
linearity_summary = _linearity_test(residuals, fitted)
|
|
313
|
+
hetero_summary = _heteroscedasticity_test(residuals, fitted, Xc)
|
|
314
|
+
|
|
315
|
+
out = {
|
|
316
|
+
"n_obs": int(len(data)),
|
|
317
|
+
"n_complete": int(n_complete),
|
|
318
|
+
"columns": columns,
|
|
319
|
+
"missing": missing_summary,
|
|
320
|
+
"outliers": outlier_summary,
|
|
321
|
+
"linearity": linearity_summary,
|
|
322
|
+
"heteroscedasticity": hetero_summary,
|
|
323
|
+
"_r_squared": float(fit.rsquared),
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if verbose:
|
|
327
|
+
print(format_summary(out))
|
|
328
|
+
|
|
329
|
+
return out
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def format_summary(diag: Dict[str, Any]) -> str:
|
|
333
|
+
"""Format a diagnosis dict as a one-paragraph human-readable summary.
|
|
334
|
+
|
|
335
|
+
Suitable for terminal output, log lines, or email bodies.
|
|
336
|
+
"""
|
|
337
|
+
n_obs = diag.get("n_obs", "?")
|
|
338
|
+
n_complete = diag.get("n_complete", "?")
|
|
339
|
+
columns = diag.get("columns", [])
|
|
340
|
+
col_str = ", ".join(columns)
|
|
341
|
+
|
|
342
|
+
missing = diag.get("missing", {})
|
|
343
|
+
total_missing = missing.get("total_missing", 0)
|
|
344
|
+
pattern = missing.get("pattern", "unknown")
|
|
345
|
+
|
|
346
|
+
outliers = diag.get("outliers", {})
|
|
347
|
+
n_outliers = outliers.get("n_outliers", 0)
|
|
348
|
+
max_cooks = outliers.get("max_cooks_d")
|
|
349
|
+
|
|
350
|
+
linearity = diag.get("linearity", {})
|
|
351
|
+
lin_interp = linearity.get("interpretation", "n/a")
|
|
352
|
+
|
|
353
|
+
hetero = diag.get("heteroscedasticity", {})
|
|
354
|
+
het_interp = hetero.get("interpretation", "n/a")
|
|
355
|
+
|
|
356
|
+
r2 = diag.get("_r_squared")
|
|
357
|
+
|
|
358
|
+
lines = [
|
|
359
|
+
f"Diagnostic for {col_str} (n={n_obs}, complete cases={n_complete})",
|
|
360
|
+
f" Missingness: {total_missing} missing values total; pattern = {pattern}.",
|
|
361
|
+
]
|
|
362
|
+
if max_cooks is not None:
|
|
363
|
+
lines.append(
|
|
364
|
+
f" Outliers: {n_outliers} influential points (max Cook's D = {max_cooks:.3f})."
|
|
365
|
+
)
|
|
366
|
+
else:
|
|
367
|
+
lines.append(f" Outliers: {n_outliers} influential points.")
|
|
368
|
+
lines.append(f" Linearity: {lin_interp}")
|
|
369
|
+
lines.append(f" Heteroscedasticity: {het_interp}")
|
|
370
|
+
if r2 is not None:
|
|
371
|
+
lines.append(f" R-squared (OLS reference): {r2:.3f}")
|
|
372
|
+
return "\n".join(lines)
|