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/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)