statgpu 0.1.0__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.
Files changed (168) hide show
  1. statgpu/__init__.py +174 -0
  2. statgpu/_base.py +544 -0
  3. statgpu/_config.py +127 -0
  4. statgpu/anova/__init__.py +5 -0
  5. statgpu/anova/_oneway.py +194 -0
  6. statgpu/backends/__init__.py +83 -0
  7. statgpu/backends/_array_ops.py +529 -0
  8. statgpu/backends/_base.py +184 -0
  9. statgpu/backends/_cupy.py +453 -0
  10. statgpu/backends/_factory.py +65 -0
  11. statgpu/backends/_gpu_inference_cupy.py +214 -0
  12. statgpu/backends/_gpu_inference_torch.py +422 -0
  13. statgpu/backends/_numpy.py +324 -0
  14. statgpu/backends/_torch.py +685 -0
  15. statgpu/backends/_torch_safe.py +47 -0
  16. statgpu/backends/_utils.py +423 -0
  17. statgpu/core/__init__.py +10 -0
  18. statgpu/core/formula/__init__.py +33 -0
  19. statgpu/core/formula/_design.py +99 -0
  20. statgpu/core/formula/_parser.py +191 -0
  21. statgpu/core/formula/_terms.py +70 -0
  22. statgpu/core/formula/tests/__init__.py +0 -0
  23. statgpu/core/formula/tests/test_parser.py +194 -0
  24. statgpu/covariance/__init__.py +6 -0
  25. statgpu/covariance/_empirical.py +310 -0
  26. statgpu/covariance/_shrinkage.py +248 -0
  27. statgpu/cross_validation/__init__.py +31 -0
  28. statgpu/cross_validation/_base.py +410 -0
  29. statgpu/cross_validation/_engine.py +167 -0
  30. statgpu/diagnostics/__init__.py +7 -0
  31. statgpu/diagnostics/_regression_diagnostics.py +188 -0
  32. statgpu/feature_selection/__init__.py +24 -0
  33. statgpu/feature_selection/_knockoff.py +870 -0
  34. statgpu/feature_selection/_knockoff_utils.py +1003 -0
  35. statgpu/feature_selection/_stepwise.py +300 -0
  36. statgpu/glm_core/__init__.py +81 -0
  37. statgpu/glm_core/_base.py +202 -0
  38. statgpu/glm_core/_family.py +362 -0
  39. statgpu/glm_core/_fused.py +149 -0
  40. statgpu/glm_core/_gamma.py +111 -0
  41. statgpu/glm_core/_inverse_gaussian.py +62 -0
  42. statgpu/glm_core/_irls.py +561 -0
  43. statgpu/glm_core/_logistic.py +82 -0
  44. statgpu/glm_core/_negative_binomial.py +68 -0
  45. statgpu/glm_core/_poisson.py +60 -0
  46. statgpu/glm_core/_solver_legacy.py +100 -0
  47. statgpu/glm_core/_squared.py +53 -0
  48. statgpu/glm_core/_tweedie.py +74 -0
  49. statgpu/inference/__init__.py +239 -0
  50. statgpu/inference/_distributions_backend.py +2610 -0
  51. statgpu/inference/_multiple_testing.py +391 -0
  52. statgpu/inference/_resampling.py +1400 -0
  53. statgpu/inference/_results.py +265 -0
  54. statgpu/linear_model/__init__.py +75 -0
  55. statgpu/linear_model/_gaussian_inference.py +306 -0
  56. statgpu/linear_model/_glm_base.py +1261 -0
  57. statgpu/linear_model/_ordered_logit.py +52 -0
  58. statgpu/linear_model/_ordered_probit.py +50 -0
  59. statgpu/linear_model/_stats.py +170 -0
  60. statgpu/linear_model/cv/__init__.py +13 -0
  61. statgpu/linear_model/cv/_elasticnet_cv.py +892 -0
  62. statgpu/linear_model/cv/_lasso_cv.py +253 -0
  63. statgpu/linear_model/cv/_logistic_cv.py +895 -0
  64. statgpu/linear_model/cv/_ridge_cv.py +1160 -0
  65. statgpu/linear_model/legacy/__init__.py +1 -0
  66. statgpu/linear_model/legacy/_distributions_legacy_gpu.py +340 -0
  67. statgpu/linear_model/legacy/_elasticnet_legacy.py +936 -0
  68. statgpu/linear_model/legacy/_lasso_legacy.py +4876 -0
  69. statgpu/linear_model/legacy/_penalized_legacy.py +1174 -0
  70. statgpu/linear_model/legacy/_ridge_legacy.py +863 -0
  71. statgpu/linear_model/legacy/_solver_legacy.py +104 -0
  72. statgpu/linear_model/penalized/__init__.py +25 -0
  73. statgpu/linear_model/penalized/_base.py +437 -0
  74. statgpu/linear_model/penalized/_fit_mixin.py +1877 -0
  75. statgpu/linear_model/penalized/_inference_mixin.py +1179 -0
  76. statgpu/linear_model/penalized/_penalized_cv.py +2699 -0
  77. statgpu/linear_model/penalized/_penalized_gamma.py +86 -0
  78. statgpu/linear_model/penalized/_penalized_inverse_gaussian.py +62 -0
  79. statgpu/linear_model/penalized/_penalized_linear.py +236 -0
  80. statgpu/linear_model/penalized/_penalized_logistic.py +100 -0
  81. statgpu/linear_model/penalized/_penalized_negative_binomial.py +65 -0
  82. statgpu/linear_model/penalized/_penalized_poisson.py +62 -0
  83. statgpu/linear_model/penalized/_penalized_tweedie.py +65 -0
  84. statgpu/linear_model/penalized/_predict_mixin.py +182 -0
  85. statgpu/linear_model/wrappers/__init__.py +31 -0
  86. statgpu/linear_model/wrappers/_adaptive_lasso.py +63 -0
  87. statgpu/linear_model/wrappers/_elasticnet.py +75 -0
  88. statgpu/linear_model/wrappers/_gamma.py +67 -0
  89. statgpu/linear_model/wrappers/_inverse_gaussian.py +47 -0
  90. statgpu/linear_model/wrappers/_lasso.py +2124 -0
  91. statgpu/linear_model/wrappers/_linear.py +1127 -0
  92. statgpu/linear_model/wrappers/_logistic.py +1435 -0
  93. statgpu/linear_model/wrappers/_mcp.py +58 -0
  94. statgpu/linear_model/wrappers/_negative_binomial.py +58 -0
  95. statgpu/linear_model/wrappers/_poisson.py +48 -0
  96. statgpu/linear_model/wrappers/_ridge.py +166 -0
  97. statgpu/linear_model/wrappers/_scad.py +58 -0
  98. statgpu/linear_model/wrappers/_tweedie.py +57 -0
  99. statgpu/metrics/__init__.py +21 -0
  100. statgpu/metrics/_classification.py +591 -0
  101. statgpu/nonparametric/__init__.py +50 -0
  102. statgpu/nonparametric/kernel_methods/__init__.py +25 -0
  103. statgpu/nonparametric/kernel_methods/_kernels.py +246 -0
  104. statgpu/nonparametric/kernel_methods/_krr.py +234 -0
  105. statgpu/nonparametric/kernel_methods/_krr_cv.py +380 -0
  106. statgpu/nonparametric/kernel_smoothing/__init__.py +39 -0
  107. statgpu/nonparametric/kernel_smoothing/_bandwidth_selection.py +1083 -0
  108. statgpu/nonparametric/kernel_smoothing/_kde.py +761 -0
  109. statgpu/nonparametric/kernel_smoothing/_kernel_common.py +348 -0
  110. statgpu/nonparametric/kernel_smoothing/_kernel_regression.py +748 -0
  111. statgpu/nonparametric/splines/__init__.py +5 -0
  112. statgpu/nonparametric/splines/_bspline_basis.py +336 -0
  113. statgpu/nonparametric/splines/_penalized.py +349 -0
  114. statgpu/panel/__init__.py +19 -0
  115. statgpu/panel/_covariance.py +140 -0
  116. statgpu/panel/_fixed_effects.py +420 -0
  117. statgpu/panel/_random_effects.py +385 -0
  118. statgpu/panel/_utils.py +482 -0
  119. statgpu/penalties/__init__.py +139 -0
  120. statgpu/penalties/_adaptive_l1.py +313 -0
  121. statgpu/penalties/_base.py +261 -0
  122. statgpu/penalties/_categories.py +39 -0
  123. statgpu/penalties/_elasticnet.py +98 -0
  124. statgpu/penalties/_group_lasso.py +678 -0
  125. statgpu/penalties/_group_mcp.py +553 -0
  126. statgpu/penalties/_group_scad.py +605 -0
  127. statgpu/penalties/_l1.py +107 -0
  128. statgpu/penalties/_l2.py +77 -0
  129. statgpu/penalties/_mcp.py +237 -0
  130. statgpu/penalties/_scad.py +260 -0
  131. statgpu/semiparametric/__init__.py +5 -0
  132. statgpu/semiparametric/_gam.py +401 -0
  133. statgpu/solvers/__init__.py +24 -0
  134. statgpu/solvers/_admm.py +241 -0
  135. statgpu/solvers/_constants.py +15 -0
  136. statgpu/solvers/_convergence.py +6 -0
  137. statgpu/solvers/_fista.py +436 -0
  138. statgpu/solvers/_fista_bb.py +513 -0
  139. statgpu/solvers/_fista_lla.py +541 -0
  140. statgpu/solvers/_lbfgs.py +206 -0
  141. statgpu/solvers/_newton.py +149 -0
  142. statgpu/solvers/_utils.py +277 -0
  143. statgpu/survival/__init__.py +14 -0
  144. statgpu/survival/_cox.py +3974 -0
  145. statgpu/survival/_cox_breslow_triton_kernel.py +106 -0
  146. statgpu/survival/_cox_cv.py +1159 -0
  147. statgpu/survival/_cox_efron_cuda.py +1280 -0
  148. statgpu/survival/_cox_efron_triton.py +359 -0
  149. statgpu/unsupervised/__init__.py +29 -0
  150. statgpu/unsupervised/_agglomerative.py +307 -0
  151. statgpu/unsupervised/_dbscan.py +263 -0
  152. statgpu/unsupervised/_dbscan_cpu.pyx +125 -0
  153. statgpu/unsupervised/_gmm.py +332 -0
  154. statgpu/unsupervised/_incremental_pca.py +176 -0
  155. statgpu/unsupervised/_kmeans.py +261 -0
  156. statgpu/unsupervised/_minibatch_kmeans.py +299 -0
  157. statgpu/unsupervised/_minibatch_nmf.py +252 -0
  158. statgpu/unsupervised/_nmf.py +190 -0
  159. statgpu/unsupervised/_pca.py +189 -0
  160. statgpu/unsupervised/_truncated_svd.py +132 -0
  161. statgpu/unsupervised/_tsne.py +192 -0
  162. statgpu/unsupervised/_umap.py +224 -0
  163. statgpu/unsupervised/_utils.py +134 -0
  164. statgpu-0.1.0.dist-info/METADATA +245 -0
  165. statgpu-0.1.0.dist-info/RECORD +168 -0
  166. statgpu-0.1.0.dist-info/WHEEL +5 -0
  167. statgpu-0.1.0.dist-info/licenses/LICENSE +199 -0
  168. statgpu-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,761 @@
