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.
@@ -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}"