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
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Uncertainty quantification for fitted lines.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
- ``validate_uncertainty_params``: validation entry point used by
|
|
5
|
+
:func:`pyflexplot.core.flexplot`.
|
|
6
|
+
- ``compute_bootstrap_ci``: case-resampled bootstrap CI for a smoother
|
|
7
|
+
(used when ``uncertainty='bootstrap'`` on the loess branch).
|
|
8
|
+
- ``compute_prediction_band``: residual-based symmetric prediction
|
|
9
|
+
interval (used when ``uncertainty='prediction'`` on the numeric branch).
|
|
10
|
+
- ``format_band_label``: legend-label helper used by the plot layer.
|
|
11
|
+
|
|
12
|
+
Design notes
|
|
13
|
+
------------
|
|
14
|
+
- Bootstrap uses case (row) resampling, not residual bootstrap, to be
|
|
15
|
+
robust to model misspecification.
|
|
16
|
+
- Prediction intervals assume approximately normal residuals. For
|
|
17
|
+
non-normal residuals, prefer bootstrap (loess) or transform the outcome.
|
|
18
|
+
- ``bands`` (nested coverage levels) is layered at the flexplot() call
|
|
19
|
+
site; this module only knows how to compute a single band.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Callable, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
from scipy import stats as scipy_stats
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Public set used by core.flexplot() to validate the user-facing
|
|
31
|
+
# ``uncertainty`` parameter.
|
|
32
|
+
VALID_UNCERTAINTY = frozenset({None, "ci", "prediction", "bootstrap"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_uncertainty_params(
|
|
36
|
+
uncertainty: Optional[str],
|
|
37
|
+
level: Optional[float],
|
|
38
|
+
bands: Optional[list],
|
|
39
|
+
method: Optional[str],
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Validate uncertainty-related parameters and method compatibility.
|
|
42
|
+
|
|
43
|
+
Raises ``ValueError`` with a precise message on the first violation.
|
|
44
|
+
"""
|
|
45
|
+
if uncertainty not in VALID_UNCERTAINTY:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"uncertainty must be one of {sorted(VALID_UNCERTAINTY, key=str)}; "
|
|
48
|
+
f"got {uncertainty!r}."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if level is not None:
|
|
52
|
+
if not isinstance(level, (int, float)) or not 0 < level < 1:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"level must be a number in (0, 1); got {level!r}."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if bands is not None:
|
|
58
|
+
if not isinstance(bands, (list, tuple)):
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"bands must be a list or tuple of floats; "
|
|
61
|
+
f"got type {type(bands).__name__}."
|
|
62
|
+
)
|
|
63
|
+
for b in bands:
|
|
64
|
+
if not isinstance(b, (int, float)) or not 0 < b < 1:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Each band level must be a number in (0, 1); got {b!r}."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Method/uncertainty compatibility.
|
|
70
|
+
if uncertainty == "bootstrap" and method not in ("loess", "auto"):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"uncertainty='bootstrap' is only supported for method='loess'; "
|
|
73
|
+
f"got method={method!r}. Use 'ci' or 'prediction' for LM fits."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compute_bootstrap_ci(
|
|
78
|
+
x: np.ndarray,
|
|
79
|
+
y: np.ndarray,
|
|
80
|
+
smooth_fn: Callable[[np.ndarray, np.ndarray], np.ndarray],
|
|
81
|
+
n_resamples: int = 200,
|
|
82
|
+
level: float = 0.95,
|
|
83
|
+
x_eval: Optional[np.ndarray] = None,
|
|
84
|
+
random_state: Optional[int] = None,
|
|
85
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
86
|
+
"""Case-resampled bootstrap CI for a smoother.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
x, y : 1-D arrays of equal length
|
|
91
|
+
smooth_fn : callable(x_eval, y_at_x_eval_sorted_by_x) -> yhat
|
|
92
|
+
Fitted values evaluated at ``x_eval``. The smoother is invoked
|
|
93
|
+
once on the full data and ``n_resamples`` times on bootstrap
|
|
94
|
+
samples.
|
|
95
|
+
n_resamples : int, default 200
|
|
96
|
+
level : float in (0, 1), default 0.95
|
|
97
|
+
x_eval : 1-D array of points at which to evaluate the smoother;
|
|
98
|
+
defaults to the sorted unique ``x`` values.
|
|
99
|
+
random_state : int or None for non-deterministic
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
(x_eval, lower, upper) : three 1-D arrays of equal length.
|
|
104
|
+
"""
|
|
105
|
+
rng = np.random.default_rng(random_state)
|
|
106
|
+
x = np.asarray(x)
|
|
107
|
+
y = np.asarray(y)
|
|
108
|
+
n = len(x)
|
|
109
|
+
if n != len(y):
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"x and y must have equal length; got {n} and {len(y)}."
|
|
112
|
+
)
|
|
113
|
+
if x_eval is None:
|
|
114
|
+
x_eval = np.sort(np.unique(x))
|
|
115
|
+
x_eval = np.asarray(x_eval)
|
|
116
|
+
|
|
117
|
+
# Fit on full data first (for fallback on failed bootstrap samples).
|
|
118
|
+
yhat_full = smooth_fn(x_eval, y[np.argsort(x)])
|
|
119
|
+
|
|
120
|
+
boot_preds = np.empty((n_resamples, len(x_eval)))
|
|
121
|
+
for i in range(n_resamples):
|
|
122
|
+
idx = rng.integers(0, n, size=n)
|
|
123
|
+
x_b, y_b = x[idx], y[idx]
|
|
124
|
+
try:
|
|
125
|
+
boot_preds[i] = smooth_fn(x_eval, y_b[np.argsort(x_b)])
|
|
126
|
+
except Exception:
|
|
127
|
+
# Singular fits or other numerical issues fall back to the
|
|
128
|
+
# full-data fit for that resample. This avoids losing
|
|
129
|
+
# observations from the percentile calculation.
|
|
130
|
+
boot_preds[i] = yhat_full
|
|
131
|
+
|
|
132
|
+
alpha = 1.0 - level
|
|
133
|
+
lower = np.percentile(boot_preds, 100.0 * alpha / 2.0, axis=0)
|
|
134
|
+
upper = np.percentile(boot_preds, 100.0 * (1.0 - alpha / 2.0), axis=0)
|
|
135
|
+
|
|
136
|
+
return x_eval, lower, upper
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def compute_prediction_band(
|
|
140
|
+
y_true: np.ndarray,
|
|
141
|
+
y_pred: np.ndarray,
|
|
142
|
+
level: float = 0.95,
|
|
143
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
144
|
+
"""Symmetric residual-based prediction interval.
|
|
145
|
+
|
|
146
|
+
Assumes approximately normal residuals with constant variance.
|
|
147
|
+
Uses the residual standard error (sigma) computed with ddof=0
|
|
148
|
+
(matches OLS residual-variance convention).
|
|
149
|
+
"""
|
|
150
|
+
y_true = np.asarray(y_true, dtype=float)
|
|
151
|
+
y_pred = np.asarray(y_pred, dtype=float)
|
|
152
|
+
if y_true.shape != y_pred.shape:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"y_true and y_pred must have the same shape; got "
|
|
155
|
+
f"{y_true.shape} and {y_pred.shape}."
|
|
156
|
+
)
|
|
157
|
+
residuals = y_true - y_pred
|
|
158
|
+
sigma = float(np.sqrt(np.mean(residuals ** 2)))
|
|
159
|
+
z = float(scipy_stats.norm.ppf(1.0 - (1.0 - level) / 2.0))
|
|
160
|
+
half_width = z * sigma
|
|
161
|
+
lower = y_pred - half_width
|
|
162
|
+
upper = y_pred + half_width
|
|
163
|
+
return lower, upper
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def format_band_label(level: float, kind: str = "ci") -> str:
|
|
167
|
+
"""Format a legend label for a band.
|
|
168
|
+
|
|
169
|
+
Examples
|
|
170
|
+
--------
|
|
171
|
+
>>> format_band_label(0.95)
|
|
172
|
+
'95% CI'
|
|
173
|
+
>>> format_band_label(0.80, kind='prediction')
|
|
174
|
+
'80% PI'
|
|
175
|
+
>>> format_band_label(0.95, kind='bootstrap')
|
|
176
|
+
'95% bootstrap CI'
|
|
177
|
+
"""
|
|
178
|
+
pct = int(round(level * 100))
|
|
179
|
+
if kind == "ci":
|
|
180
|
+
return f"{pct}% CI"
|
|
181
|
+
if kind == "prediction":
|
|
182
|
+
return f"{pct}% PI"
|
|
183
|
+
if kind == "bootstrap":
|
|
184
|
+
return f"{pct}% bootstrap CI"
|
|
185
|
+
return f"{pct}% {kind}"
|