1
+ """Kernel density estimation with NumPy/CuPy backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import math
7
+ from statistics import NormalDist
8
+ from typing import Any, Dict, Optional, Union
9
+
10
+ import numpy as np
11
+
12
+ from statgpu._base import BaseEstimator
13
+ from statgpu.backends import xp_asarray, xp_empty, xp_maximum
14
+
15
+
16
+ def _xp_max(x, **kwargs):
17
+ """Backend-safe max that returns values only (torch.max returns (values, indices))."""
18
+ # Use amax if available (returns values only, works for torch/cupy/numpy)
19
+ if hasattr(x, 'amax'):
20
+ return x.amax(**kwargs)
21
+ # Fallback: check if max returns (values, indices) tuple
22
+ result = x.max(**kwargs)
23
+ if hasattr(result, 'values'):
24
+ return result.values
25
+ return result
26
+ from statgpu.nonparametric.kernel_smoothing._bandwidth_selection import select_bandwidth
27
+
28
+ from statgpu.nonparametric.kernel_smoothing._kernel_common import (
29
+ _auto_backend_from_device,
30
+ _as_points_2d,
31
+ _as_samples_2d,
32
+ _effective_sample_size,
33
+ _get_xp,
34
+ _kernel_values_from_quad,
35
+ _normalize_kernel_name,
36
+ _normalize_weights,
37
+ _stable_inv_and_det,
38
+ _to_float_scalar,
39
+ _to_numpy,
40
+ _torch_device_from_data,
41
+ _weighted_covariance,
42
+ )
43
+
44
+
45
+ def _unit_ball_volume(n_features: int) -> float:
46
+ d = int(n_features)
47
+ if d <= 0:
48
+ raise ValueError("n_features must be a positive integer")
49
+ return float((math.pi ** (0.5 * d)) / math.gamma(0.5 * d + 1.0))
50
+
51
+
52
+ def _kernel_norm_const(kernel_name: str, n_features: int) -> float:
53
+ d = int(n_features)
54
+ if kernel_name == "gaussian":
55
+ return float((2.0 * math.pi) ** (-0.5 * d))
56
+
57
+ volume = _unit_ball_volume(d)
58
+ if kernel_name == "rectangular":
59
+ return float(1.0 / volume)
60
+ if kernel_name == "triangular":
61
+ return float((d + 1.0) / volume)
62
+ if kernel_name == "epanechnikov":
63
+ return float((d + 2.0) / (2.0 * volume))
64
+ if kernel_name == "biweight":
65
+ return float(((d + 2.0) * (d + 4.0)) / (8.0 * volume))
66
+ if kernel_name == "triweight":
67
+ return float(((d + 2.0) * (d + 4.0) * (d + 6.0)) / (48.0 * volume))
68
+ if kernel_name == "cosine":
69
+ if d != 1:
70
+ raise ValueError("kernel='cosine' currently supports only 1D samples")
71
+ return 1.0
72
+ if kernel_name == "optcosine":
73
+ if d != 1:
74
+ raise ValueError("kernel='optcosine' currently supports only 1D samples")
75
+ return float(math.pi / 4.0)
76
+
77
+ raise ValueError(f"Unsupported kernel: {kernel_name}")
78
+
79
+
80
+ class KernelDensityEstimator(BaseEstimator):
81
+ """sklearn-style kernel density estimator with class-owned fit/predict API."""
82
+
83
+ def __init__(
84
+ self,
85
+ *,
86
+ bandwidth: Union[str, float, int] = "scott",
87
+ weights=None,
88
+ kernel: str = "gaussian",
89
+ backend: str = "auto",
90
+ device: str = "auto",
91
+ n_jobs: Optional[int] = None,
92
+ gpu_memory_cleanup: bool = False,
93
+ ):
94
+ super().__init__(device=device, n_jobs=n_jobs)
95
+ self.bandwidth = bandwidth
96
+ self.weights = weights
97
+ self.kernel = kernel
98
+ self.backend = backend
99
+ self.gpu_memory_cleanup = gpu_memory_cleanup
100
+
101
+ def _resolve_backend_name(self, X) -> str:
102
+ name = str(self.backend).strip().lower()
103
+ if name != "auto":
104
+ return name
105
+ return _auto_backend_from_device(self._get_compute_device().value)
106
+
107
+ def fit(self, X, y=None):
108
+ backend_name = self._resolve_backend_name(X)
109
+ xp = _get_xp(backend_name)
110
+
111
+ samples_2d = _as_samples_2d(X, xp)
112
+ n_samples, n_features = int(samples_2d.shape[0]), int(samples_2d.shape[1])
113
+
114
+ device = _torch_device_from_data(samples_2d)
115
+ self._torch_device = device
116
+ weights_1d = _normalize_weights(self.weights, n_samples, xp, device=device, ref_arr=samples_2d)
117
+ n_eff = _effective_sample_size(weights_1d, xp)
118
+ kernel_name = _normalize_kernel_name(self.kernel)
119
+
120
+ data_cov = _weighted_covariance(samples_2d, weights_1d, xp)
121
+ if kernel_name in ("cosine", "optcosine") and n_features != 1:
122
+ raise ValueError(f"kernel='{kernel_name}' currently supports only 1D samples")
123
+
124
+ bw_result = None
125
+ if isinstance(self.bandwidth, str):
126
+ bw_result = select_bandwidth(
127
+ self.bandwidth,
128
+ n_eff=n_eff,
129
+ n_features=n_features,
130
+ samples_2d=samples_2d,
131
+ weights_1d=weights_1d,
132
+ data_cov=data_cov,
133
+ xp=xp,
134
+ enable_r_selectors=True,
135
+ estimator="kde",
136
+ )
137
+ factor = float(bw_result.factor)
138
+ else:
139
+ factor = float(self.bandwidth)
140
+ if (not np.isfinite(factor)) or factor <= 0.0:
141
+ raise ValueError("bandwidth factor must be a finite positive scalar")
142
+
143
+ scaled_cov = data_cov * (factor**2)
144
+ inv_cov, det_cov, stable_cov = _stable_inv_and_det(scaled_cov, xp)
145
+
146
+ kernel_norm_const = _kernel_norm_const(kernel_name, n_features)
147
+ if not np.isfinite(kernel_norm_const) or kernel_norm_const <= 0.0:
148
+ raise ValueError("kernel normalization constant must be finite and positive")
149
+ norm_const = np.sqrt(det_cov) / kernel_norm_const
150
+
151
+ self.samples_ = samples_2d
152
+ self.weights_ = weights_1d
153
+ self.bandwidth_factor_ = factor
154
+ self.bandwidth_info_ = bw_result
155
+ self.covariance_ = stable_cov
156
+ self.inv_covariance_ = inv_cov
157
+ self.norm_const_ = float(norm_const)
158
+ self.inv_norm_const_ = float(1.0 / self.norm_const_)
159
+ self.kernel_ = kernel_name
160
+ self.n_features_ = n_features
161
+ self.n_samples_ = n_samples
162
+ self.backend_ = backend_name
163
+ # Cache these terms for repeated evaluations to avoid recomputation in hot paths.
164
+ self._samples_proj_ = self.samples_ @ self.inv_covariance_
165
+ self._samples_quad_ = xp.sum(self._samples_proj_ * self.samples_, axis=1)
166
+ self._fitted = True
167
+ return self
168
+
169
+ def _require_fitted(self) -> None:
170
+ if not self._fitted:
171
+ raise RuntimeError("Estimator not fitted. Call fit() first.")
172
+
173
+ def _cleanup_cuda_memory(self):
174
+ if not self.gpu_memory_cleanup:
175
+ return
176
+ try:
177
+ import cupy as cp
178
+ cp.get_default_memory_pool().free_all_blocks()
179
+ cp.get_default_pinned_memory_pool().free_all_blocks()
180
+ except Exception:
181
+ pass
182
+
183
+ def _cleanup_torch_memory(self):
184
+ if not self.gpu_memory_cleanup:
185
+ return
186
+ try:
187
+ import torch
188
+ torch.cuda.empty_cache()
189
+ torch.cuda.synchronize()
190
+ except Exception:
191
+ pass
192
+
193
+ def __del__(self):
194
+ try:
195
+ self._cleanup_cuda_memory()
196
+ self._cleanup_torch_memory()
197
+ except Exception:
198
+ pass
199
+
200
+ def _evaluate_density(self, points_2d, *, batch_size: int, xp):
201
+ n_points = int(points_2d.shape[0])
202
+ n_samples = int(self.samples_.shape[0])
203
+ n_features = int(self.samples_.shape[1])
204
+
205
+ if batch_size <= 0:
206
+ raise ValueError("batch_size must be a positive integer")
207
+
208
+ out = xp_empty((n_points,), xp.float64, xp, ref_arr=points_2d)
209
+
210
+ if n_features == 1:
211
+ samples_1d = self.samples_[:, 0]
212
+ inv_scalar = self.inv_covariance_[0, 0]
213
+
214
+ if xp is np and self.kernel_ == "gaussian" and (n_points * n_samples) <= 8_000_000:
215
+ q_1d = points_2d[:, 0]
216
+ diff = q_1d[:, None] - samples_1d[None, :]
217
+ diff *= diff
218
+ diff *= (-0.5 * inv_scalar)
219
+ np.exp(diff, out=diff)
220
+ out[:] = (diff @ self.weights_) * self.inv_norm_const_
221
+ return out
222
+
223
+ if xp is np and (n_points * n_samples) <= 8_000_000:
224
+ q_1d = points_2d[:, 0]
225
+ diff = q_1d[:, None] - samples_1d[None, :]
226
+ quad = (diff * diff) * inv_scalar
227
+ kernels = _kernel_values_from_quad(quad, self.kernel_, xp)
228
+ out[:] = (kernels @ self.weights_) * self.inv_norm_const_
229
+ return out
230
+
231
+ for start in range(0, n_points, int(batch_size)):
232
+ stop = min(start + int(batch_size), n_points)
233
+ q_1d = points_2d[start:stop, 0]
234
+ diff = q_1d[:, None] - samples_1d[None, :]
235
+ if self.kernel_ == "gaussian":
236
+ diff *= diff
237
+ diff *= (-0.5 * inv_scalar)
238
+ xp.exp(diff, out=diff)
239
+ out[start:stop] = (diff @ self.weights_) * self.inv_norm_const_
240
+ else:
241
+ quad = (diff * diff) * inv_scalar
242
+ kernels = _kernel_values_from_quad(quad, self.kernel_, xp)
243
+ out[start:stop] = (kernels @ self.weights_) * self.inv_norm_const_
244
+ return out
245
+
246
+ s_quad = self._samples_quad_
247
+ is_gaussian = self.kernel_ == "gaussian"
248
+ use_log_sum_exp = n_features >= 8
249
+
250
+ for start in range(0, n_points, int(batch_size)):
251
+ stop = min(start + int(batch_size), n_points)
252
+ q = points_2d[start:stop]
253
+
254
+ q_proj = q @ self.inv_covariance_
255
+ q_quad = xp.sum(q_proj * q, axis=1)
256
+ cross = q_proj @ self.samples_.T
257
+ quad = q_quad[:, None] + s_quad[None, :] - 2.0 * cross
258
+ quad = xp_maximum(quad, 0.0, xp)
259
+
260
+ if use_log_sum_exp:
261
+ if is_gaussian:
262
+ log_kernels = -0.5 * quad
263
+ log_kernels_max = _xp_max(log_kernels, axis=1, keepdims=True)
264
+ log_sum = log_kernels_max[:, 0] + xp.log(
265
+ xp.sum(xp.exp(log_kernels - log_kernels_max) * self.weights_[None, :], axis=1)
266
+ )
267
+ out[start:stop] = xp.exp(log_sum) * self.inv_norm_const_
268
+ else:
269
+ kernels = _kernel_values_from_quad(quad, self.kernel_, xp)
270
+ log_sum = self._log_weighted_kernel_sum(kernels, xp)
271
+ out[start:stop] = xp.where(
272
+ xp.isfinite(log_sum),
273
+ xp.exp(log_sum) * self.inv_norm_const_,
274
+ 0.0,
275
+ )
276
+ else:
277
+ kernels = _kernel_values_from_quad(quad, self.kernel_, xp)
278
+ out[start:stop] = (kernels @ self.weights_) * self.inv_norm_const_
279
+
280
+ return out
281
+
282
+ def _log_weighted_kernel_sum(self, kernels, xp):
283
+ """Compute row-wise log(weighted kernel sum) with exact zero-density handling.
284
+
285
+ Parameters
286
+ ----------
287
+ kernels : array-like
288
+ Kernel values for each query/sample pair.
289
+ xp : module
290
+ Backend array module used for the computation.
291
+
292
+ Returns
293
+ -------
294
+ array-like
295
+ Per-row log-sum values, or ``-inf`` when all weighted kernel terms are zero.
296
+ """
297
+ positive_weight_mask = self.weights_[None, :] > 0.0
298
+ positive_term_mask = (kernels > 0.0) & positive_weight_mask
299
+ safe_kernels = xp.where(positive_term_mask, kernels, 1.0)
300
+ safe_weights = xp.where(positive_weight_mask, self.weights_[None, :], 1.0)
301
+ log_terms = xp.where(
302
+ positive_term_mask,
303
+ xp.log(safe_kernels) + xp.log(safe_weights),
304
+ float("-inf"),
305
+ )
306
+ log_terms_max = _xp_max(log_terms, axis=1, keepdims=True)
307
+ finite_rows = xp.isfinite(log_terms_max[:, 0])
308
+ shifted = xp.where(finite_rows[:, None], log_terms - log_terms_max, float("-inf"))
309
+ return xp.where(
310
+ finite_rows,
311
+ log_terms_max[:, 0] + xp.log(xp.sum(xp.exp(shifted), axis=1)),
312
+ float("-inf"),
313
+ )
314
+
315
+ def pdf(self, points, *, batch_size: int = 1024):
316
+ self._require_fitted()
317
+ xp = _get_xp(self.backend_)
318
+ points_2d = _as_points_2d(points, self.n_features_, xp, ref_arr=self.samples_)
319
+ result = self._evaluate_density(points_2d, batch_size=int(batch_size), xp=xp)
320
+ self._cleanup_cuda_memory()
321
+ self._cleanup_torch_memory()
322
+ return result
323
+
324
+ def _evaluate_log_density(self, points_2d, *, batch_size: int, xp):
325
+ """Evaluate log-density in log domain (avoids underflow for high dimensions)."""
326
+ if batch_size <= 0:
327
+ raise ValueError("batch_size must be a positive integer")
328
+
329
+ n_points = int(points_2d.shape[0])
330
+
331
+ s_quad = self._samples_quad_
332
+ log_norm = math.log(self.inv_norm_const_) if self.inv_norm_const_ > 0.0 else float("-inf")
333
+ is_gaussian = self.kernel_ == "gaussian"
334
+
335
+ out = xp_empty((n_points,), xp.float64, xp, ref_arr=points_2d)
336
+
337
+ for start in range(0, points_2d.shape[0], int(batch_size)):
338
+ stop = min(start + int(batch_size), points_2d.shape[0])
339
+ q = points_2d[start:stop]
340
+
341
+ q_proj = q @ self.inv_covariance_
342
+ q_quad = xp.sum(q_proj * q, axis=1)
343
+ cross = q_proj @ self.samples_.T
344
+ quad = q_quad[:, None] + s_quad[None, :] - 2.0 * cross
345
+ quad = xp_maximum(quad, 0.0, xp)
346
+
347
+ if is_gaussian:
348
+ log_kernels = -0.5 * quad
349
+ log_kernels_max = _xp_max(log_kernels, axis=1, keepdims=True)
350
+ log_kernels_shifted = log_kernels - log_kernels_max
351
+ log_sum = log_kernels_max[:, 0] + xp.log(
352
+ xp.sum(xp.exp(log_kernels_shifted) * self.weights_[None, :], axis=1)
353
+ )
354
+ out[start:stop] = log_sum + log_norm
355
+ else:
356
+ kernels = _kernel_values_from_quad(quad, self.kernel_, xp)
357
+ log_sum = self._log_weighted_kernel_sum(kernels, xp)
358
+ out[start:stop] = xp.where(
359
+ xp.isfinite(log_sum),
360
+ log_sum + log_norm,
361
+ float("-inf"),
362
+ )
363
+
364
+ return out
365
+
366
+ def logpdf(self, points, *, batch_size: int = 1024):
367
+ self._require_fitted()
368
+ xp = _get_xp(self.backend_)
369
+ points_2d = _as_points_2d(points, self.n_features_, xp, ref_arr=self.samples_)
370
+ result = self._evaluate_log_density(points_2d, batch_size=int(batch_size), xp=xp)
371
+ self._cleanup_cuda_memory()
372
+ self._cleanup_torch_memory()
373
+ return result
374
+
375
+ def __call__(self, points, *, batch_size: int = 1024):
376
+ return self.pdf(points, batch_size=batch_size)
377
+
378
+ def to_numpy_metadata(self):
379
+ self._require_fitted()
380
+ bandwidth_selection = None
381
+ if hasattr(self.bandwidth_info_, "to_dict"):
382
+ bandwidth_selection = self.bandwidth_info_.to_dict()
383
+ return {
384
+ "bandwidth_factor": float(self.bandwidth_factor_),
385
+ "bandwidth_selection": bandwidth_selection,
386
+ "n_samples": int(self.n_samples_),
387
+ "n_features": int(self.n_features_),
388
+ "backend": self.backend_,
389
+ "kernel": self.kernel_,
390
+ "covariance": _to_numpy(self.covariance_),
391
+ "inv_covariance": _to_numpy(self.inv_covariance_),
392
+ "weights": _to_numpy(self.weights_),
393
+ }
394
+
395
+ def predict(self, X):
396
+ return self.pdf(X)
397
+
398
+ def score_samples(self, X):
399
+ return self.logpdf(X)
400
+
401
+ def score(self, X, y=None):
402
+ vals = self.score_samples(X)
403
+ return float(np.mean(_to_numpy(vals)))
404
+
405
+
406
+ class KDE(KernelDensityEstimator):
407
+ """Alias class for KernelDensityEstimator."""
408
+
409
+
410
+ def fit_kde(
411
+ samples,
412
+ *,
413
+ bandwidth: Union[str, float, int] = "scott",
414
+ weights=None,
415
+ kernel: str = "gaussian",
416
+ backend: str = "auto",
417
+ ) -> KDE:
418
+ """Fit a KDE model.
419
+
420
+ Parameters
421
+ ----------
422
+ samples : array-like of shape (n_samples,) or (n_samples, n_features)
423
+ Training observations.
424
+ bandwidth : {'scott', 'silverman', 'nrd0', 'nrd', 'ucv', 'bcv', 'sj', 'sj-ste', 'sj-dpi'} or float, default='scott'
425
+ Bandwidth scaling factor mode or explicit positive factor.
426
+ weights : array-like of shape (n_samples,), optional
427
+ Non-negative sample weights. If omitted, uniform weights are used.
428
+ kernel : {'gaussian', 'rectangular', 'triangular', 'epanechnikov',
429
+ 'biweight', 'triweight', 'cosine', 'optcosine'}, default='gaussian'
430
+ Kernel function used for density estimation.
431
+ backend : {'auto', 'numpy', 'cupy'}, default='auto'
432
+ Compute backend. 'auto' selects from the estimator's configured
433
+ device/backend rather than inferring from input array types.
434
+
435
+ Returns
436
+ -------
437
+ KDE
438
+ Fitted KDE object.
439
+ """
440
+ model = KDE(
441
+ bandwidth=bandwidth,
442
+ weights=weights,
443
+ kernel=kernel,
444
+ backend=backend,
445
+ )
446
+ return model.fit(samples)
447
+
448
+
449
+ def kde_pdf(
450
+ samples,
451
+ points,
452
+ *,
453
+ bandwidth: Union[str, float, int] = "scott",
454
+ weights=None,
455
+ kernel: str = "gaussian",
456
+ backend: str = "auto",
457
+ return_log: bool = False,
458
+ batch_size: int = 1024,
459
+ ):
460
+ """One-shot Gaussian KDE evaluation.
461
+
462
+ This helper fits a KDE model and evaluates it at `points`.
463
+ """
464
+ model = fit_kde(
465
+ samples,
466
+ bandwidth=bandwidth,
467
+ weights=weights,
468
+ kernel=kernel,
469
+ backend=backend,
470
+ )
471
+ if return_log:
472
+ return model.logpdf(points, batch_size=batch_size)
473
+ return model.pdf(points, batch_size=batch_size)
474
+
475
+
476
+ @dataclass
477
+ class KDEBootstrapResult:
478
+ """Pointwise bootstrap confidence intervals for KDE estimates."""
479
+
480
+ points: np.ndarray
481
+ estimate: np.ndarray
482
+ lower: np.ndarray
483
+ upper: np.ndarray
484
+ confidence_level: float
485
+ n_resamples: int
486
+ random_state: Optional[int]
487
+ kernel: str
488
+ backend: str
489
+ metadata: Dict[str, Any]
490
+ bootstrap_samples: Optional[np.ndarray] = None
491
+
492
+ def to_dict(self) -> Dict[str, Any]:
493
+ payload = {
494
+ "points": np.asarray(self.points).tolist(),
495
+ "estimate": np.asarray(self.estimate).tolist(),
496
+ "lower": np.asarray(self.lower).tolist(),
497
+ "upper": np.asarray(self.upper).tolist(),
498
+ "confidence_level": float(self.confidence_level),
499
+ "n_resamples": int(self.n_resamples),
500
+ "random_state": self.random_state,
501
+ "kernel": self.kernel,
502
+ "backend": self.backend,
503
+ "metadata": self.metadata,
504
+ }
505
+ if self.bootstrap_samples is not None:
506
+ payload["bootstrap_samples"] = np.asarray(self.bootstrap_samples).tolist()
507
+ return payload
508
+
509
+
510
+ def _validate_confidence_level(confidence_level: float) -> float:
511
+ level = float(confidence_level)
512
+ if level <= 0.0 or level >= 1.0:
513
+ raise ValueError("confidence_level must be in (0, 1)")
514
+ return level
515
+
516
+
517
+ def _validate_n_resamples(n_resamples: int) -> int:
518
+ n = int(n_resamples)
519
+ if n <= 0:
520
+ raise ValueError("n_resamples must be a positive integer")
521
+ return n
522
+
523
+
524
+ def kde_bootstrap_confidence_interval(
525
+ samples,
526
+ points,
527
+ *,
528
+ bandwidth: Union[str, float, int] = "scott",
529
+ weights=None,
530
+ kernel: str = "gaussian",
531
+ backend: str = "auto",
532
+ n_resamples: int = 200,
533
+ confidence_level: float = 0.95,
534
+ random_state: Optional[int] = None,
535
+ method: str = "percentile",
536
+ return_bootstrap_samples: bool = False,
537
+ batch_size: int = 1024,
538
+ ) -> KDEBootstrapResult:
539
+ """Backward-compatible bootstrap CI wrapper for KDE.
540
+
541
+ This wrapper preserves the original API and delegates to
542
+ ``kde_confidence_interval(method='bootstrap')``.
543
+ """
544
+ return kde_confidence_interval(
545
+ samples,
546
+ points,
547
+ bandwidth=bandwidth,
548
+ weights=weights,
549
+ kernel=kernel,
550
+ backend=backend,
551
+ n_resamples=n_resamples,
552
+ confidence_level=confidence_level,
553
+ random_state=random_state,
554
+ method="bootstrap",
555
+ bootstrap_method=method,
556
+ return_bootstrap_samples=return_bootstrap_samples,
557
+ batch_size=batch_size,
558
+ )
559
+
560
+
561
+ def kde_confidence_interval(
562
+ samples,
563
+ points,
564
+ *,
565
+ bandwidth: Union[str, float, int] = "scott",
566
+ weights=None,
567
+ kernel: str = "gaussian",
568
+ backend: str = "auto",
569
+ n_resamples: int = 200,
570
+ confidence_level: float = 0.95,
571
+ random_state: Optional[int] = None,
572
+ method: str = "normal",
573
+ bootstrap_method: str = "percentile",
574
+ return_bootstrap_samples: bool = False,
575
+ batch_size: int = 1024,
576
+ ) -> KDEBootstrapResult:
577
+ """Estimate pointwise KDE confidence intervals.
578
+
579
+ Supported methods:
580
+ - ``normal``: asymptotic normal approximation (fast path, 1D Gaussian).
581
+ - ``bootstrap``: non-parametric bootstrap percentile intervals.
582
+ """
583
+ method_name = str(method).strip().lower()
584
+ if method_name not in ("normal", "bootstrap"):
585
+ raise ValueError("method must be one of: 'normal', 'bootstrap'")
586
+
587
+ bootstrap_method_name = str(bootstrap_method).strip().lower()
588
+ if bootstrap_method_name != "percentile":
589
+ raise ValueError("bootstrap_method must be 'percentile'")
590
+
591
+ level = _validate_confidence_level(confidence_level)
592
+ n_boot = _validate_n_resamples(n_resamples)
593
+ model = fit_kde(
594
+ samples,
595
+ bandwidth=bandwidth,
596
+ weights=weights,
597
+ kernel=kernel,
598
+ backend=backend,
599
+ )
600
+
601
+ xp = _get_xp(model.backend_)
602
+ points_2d = _as_points_2d(points, model.n_features_, xp)
603
+ estimate = np.asarray(_to_numpy(model.pdf(points_2d, batch_size=batch_size)), dtype=np.float64)
604
+
605
+ points_np = np.asarray(_to_numpy(points_2d), dtype=np.float64)
606
+ if model.n_features_ == 1 and points_np.ndim == 2 and int(points_np.shape[1]) == 1:
607
+ points_np = points_np.reshape(-1)
608
+
609
+ if method_name == "normal":
610
+ if model.n_features_ != 1 or model.kernel_ != "gaussian":
611
+ raise ValueError("method='normal' currently supports only 1D Gaussian KDE")
612
+
613
+ n_eff = float(_effective_sample_size(model.weights_, xp))
614
+ if (not np.isfinite(n_eff)) or n_eff <= 0.0:
615
+ raise ValueError("effective sample size must be finite and positive")
616
+
617
+ cov11 = float(_to_float_scalar(model.covariance_[0, 0]))
618
+ if (not np.isfinite(cov11)) or cov11 <= 0.0:
619
+ raise ValueError("covariance must be positive for normal CI")
620
+
621
+ h = math.sqrt(cov11)
622
+ if (not np.isfinite(h)) or h <= 0.0:
623
+ raise ValueError("bandwidth scale must be positive for normal CI")
624
+
625
+ r_kernel = 1.0 / (2.0 * math.sqrt(math.pi))
626
+ var = np.maximum(estimate, 0.0) * (r_kernel / (n_eff * h))
627
+ var = np.maximum(var, 0.0)
628
+ se = np.sqrt(var)
629
+
630
+ z = float(NormalDist().inv_cdf(0.5 + 0.5 * level))
631
+ lower = np.maximum(estimate - z * se, 0.0)
632
+ upper = estimate + z * se
633
+
634
+ return KDEBootstrapResult(
635
+ points=points_np,
636
+ estimate=estimate,
637
+ lower=lower,
638
+ upper=upper,
639
+ confidence_level=level,
640
+ n_resamples=0,
641
+ random_state=random_state,
642
+ kernel=_normalize_kernel_name(kernel),
643
+ backend=model.backend_,
644
+ metadata={
645
+ "method": method_name,
646
+ "bandwidth": bandwidth,
647
+ "batch_size": int(batch_size),
648
+ "n_features": int(model.n_features_),
649
+ "n_eff": float(n_eff),
650
+ },
651
+ bootstrap_samples=None,
652
+ )
653
+
654
+ samples_np = np.asarray(_to_numpy(_as_samples_2d(samples, xp)), dtype=np.float64)
655
+ n_samples = int(samples_np.shape[0])
656
+
657
+ weights_np = np.asarray(_to_numpy(_normalize_weights(weights, n_samples, xp)), dtype=np.float64)
658
+ rng = np.random.default_rng(random_state)
659
+ boot_samples = np.empty((n_boot, estimate.size), dtype=np.float64)
660
+
661
+ use_fast_1d_numpy = (
662
+ (xp is np)
663
+ and (model.n_features_ == 1)
664
+ and (model.kernel_ == "gaussian")
665
+ and np.isfinite(float(model.bandwidth_factor_))
666
+ and (float(model.bandwidth_factor_) > 0.0)
667
+ )
668
+
669
+ if use_fast_1d_numpy:
670
+ samples_1d = samples_np.reshape(-1)
671
+ points_1d = points_np.reshape(-1)
672
+ bw_factor = float(model.bandwidth_factor_)
673
+ sqrt_2pi = math.sqrt(2.0 * math.pi)
674
+
675
+ for i in range(n_boot):
676
+ idx = rng.choice(n_samples, size=n_samples, replace=True, p=weights_np)
677
+ sampled_x = samples_1d[idx]
678
+ sampled_w = weights_np[idx]
679
+
680
+ sampled_w_sum = float(np.sum(sampled_w))
681
+ if sampled_w_sum <= 0.0:
682
+ raise ValueError("bootstrap weights must sum to a positive value")
683
+ sampled_w = sampled_w / sampled_w_sum
684
+
685
+ mean = float(np.sum(sampled_w * sampled_x))
686
+ centered = sampled_x - mean
687
+ denom = 1.0 - float(np.sum(sampled_w * sampled_w))
688
+ if denom <= 1e-15:
689
+ raise ValueError("effective degrees of freedom is too small for covariance estimation")
690
+
691
+ var = float(np.sum(sampled_w * (centered * centered)) / denom)
692
+ if (not np.isfinite(var)) or var <= 0.0:
693
+ var = float(np.finfo(np.float64).tiny)
694
+ jitter = max(var * 1e-12, 1e-12)
695
+ var = var + jitter
696
+
697
+ scaled_var = var * (bw_factor * bw_factor)
698
+ inv_scalar = 1.0 / scaled_var
699
+ inv_norm_const = 1.0 / (math.sqrt(scaled_var) * sqrt_2pi)
700
+
701
+ diff = points_1d[:, None] - sampled_x[None, :]
702
+ diff *= diff
703
+ diff *= (-0.5 * inv_scalar)
704
+ np.exp(diff, out=diff)
705
+ boot_samples[i, :] = (diff @ sampled_w) * inv_norm_const
706
+ else:
707
+ for i in range(n_boot):
708
+ idx = rng.choice(n_samples, size=n_samples, replace=True, p=weights_np)
709
+ sampled_data = samples_np[idx]
710
+ sampled_weights = weights_np[idx]
711
+ sampled_weight_sum = float(np.sum(sampled_weights))
712
+ if sampled_weight_sum <= 0.0:
713
+ raise ValueError("bootstrap weights must sum to a positive value")
714
+ sampled_weights = sampled_weights / sampled_weight_sum
715
+
716
+ sampled_data_backend = sampled_data if xp is np else xp_asarray(sampled_data, dtype=xp.float64, xp=xp, ref_arr=points_2d)
717
+ sampled_weights_backend = sampled_weights if xp is np else xp_asarray(sampled_weights, dtype=xp.float64, xp=xp, ref_arr=points_2d)
718
+
719
+ boot_model = fit_kde(
720
+ sampled_data_backend,
721
+ bandwidth=bandwidth,
722
+ weights=sampled_weights_backend,
723
+ kernel=kernel,
724
+ backend=model.backend_,
725
+ )
726
+ boot_samples[i, :] = np.asarray(_to_numpy(boot_model.pdf(points_2d, batch_size=batch_size)), dtype=np.float64)
727
+
728
+ alpha = 1.0 - level
729
+ lower = np.quantile(boot_samples, alpha / 2.0, axis=0)
730
+ upper = np.quantile(boot_samples, 1.0 - alpha / 2.0, axis=0)
731
+
732
+ return KDEBootstrapResult(
733
+ points=points_np,
734
+ estimate=estimate,
735
+ lower=lower,
736
+ upper=upper,
737
+ confidence_level=level,
738
+ n_resamples=n_boot,
739
+ random_state=random_state,
740
+ kernel=_normalize_kernel_name(kernel),
741
+ backend=model.backend_,
742
+ metadata={
743
+ "method": method_name,
744
+ "bootstrap_method": bootstrap_method_name,
745
+ "bandwidth": bandwidth,
746
+ "batch_size": int(batch_size),
747
+ "n_features": int(model.n_features_),
748
+ },
749
+ bootstrap_samples=boot_samples if return_bootstrap_samples else None,
750
+ )
751
+
752
+
753
+ __all__ = [
754
+ "KernelDensityEstimator",
755
+ "KDE",
756
+ "KDEBootstrapResult",
757
+ "fit_kde",
758
+ "kde_pdf",
759
+ "kde_confidence_interval",
760
+ "kde_bootstrap_confidence_interval",
761
+ ]