Skip to content

Commit e863b8a

Browse files
committed
new version + v0 paper
1 parent e776290 commit e863b8a

26 files changed

Lines changed: 3325 additions & 30 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.5.0] - 2025-04-04
6+
7+
### Added
8+
- Multivariate NW regression: `nw_bandwidth_mv()` and `loocv_mse_mv()`
9+
- Numba-accelerated multivariate NW: `loocv_mv_numba_gauss()`
10+
- Tests for multivariate NW (gradient accuracy, grid search comparison)
11+
- Benchmarks for multivariate KDE and NW
12+
13+
### Changed
14+
- Newton-Armijo optimization improved with step clipping for stability
15+
- README updated with multivariate NW example and API docs
16+
- Limitations section updated (removed "NW univariate only")
17+
518
## [0.2.0] - 2025-04-01
619

720
### Added

README.md

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[![Downloads](https://static.pepy.tech/badge/hbw)](https://pepy.tech/project/hbw)
77
[![Docs](https://img.shields.io/badge/docs-online-blue)](https://finite-sample.github.io/hbw)
88

9-
Fast kernel bandwidth selection via analytic Hessian Newton optimization.
9+
Fast kernel bandwidth selection via analytic Hessian Newton optimization. **16× faster for KDE, 25-49× faster for NW regression** at n≥10,000.
1010

1111
## Installation
1212

@@ -39,9 +39,25 @@ h = kde_bandwidth(x_large, max_n=5000, seed=42) # Uses 5000 random points
3939
from hbw import kde_bandwidth_mv
4040
X = np.random.randn(500, 2)
4141
h = kde_bandwidth_mv(X)
42-
print(f"Optimal 2D bandwidth: {h:.4f}")
42+
print(f"Optimal 2D KDE bandwidth: {h:.4f}")
43+
44+
# Multivariate NW regression (2D predictors)
45+
from hbw import nw_bandwidth_mv
46+
X = np.random.randn(500, 2)
47+
y = np.sin(X[:, 0]) + 0.5 * X[:, 1] + 0.3 * np.random.randn(500)
48+
h = nw_bandwidth_mv(X, y)
49+
print(f"Optimal 2D NW bandwidth: {h:.4f}")
4350
```
4451

52+
## When to Use Data-Driven Bandwidth Selection
53+
54+
Silverman's rule-of-thumb assumes your data is unimodal and roughly Gaussian. Use `hbw` when:
55+
56+
- **Multimodal distributions**: Silverman oversmooths multiple peaks into a single blob. LSCV adapts to reveal distinct modes.
57+
- **Non-Gaussian data**: Heavy tails or skewness cause Silverman to choose suboptimal bandwidths. Cross-validation optimizes for your actual data shape.
58+
- **Regression (Nadaraya-Watson)**: Silverman's rule is designed for density estimation, not the x-y relationship in regression. NW bandwidth selection requires data-driven LOOCV.
59+
- **Bootstrap/uncertainty quantification**: Each resample has different structure; rule-of-thumb bandwidths don't adapt. CV selection per resample is critical—and with Newton optimization, now practical.
60+
4561
## API Reference
4662

4763
### `kde_bandwidth(x, kernel="gauss", h0=None, max_n=5000, seed=None)`
@@ -106,11 +122,33 @@ Compute LSCV score, gradient, and Hessian for multivariate KDE.
106122

107123
**Returns:** `tuple[float, float, float]` - (score, gradient, hessian)
108124

125+
### `nw_bandwidth_mv(data, y, kernel="gauss", h0=None, max_n=3000, seed=None, standardize=True)`
126+
127+
Select optimal multivariate NW bandwidth via LOOCV-MSE minimization with product kernel.
128+
129+
| Parameter | Type | Description |
130+
|-----------|------|-------------|
131+
| `data` | array-like | Predictor values, shape (n, d) |
132+
| `y` | array-like | Response values |
133+
| `kernel` | str | `"gauss"`, `"epan"`, `"unif"`, `"biweight"`, `"triweight"`, or `"cosine"` |
134+
| `h0` | float | Initial bandwidth (default: Scott's rule) |
135+
| `max_n` | int | Subsample size for large data |
136+
| `seed` | int | Random seed |
137+
| `standardize` | bool | Standardize each predictor dimension to unit variance |
138+
139+
**Returns:** `float` - optimal isotropic bandwidth
140+
141+
### `loocv_mse_mv(data, y, h, kernel="gauss")`
142+
143+
Compute LOOCV-MSE, gradient, and Hessian for multivariate NW regression.
144+
145+
**Returns:** `tuple[float, float, float]` - (loss, gradient, hessian)
146+
109147
## How It Works
110148

111149
**Problem:** Cross-validation bandwidth selection requires O(n²) per evaluation. Grid search needs 50-100 evaluations.
112150

113-
**Solution:** We derive closed-form gradients *and* Hessians for the LSCV (KDE) and LOOCV-MSE (NW) objectives. This enables Newton optimization that converges in 6-12 evaluations—same optimum, 4-10x fewer evaluations.
151+
**Solution:** We derive closed-form gradients *and* Hessians for the LSCV (KDE) and LOOCV-MSE (NW) objectives. Newton optimization converges in 6-12 iterations, but the key insight is that each iteration shares O(n²) pairwise computations across objective, gradient, and Hessian—yielding speedups that grow with sample size (16× for KDE, 25-49× for NW at n≥10,000).
114152

115153
**Supported kernels:**
116154
- Gaussian: `K(u) = exp(-u²/2) / √(2π)`
@@ -124,37 +162,34 @@ For full mathematical details, see the [paper](ms/).
124162

125163
## Results
126164

127-
Newton-Armijo with analytic Hessian achieves identical accuracy to grid search with significant speedups. All implementations use Numba with parallel execution.
165+
Newton-Armijo with analytic Hessian achieves identical accuracy to grid search with speedups that grow with sample size. All implementations use Numba with parallel execution.
128166

129-
**KDE (n=5000):**
130-
| Kernel | Grid (50 pts) | Newton | Speedup |
131-
|--------|---------------|--------|---------|
132-
| Gaussian | 2614 ms | 502 ms | 5.2× |
133-
| Epanechnikov | 920 ms | 582 ms | 1.6× |
134-
| Biweight | 1111 ms | 754 ms | 1.5× |
135-
| Triweight | 1113 ms | 301 ms | 3.7× |
136-
| Cosine | 1591 ms | 1790 ms | 0.9× |
167+
**Speedup vs. Grid Search (Gaussian kernel):**
168+
| n | KDE Speedup | NW Speedup |
169+
|---|-------------|------------|
170+
| 100 | 0.3× | 1.1× |
171+
| 500 | 4.2× | 7.8× |
172+
| 2,000 | 7.9× | 28× |
173+
| 5,000 | 8.7× | 26× |
174+
| 10,000 | 16× | 25× |
175+
| 20,000 || 49× |
137176

138-
**NW Regression (n=5000):**
139-
| Kernel | Grid (50 pts) | Newton | Speedup |
140-
|--------|---------------|--------|---------|
141-
| Gaussian | 1663 ms | 586 ms | 2.8× |
142-
| Epanechnikov | 574 ms | 214 ms | 2.7× |
143-
| Biweight | 580 ms | 159 ms | 3.7× |
144-
| Triweight | 579 ms | 95 ms | 6.1× |
145-
| Cosine | 716 ms | 105 ms | 6.8× |
146-
147-
**Bootstrap use case**: For 200 bootstrap resamples at n=1000, Newton saves significant computation time.
177+
**Bootstrap use case**: 200 resamples at n=10,000 takes ~100 minutes with grid search vs. ~4 minutes with Newton.
148178

149179
Tested across sample sizes, noise levels, four DGPs (bimodal, unimodal, skewed, heavy-tailed), and all six kernels. See [ms/](ms/) for full details.
150180

181+
## Limitations
182+
183+
- **Multivariate KDE/NW**: Isotropic bandwidth only (same h in all dimensions)
184+
- **Not supported**: Anisotropic bandwidth (dimension-specific bandwidths), local/adaptive bandwidth selection
185+
151186
## Citation
152187

153188
```bibtex
154-
@misc{hbw2024,
189+
@misc{hbw2025,
155190
author = {Sood, Gaurav},
156191
title = {Analytic-Hessian Bandwidth Selection for Kernel Density Estimation and Nadaraya-Watson Regression},
157-
year = {2024},
192+
year = {2025},
158193
url = {https://github.com/finite-sample/hbw}
159194
}
160195
```

benchmarks/benchmark_speedup.py

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
#!/usr/bin/env python
2+
"""Benchmark Newton vs Grid Search implementations across sample sizes and kernels."""
3+
4+
import time
5+
from typing import Any
6+
7+
import numpy as np
8+
9+
from hbw._numba_kde import (
10+
lscv_mv_numba_gauss,
11+
lscv_score_numba_biweight,
12+
lscv_score_numba_cosine,
13+
lscv_score_numba_epan,
14+
lscv_score_numba_gauss,
15+
lscv_score_numba_triweight,
16+
lscv_score_numba_unif,
17+
)
18+
from hbw._numba_kde import warmup as warmup_kde
19+
from hbw._numba_nw import (
20+
loocv_mv_numba_gauss,
21+
loocv_score_mv_numba_gauss,
22+
loocv_score_numba_biweight,
23+
loocv_score_numba_cosine,
24+
loocv_score_numba_epan,
25+
loocv_score_numba_gauss,
26+
loocv_score_numba_triweight,
27+
loocv_score_numba_unif,
28+
)
29+
from hbw._numba_nw import warmup as warmup_nw
30+
from hbw._optim import _silverman_h
31+
from hbw.kde import kde_bandwidth, kde_bandwidth_mv, lscv_mv
32+
from hbw.nw import loocv_mse_mv, nw_bandwidth, nw_bandwidth_mv
33+
34+
KERNELS = ["gauss", "epan", "unif", "biweight", "triweight", "cosine"]
35+
36+
KDE_SCORE_FUNCS = {
37+
"gauss": lscv_score_numba_gauss,
38+
"epan": lscv_score_numba_epan,
39+
"unif": lscv_score_numba_unif,
40+
"biweight": lscv_score_numba_biweight,
41+
"triweight": lscv_score_numba_triweight,
42+
"cosine": lscv_score_numba_cosine,
43+
}
44+
45+
NW_SCORE_FUNCS = {
46+
"gauss": loocv_score_numba_gauss,
47+
"epan": loocv_score_numba_epan,
48+
"unif": loocv_score_numba_unif,
49+
"biweight": loocv_score_numba_biweight,
50+
"triweight": loocv_score_numba_triweight,
51+
"cosine": loocv_score_numba_cosine,
52+
}
53+
54+
55+
def grid_search_kde(x: np.ndarray, kernel: str, n_grid: int = 50) -> float:
56+
"""Grid search using Numba-accelerated score function."""
57+
h0 = _silverman_h(x, kernel)
58+
h_grid = np.logspace(np.log10(h0 * 0.1), np.log10(h0 * 3), n_grid)
59+
score_fn = KDE_SCORE_FUNCS[kernel]
60+
scores = [score_fn(x, h) for h in h_grid]
61+
return h_grid[np.argmin(scores)]
62+
63+
64+
def grid_search_nw(x: np.ndarray, y: np.ndarray, kernel: str, n_grid: int = 50) -> float:
65+
"""Grid search using Numba-accelerated score function."""
66+
h0 = _silverman_h(x, kernel)
67+
h_grid = np.logspace(np.log10(h0 * 0.1), np.log10(h0 * 3), n_grid)
68+
score_fn = NW_SCORE_FUNCS[kernel]
69+
scores = [score_fn(x, y, h) for h in h_grid]
70+
return h_grid[np.argmin(scores)]
71+
72+
73+
def benchmark_fn(
74+
fn: Any, *args: Any, warmup_runs: int = 1, timed_runs: int = 3, **kwargs: Any
75+
) -> float:
76+
"""Benchmark a function, return median time in milliseconds."""
77+
for _ in range(warmup_runs):
78+
fn(*args, **kwargs)
79+
80+
times = []
81+
for _ in range(timed_runs):
82+
start = time.perf_counter()
83+
fn(*args, **kwargs)
84+
times.append(time.perf_counter() - start)
85+
86+
return float(1000 * np.median(times))
87+
88+
89+
def run_kde_benchmarks(rng: np.random.Generator) -> None:
90+
"""Run KDE benchmarks comparing Newton vs Grid Search across kernels."""
91+
print("\n" + "=" * 80)
92+
print("KDE BANDWIDTH SELECTION: NEWTON vs GRID SEARCH")
93+
print("=" * 80)
94+
print("\nCompares Newton optimization vs Grid Search (50 points).")
95+
print("Both methods use Numba-accelerated score functions.\n")
96+
97+
sample_sizes = [1000, 2000, 5000]
98+
99+
for n in sample_sizes:
100+
print(f"\n--- n = {n} ---")
101+
print(f"{'Kernel':<12} | {'Grid (50 pts)':<14} | {'Newton':<10} | {'Speedup':<10}")
102+
print("-" * 55)
103+
104+
x = rng.standard_normal(n)
105+
106+
for kernel in KERNELS:
107+
t_grid = benchmark_fn(grid_search_kde, x, kernel, n_grid=50)
108+
t_newton = benchmark_fn(kde_bandwidth, x, kernel=kernel, max_n=None)
109+
speedup = t_grid / t_newton
110+
print(f"{kernel:<12} | {t_grid:>10.1f} ms | {t_newton:>6.1f} ms | {speedup:>6.1f}x")
111+
112+
113+
def run_nw_benchmarks(rng: np.random.Generator) -> None:
114+
"""Run NW benchmarks comparing Newton vs Grid Search across kernels."""
115+
print("\n" + "=" * 80)
116+
print("NW REGRESSION BANDWIDTH SELECTION: NEWTON vs GRID SEARCH")
117+
print("=" * 80)
118+
print("\nCompares Newton optimization vs Grid Search (50 points).")
119+
print("Both methods use Numba-accelerated score functions.\n")
120+
121+
sample_sizes = [1000, 2000, 5000]
122+
123+
for n in sample_sizes:
124+
print(f"\n--- n = {n} ---")
125+
print(f"{'Kernel':<12} | {'Grid (50 pts)':<14} | {'Newton':<10} | {'Speedup':<10}")
126+
print("-" * 55)
127+
128+
x = rng.uniform(-3, 3, n)
129+
y = np.sin(x) + 0.3 * rng.standard_normal(n)
130+
131+
for kernel in KERNELS:
132+
t_grid = benchmark_fn(grid_search_nw, x, y, kernel, n_grid=50)
133+
t_newton = benchmark_fn(nw_bandwidth, x, y, kernel=kernel, max_n=None)
134+
speedup = t_grid / t_newton
135+
print(f"{kernel:<12} | {t_grid:>10.1f} ms | {t_newton:>6.1f} ms | {speedup:>6.1f}x")
136+
137+
138+
def grid_search_kde_mv(data: np.ndarray, kernel: str, n_grid: int = 50) -> float:
139+
"""Grid search for multivariate KDE bandwidth using Numba-accelerated score function."""
140+
n, d = data.shape
141+
std_avg = float(np.mean(np.std(data, axis=0, ddof=1)))
142+
h_init = std_avg * n ** (-1.0 / (d + 4))
143+
h_grid = np.logspace(np.log10(h_init * 0.1), np.log10(h_init * 3), n_grid)
144+
if kernel == "gauss":
145+
scores = [lscv_mv_numba_gauss(data, h)[0] for h in h_grid]
146+
else:
147+
scores = [lscv_mv(data, h, kernel)[0] for h in h_grid]
148+
return h_grid[np.argmin(scores)]
149+
150+
151+
def grid_search_nw_mv(data: np.ndarray, y: np.ndarray, kernel: str, n_grid: int = 50) -> float:
152+
"""Grid search for multivariate NW bandwidth using Numba-accelerated score function."""
153+
n, d = data.shape
154+
std_avg = float(np.mean(np.std(data, axis=0, ddof=1)))
155+
h_init = std_avg * n ** (-1.0 / (d + 4))
156+
h_grid = np.logspace(np.log10(h_init * 0.1), np.log10(h_init * 3), n_grid)
157+
if kernel == "gauss":
158+
scores = [loocv_score_mv_numba_gauss(data, y, h) for h in h_grid]
159+
else:
160+
scores = [loocv_mse_mv(data, y, h, kernel)[0] for h in h_grid]
161+
return h_grid[np.argmin(scores)]
162+
163+
164+
def run_kde_mv_benchmarks(rng: np.random.Generator) -> None:
165+
"""Run multivariate KDE benchmarks comparing Newton vs Grid Search."""
166+
print("\n" + "=" * 80)
167+
print("MULTIVARIATE KDE BANDWIDTH SELECTION: NEWTON vs GRID SEARCH")
168+
print("=" * 80)
169+
print("\nCompares Newton optimization vs Grid Search (50 points).")
170+
print("Gaussian kernel only for multivariate.\n")
171+
172+
sample_sizes = [500, 1000]
173+
dims = [2, 3]
174+
175+
for n in sample_sizes:
176+
for d in dims:
177+
print(f"\n--- n = {n}, d = {d} ---")
178+
print(f"{'Kernel':<12} | {'Grid (50 pts)':<14} | {'Newton':<10} | {'Speedup':<10}")
179+
print("-" * 55)
180+
181+
data = rng.standard_normal((n, d))
182+
183+
t_grid = benchmark_fn(grid_search_kde_mv, data, "gauss", n_grid=50)
184+
t_newton = benchmark_fn(kde_bandwidth_mv, data, kernel="gauss", max_n=None)
185+
speedup = t_grid / t_newton
186+
print(f"{'gauss':<12} | {t_grid:>10.1f} ms | {t_newton:>6.1f} ms | {speedup:>6.1f}x")
187+
188+
189+
def run_nw_mv_benchmarks(rng: np.random.Generator) -> None:
190+
"""Run multivariate NW benchmarks comparing Newton vs Grid Search."""
191+
print("\n" + "=" * 80)
192+
print("MULTIVARIATE NW REGRESSION BANDWIDTH SELECTION: NEWTON vs GRID SEARCH")
193+
print("=" * 80)
194+
print("\nCompares Newton optimization vs Grid Search (50 points).")
195+
print("Gaussian kernel only for multivariate.\n")
196+
197+
sample_sizes = [500, 1000]
198+
dims = [2, 3]
199+
200+
for n in sample_sizes:
201+
for d in dims:
202+
print(f"\n--- n = {n}, d = {d} ---")
203+
print(f"{'Kernel':<12} | {'Grid (50 pts)':<14} | {'Newton':<10} | {'Speedup':<10}")
204+
print("-" * 55)
205+
206+
data = rng.standard_normal((n, d))
207+
y = np.sin(data[:, 0]) + 0.5 * data[:, 1] + 0.3 * rng.standard_normal(n)
208+
209+
t_grid = benchmark_fn(grid_search_nw_mv, data, y, "gauss", n_grid=50)
210+
t_newton = benchmark_fn(nw_bandwidth_mv, data, y, kernel="gauss", max_n=None)
211+
speedup = t_grid / t_newton
212+
print(f"{'gauss':<12} | {t_grid:>10.1f} ms | {t_newton:>6.1f} ms | {speedup:>6.1f}x")
213+
214+
215+
def run_benchmarks() -> None:
216+
"""Run all benchmarks."""
217+
print("Warming up Numba JIT compilation...")
218+
warmup_kde()
219+
warmup_nw()
220+
print("Warmup complete.")
221+
222+
rng = np.random.default_rng(42)
223+
224+
run_kde_benchmarks(rng)
225+
run_nw_benchmarks(rng)
226+
run_kde_mv_benchmarks(rng)
227+
run_nw_mv_benchmarks(rng)
228+
229+
print("\n" + "=" * 80)
230+
print("SUMMARY")
231+
print("=" * 80)
232+
print("Newton optimization with analytic Hessian achieves the same optimum")
233+
print("as grid search with 3-8x fewer evaluations, leveraging Numba parallelization.")
234+
235+
236+
if __name__ == "__main__":
237+
run_benchmarks()

0 commit comments

Comments
 (0)