ordboost 0.2.1__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.
ordboost/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """OrdBoost: Non-parametric discrete ordinal-binning gradient boosting and continuous regression."""
2
+
3
+ from ordboost.distributions import (
4
+ ContinuousPredictiveDistribution,
5
+ DiscretePredictiveDistribution,
6
+ PredictiveDistribution,
7
+ )
8
+ from ordboost.mappers import (
9
+ BaseBinMapper,
10
+ ContinuousBinMapper,
11
+ EmpiricalMeanBinMapper,
12
+ EmpiricalMedianBinMapper,
13
+ QuantileBinMapper,
14
+ UniformBinMapper,
15
+ )
16
+ from ordboost.metrics import (
17
+ crps_score,
18
+ interval_coverage_rate,
19
+ pinball_loss,
20
+ winkler_score,
21
+ )
22
+ from ordboost.models import OrdBoostClassifier, OrdBoostRegressor
23
+
24
+ __version__ = "0.2.1"
25
+
26
+ __all__ = [
27
+ # Models
28
+ "OrdBoostClassifier",
29
+ "OrdBoostRegressor",
30
+ # Mappers
31
+ "BaseBinMapper",
32
+ "ContinuousBinMapper",
33
+ "EmpiricalMeanBinMapper",
34
+ "EmpiricalMedianBinMapper",
35
+ "QuantileBinMapper",
36
+ "UniformBinMapper",
37
+ # Distributions
38
+ "PredictiveDistribution",
39
+ "DiscretePredictiveDistribution",
40
+ "ContinuousPredictiveDistribution",
41
+ # Metrics
42
+ "crps_score",
43
+ "interval_coverage_rate",
44
+ "pinball_loss",
45
+ "winkler_score",
46
+ ]
@@ -0,0 +1,398 @@
1
+ """Predictive probability distributions for discrete ordinal outcomes."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Union
5
+
6
+ import numpy as np
7
+ from numpy.typing import ArrayLike
8
+
9
+
10
+ class PredictiveDistribution(ABC):
11
+ """Abstract base class for all predictive probability distributions.
12
+
13
+ Defines a unified interface for extracting point estimates, quantiles,
14
+ prediction intervals, and cumulative probabilities regardless of whether
15
+ the distribution is discrete or continuous.
16
+
17
+ Methods
18
+ -------
19
+ mean()
20
+ Calculate expected values across samples.
21
+ median()
22
+ Calculate 50th percentile predictions across samples.
23
+ ppf(q)
24
+ Calculate percent point function (inverse CDF / quantiles).
25
+ interval(alpha=0.10)
26
+ Calculate central prediction bounds for a given significance level.
27
+
28
+ """
29
+
30
+ @abstractmethod
31
+ def mean(self) -> np.ndarray:
32
+ """Calculate the expected value for each sample."""
33
+ pass
34
+
35
+ @abstractmethod
36
+ def ppf(self, q: Union[float, np.ndarray]) -> np.ndarray:
37
+ """Percent Point Function (inverse CDF / quantile calculation)."""
38
+ pass
39
+
40
+ def median(self) -> np.ndarray:
41
+ """Calculate the 50th percentile (median) prediction for each sample.
42
+
43
+ Returns
44
+ -------
45
+ np.ndarray
46
+ 1D array of shape (n_samples,) containing median predictions.
47
+
48
+ """
49
+ return self.ppf(0.5)
50
+
51
+ def interval(self, alpha: float = 0.10) -> tuple[np.ndarray, np.ndarray]:
52
+ """Calculate central prediction bounds for a given significance level.
53
+
54
+ Parameters
55
+ ----------
56
+ alpha : float, default=0.10
57
+ Significance level (e.g., alpha=0.10 yields a 90% central interval).
58
+
59
+ Returns
60
+ -------
61
+ tuple[np.ndarray, np.ndarray]
62
+ Tuple of (lower_bounds, upper_bounds), each as a 1D array.
63
+
64
+ Raises
65
+ ------
66
+ ValueError
67
+ If `alpha` is not strictly within (0.0, 1.0).
68
+
69
+ """
70
+ if not 0.0 < alpha < 1.0:
71
+ raise ValueError("Significance level 'alpha' must be between 0.0 and 1.0.")
72
+
73
+ lower_q = alpha / 2.0
74
+ upper_q = 1.0 - (alpha / 2.0)
75
+ bounds = self.ppf(np.array([lower_q, upper_q]))
76
+ return bounds[:, 0], bounds[:, 1]
77
+
78
+
79
+ class DiscretePredictiveDistribution(PredictiveDistribution):
80
+ """Encapsulates a discrete Probability Mass Function (PMF) matrix.
81
+
82
+ Provides vectorized utilities for computing cumulative distribution
83
+ functions (CDF), percent point functions (quantiles/PPF), expected values,
84
+ medians, and prediction intervals across samples.
85
+
86
+ Parameters
87
+ ----------
88
+ pmf : np.ndarray
89
+ A 2D float array of shape (n_samples, n_classes) representing the
90
+ predicted probability for each discrete target class. Values along
91
+ each row must sum to 1.0.
92
+ classes : np.ndarray
93
+ A 1D array of shape (n_classes,) representing the physical ordinal
94
+ class labels in strictly ascending order.
95
+
96
+ Attributes
97
+ ----------
98
+ pmf : np.ndarray
99
+ A 2D float array of shape (n_samples, n_classes) containing predicted
100
+ class probabilities.
101
+ classes : np.ndarray
102
+ A 1D array of shape (n_classes,) containing the ordinal class labels.
103
+ cdf : np.ndarray
104
+ A 2D float array of shape (n_samples, n_classes) containing cumulative
105
+ probabilities computed from `pmf`.
106
+
107
+ Methods
108
+ -------
109
+ mean()
110
+ Calculate the expected value for each sample.
111
+ ppf(q)
112
+ Calculate the percent point function (inverse CDF / quantiles).
113
+ median()
114
+ Calculate the 50th percentile prediction for each sample.
115
+ interval(alpha=0.10)
116
+ Calculate central prediction bounds for a given significance level.
117
+
118
+ Raises
119
+ ------
120
+ ValueError
121
+ If `pmf` is not a 2D array, `classes` is not a 1D array, or the
122
+ number of columns in `pmf` does not match the length of `classes`.
123
+
124
+ """
125
+
126
+ def __init__(self, pmf: np.ndarray, classes: np.ndarray) -> None:
127
+ pmf_arr = np.asarray(pmf, dtype=float)
128
+ classes_arr = np.asarray(classes)
129
+
130
+ if pmf_arr.ndim != 2:
131
+ raise ValueError(
132
+ f"Expected 'pmf' to be a 2D array of shape (n_samples, n_classes), "
133
+ f"got shape {pmf_arr.shape}."
134
+ )
135
+ if classes_arr.ndim != 1:
136
+ raise ValueError(
137
+ f"Expected 'classes' to be a 1D array, got shape {classes_arr.shape}."
138
+ )
139
+ if pmf_arr.shape[1] != classes_arr.shape[0]:
140
+ raise ValueError(
141
+ f"Mismatch between PMF class dimension ({pmf_arr.shape[1]}) "
142
+ f"and classes array length ({classes_arr.shape[0]})."
143
+ )
144
+
145
+ self.pmf = pmf_arr
146
+ self.classes = classes_arr
147
+ self._cdf: np.ndarray | None = None
148
+
149
+ @property
150
+ def cdf(self) -> np.ndarray:
151
+ """Compute the Cumulative Distribution Function (CDF) array.
152
+
153
+ Returns
154
+ -------
155
+ np.ndarray
156
+ 2D array of shape (n_samples, n_classes) containing cumulative probabilities.
157
+
158
+ """
159
+ if self._cdf is None:
160
+ self._cdf = np.clip(np.cumsum(self.pmf, axis=1), 0.0, 1.0)
161
+ return self._cdf
162
+
163
+ def mean(self) -> np.ndarray:
164
+ """Calculate the expected value (mean) for each sample.
165
+
166
+ Returns
167
+ -------
168
+ np.ndarray
169
+ 1D array of shape (n_samples,) representing expected values in
170
+ physical class units.
171
+
172
+ """
173
+ return np.dot(self.pmf, self.classes)
174
+
175
+ def ppf(self, q: Union[float, np.ndarray]) -> np.ndarray:
176
+ """Percent Point Function (inverse CDF / quantile calculation).
177
+
178
+ Maps quantile probabilities back to discrete physical class levels.
179
+
180
+ Parameters
181
+ ----------
182
+ q : float | np.ndarray
183
+ Quantile level(s) in the range [0.0, 1.0]. Can be a single scalar
184
+ or an array of quantiles.
185
+
186
+ Returns
187
+ -------
188
+ np.ndarray
189
+ If `q` is a scalar, returns a 1D array of shape (n_samples,).
190
+ If `q` is 1D array of length `n_quantiles`, returns a 2D array of
191
+ shape (n_samples, n_quantiles).
192
+
193
+ Raises
194
+ ------
195
+ ValueError
196
+ If any quantile in `q` lies outside [0.0, 1.0].
197
+
198
+ """
199
+ q_arr = np.asarray(q, dtype=float)
200
+ if np.any((q_arr < 0.0) | (q_arr > 1.0)):
201
+ raise ValueError("All quantiles in 'q' must lie within [0.0, 1.0].")
202
+
203
+ cdf = self.cdf
204
+ if q_arr.ndim == 0:
205
+ indices = np.argmax(cdf >= q_arr, axis=1)
206
+ return self.classes[indices]
207
+
208
+ cdf_expanded = cdf[:, np.newaxis, :]
209
+ q_expanded = q_arr[np.newaxis, :, np.newaxis]
210
+ indices = np.argmax(cdf_expanded >= q_expanded, axis=2)
211
+ return self.classes[indices]
212
+
213
+
214
+ class ContinuousPredictiveDistribution(PredictiveDistribution):
215
+ """Encapsulates a continuous predictive Cumulative Distribution Function (CDF).
216
+
217
+ Provides vectorized utilities for computing expected continuous values,
218
+ medians, percent point functions (quantiles/PPF), central prediction
219
+ intervals, and continuous CDF probabilities across samples.
220
+
221
+ Parameters
222
+ ----------
223
+ grid_y : np.ndarray
224
+ 1D float array of shape (n_grid_points,) representing continuous physical
225
+ target grid values in strictly ascending order.
226
+ grid_cdf : np.ndarray
227
+ 2D float array of shape (n_samples, n_grid_points) containing evaluated
228
+ cumulative probabilities across grid points.
229
+
230
+ Attributes
231
+ ----------
232
+ grid_y : np.ndarray
233
+ 1D float array containing grid values.
234
+ grid_cdf : np.ndarray
235
+ 2D float array containing cumulative probabilities bounded in [0.0, 1.0].
236
+
237
+ Methods
238
+ -------
239
+ mean()
240
+ Calculate expected continuous values via numerical integration.
241
+ ppf(q)
242
+ Calculate percent point function (inverse CDF / quantiles).
243
+ cdf(y)
244
+ Evaluate continuous CDF probability P(Y <= y) at physical value y.
245
+
246
+ Raises
247
+ ------
248
+ ValueError
249
+ If `grid_y` is not 1D, `grid_cdf` is not 2D, or shape dimensions mismatch.
250
+
251
+ """
252
+
253
+ def __init__(self, grid_y: np.ndarray, grid_cdf: np.ndarray) -> None:
254
+ y_arr = np.asarray(grid_y, dtype=float)
255
+ cdf_arr = np.asarray(grid_cdf, dtype=float)
256
+
257
+ if y_arr.ndim != 1 or cdf_arr.ndim != 2:
258
+ raise ValueError("Invalid array dimensions for grid_y or grid_cdf.")
259
+ if cdf_arr.shape[1] != y_arr.shape[0]:
260
+ raise ValueError("Grid CDF column dimension must match grid_y length.")
261
+
262
+ self.grid_y = y_arr
263
+ self.grid_cdf = np.clip(cdf_arr, 0.0, 1.0)
264
+ self._n_samples = cdf_arr.shape[0]
265
+
266
+ def mean(self) -> np.ndarray:
267
+ """Calculate expected continuous values via numerical integration.
268
+
269
+ Returns
270
+ -------
271
+ np.ndarray
272
+ 1D array of shape (n_samples,) containing expected physical values.
273
+
274
+ """
275
+ dy = np.diff(self.grid_y)
276
+ avg_prob = 1.0 - 0.5 * (self.grid_cdf[:, :-1] + self.grid_cdf[:, 1:])
277
+ return np.sum(avg_prob * dy, axis=1) + self.grid_y[0]
278
+
279
+ def ppf(self, q: Union[float, ArrayLike]) -> np.ndarray:
280
+ """Calculate continuous interpolated values at quantile level `q`.
281
+
282
+ Parameters
283
+ ----------
284
+ q : float | ArrayLike
285
+ Quantile level(s) strictly in the range [0.0, 1.0].
286
+
287
+ Returns
288
+ -------
289
+ np.ndarray
290
+ If `q` is a scalar, returns a 1D array of shape (n_samples,).
291
+ If `q` is a 1D array of length `n_quantiles`, returns a 2D array
292
+ of shape (n_samples, n_quantiles).
293
+
294
+ Raises
295
+ ------
296
+ ValueError
297
+ If any quantile in `q` lies outside [0.0, 1.0].
298
+ If `q` is not a 1D array or a float.
299
+
300
+ """
301
+ q_arr = np.asarray(q, dtype=float)
302
+ if np.any((q_arr < 0.0) | (q_arr > 1.0)):
303
+ raise ValueError("All quantiles in 'q' must lie within [0.0, 1.0].")
304
+
305
+ n_samples, n_grid = self.grid_cdf.shape
306
+
307
+ # 1. Scalar quantile query -> returns shape (n_samples,)
308
+ if q_arr.ndim == 0:
309
+ q_val = q_arr.item()
310
+ idx = np.clip(
311
+ np.count_nonzero(self.grid_cdf <= q_val, axis=1) - 1,
312
+ 0,
313
+ n_grid - 2,
314
+ )
315
+ rows = np.arange(n_samples)
316
+ q0, q1 = self.grid_cdf[rows, idx], self.grid_cdf[rows, idx + 1]
317
+ t = np.clip((q_val - q0) / (q1 - q0), 0.0, 1.0)
318
+ return (1.0 - t) * self.grid_y[idx] + t * self.grid_y[idx + 1]
319
+
320
+ # 2. Array quantile query -> returns shape (n_samples, n_quantiles)
321
+ if q_arr.ndim == 1:
322
+ grid_cdf_ = self.grid_cdf[:, np.newaxis, :]
323
+ grid_q_arr = q_arr[np.newaxis, :, np.newaxis]
324
+
325
+ idx = np.clip(
326
+ np.count_nonzero(grid_cdf_ <= grid_q_arr, axis=2) - 1,
327
+ 0,
328
+ n_grid - 2,
329
+ )
330
+ q0 = np.take_along_axis(self.grid_cdf, idx, axis=1)
331
+ q1 = np.take_along_axis(self.grid_cdf, idx + 1, axis=1)
332
+ t = np.clip((q_arr[np.newaxis, :] - q0) / (q1 - q0), 0.0, 1.0)
333
+ return (1.0 - t) * self.grid_y[idx] + t * self.grid_y[idx + 1]
334
+
335
+ raise ValueError("Quantile 'q' must be a scalar float or a 1D array.")
336
+
337
+ def cdf(self, y: Union[float, ArrayLike]) -> np.ndarray:
338
+ """Evaluate continuous CDF probability P(Y <= y) at physical value(s) y.
339
+
340
+ Parameters
341
+ ----------
342
+ y : float | ArrayLike
343
+ If a scalar float, evaluates P(Y <= y) at y for all samples.
344
+ If a 1D array of shape (n_samples,), evaluates P(Y_i <= y_i)
345
+ sample-wise for each corresponding sample i.
346
+
347
+ Returns
348
+ -------
349
+ np.ndarray
350
+ 1D array of shape (n_samples,) containing evaluated probabilities.
351
+
352
+ Raises
353
+ ------
354
+ ValueError
355
+ If y is an array and not of shape (n_samples,).
356
+ If y is not a scalar or a 1D array.
357
+
358
+ """
359
+ y_arr = np.asarray(y, dtype=float)
360
+ n_grid = len(self.grid_y)
361
+
362
+ # 1. Scalar query (same y for all samples)
363
+ if y_arr.ndim == 0:
364
+ idx = int(
365
+ np.clip(
366
+ np.searchsorted(self.grid_y, y_arr.item(), side="right") - 1,
367
+ 0,
368
+ n_grid - 2,
369
+ )
370
+ )
371
+ t = np.clip(
372
+ (y_arr.item() - self.grid_y[idx])
373
+ / (self.grid_y[idx + 1] - self.grid_y[idx]),
374
+ 0.0,
375
+ 1.0,
376
+ )
377
+ return (1.0 - t) * self.grid_cdf[:, idx] + t * self.grid_cdf[:, idx + 1]
378
+
379
+ # 2. Vectorized 1D query (sample-wise y_i)
380
+ if y_arr.ndim == 1:
381
+ if len(y_arr) != self._n_samples:
382
+ raise ValueError(
383
+ f"Expected 1D 'y' array of length {self._n_samples}, "
384
+ f"got {len(y_arr)}."
385
+ )
386
+ idx = np.clip(
387
+ np.searchsorted(self.grid_y, y_arr, side="right") - 1,
388
+ 0,
389
+ n_grid - 2,
390
+ )
391
+ y0, y1 = self.grid_y[idx], self.grid_y[idx + 1]
392
+ t = np.clip((y_arr - y0) / (y1 - y0), 0.0, 1.0)
393
+ rows = np.arange(self._n_samples)
394
+ return (1.0 - t) * self.grid_cdf[rows, idx] + t * self.grid_cdf[
395
+ rows, idx + 1
396
+ ]
397
+
398
+ raise ValueError("Parameter 'y' must be a scalar float or a 1D array.")