3535relative rate of convergence to the optimal bandwidth is only ``n^(-1/10)`` -- and
3636this shows up here as an interquartile range of a quarter to two fifths of the
3737median, which barely shrinks with n. That is a property of the method, not a
38- defect in this implementation, so the tests below gate the *median* over
39- replicates and characterise the spread rather than trying to bound it tightly.
38+ defect in this implementation, so the tests below characterise the spread rather
39+ than trying to bound it tightly.
40+
41+ **Where the tolerances come from.** Properties 1 and 2 are statements about a
42+ quantity with a known population value, so their gates are
43+ :func:`simcheck.assert_unbiased`: the estimate is computed once per replicate and
44+ compared to the truth against its own Monte Carlo standard error. The tolerance
45+ is therefore a function of ``REPS`` and of the estimator's own spread, and it
46+ tightens on its own when the replicate count rises -- which is why ``REPS`` is
47+ :func:`simcheck.reps_for` rather than a constant. The hand-picked ranges these
48+ replaced (``-0.28 < slope < -0.13``, ``0.75 < ratio < 1.35``) recorded neither
49+ the study they came from nor what would make them wrong.
50+
51+ Three numbers here are deliberately *not* simcheck gates, because they are not
52+ tolerances: the ``<= 1 + 1e-9`` bounds on efficiency are an exact mathematical
53+ fact plus float slack, and the efficiency floors and the doubling/halving factor
54+ are effect sizes -- how good a selection has to be, and how much worse a wrong
55+ one has to be -- for which no sampling distribution supplies a band. Each is
56+ marked where it appears.
4057"""
4158
4259from __future__ import annotations
4562
4663import numpy as np
4764import pytest
65+ from simcheck import MonteCarloResult , assert_unbiased , reps_for
4866
4967from hbw import kde_bandwidth , kde_evaluate , nw_bandwidth , nw_predict
5068
51- # Replicates per sample size. Enough to pin a median without the file becoming
52- # something nobody runs; the quantities gated below are stable at this size.
53- REPS = 25
69+ # Replicates per sample size, from simcheck's tier: 100 normally, 400 when
70+ # SIMCHECK_DEEP is set, and whatever SIMCHECK_REPS says beyond that. It was a
71+ # hardcoded 25, which is below simcheck's own FAST_REPS floor and is not enough
72+ # to resolve the rate to better than about +-0.15 -- roughly the width of the
73+ # range the old assertion allowed, so the gate could not have failed for any
74+ # reason short of a constant selector.
75+ REPS = reps_for ()
5476GRID = np .linspace (- 6.0 , 6.0 , 801 )
5577NORMAL_DENSITY = np .exp (- 0.5 * GRID ** 2 ) / np .sqrt (2 * np .pi )
5678
@@ -82,32 +104,87 @@ def _kde_ise(sample: np.ndarray, bandwidth: float) -> float:
82104 return float (np .trapezoid ((estimate - NORMAL_DENSITY ) ** 2 , GRID ))
83105
84106
107+ def _study (estimates : np .ndarray , truth : float ) -> MonteCarloResult :
108+ """Wrap per-replicate estimates as a simcheck study.
109+
110+ The selector reports a bandwidth and nothing else -- no standard error and no
111+ interval -- so those are recorded as absent rather than invented.
112+ ``assert_unbiased`` reads only the estimates and the truth.
113+
114+ Args:
115+ estimates: One estimate per replicate.
116+ truth: The population value it is being compared against.
117+
118+ Returns:
119+ MonteCarloResult: The study.
120+ """
121+ values = np .asarray (estimates , dtype = float )
122+ return MonteCarloResult (
123+ estimates = values ,
124+ standard_errors = np .full (values .shape , np .nan ),
125+ covered = None ,
126+ rejected = None ,
127+ truth = float (truth ),
128+ )
129+
130+
85131def _selected_bandwidths (
86132 sizes : Sequence [int ],
87133 draw : Callable [..., tuple ],
88134 select : Callable [..., float ],
89- ) -> tuple [list [float ], list [float ]]:
90- """Median selected bandwidth at each sample size.
135+ ) -> np .ndarray :
136+ """Selected bandwidth for every (replicate, sample size) pair.
137+
138+ Replicate ``i`` is seeded ``1000 + i`` at every size, so a *row* is one
139+ replicate followed across n. That is what makes a per-replicate log-log slope
140+ meaningful: each row yields one draw from the slope's sampling distribution,
141+ and the spread of those draws is the Monte Carlo standard error the gate
142+ needs. Collapsing to a median first, as this used to, leaves a single number
143+ with no measurable uncertainty and nothing to set a tolerance from.
91144
92145 Args:
93146 sizes: Sample sizes to sweep.
94147 draw: Callable ``(rng, n)`` returning the arguments for ``select``.
95148 select: Callable taking those arguments and returning a bandwidth.
96149
97150 Returns:
98- tuple: ``(medians, spreads)``, the second being IQR over median .
151+ np.ndarray: Shape ``(REPS, len(sizes))`` .
99152 """
100- medians , spreads = [], []
101- for n in sizes :
102- chosen = []
153+ chosen = np .empty ((REPS , len (sizes )))
154+ for column , n in enumerate (sizes ):
103155 for i in range (REPS ):
104156 rng = np .random .default_rng (1000 + i )
105- chosen .append (select (* draw (rng , n )))
106- chosen = np .asarray (chosen )
107- median = float (np .median (chosen ))
108- medians .append (median )
109- spreads .append (float (np .percentile (chosen , 75 ) - np .percentile (chosen , 25 )) / median )
110- return medians , spreads
157+ chosen [i , column ] = select (* draw (rng , n ))
158+ return chosen
159+
160+
161+ def _log_log_slopes (sizes : Sequence [int ], chosen : np .ndarray ) -> np .ndarray :
162+ """Per-replicate slope of ``log h`` on ``log n``.
163+
164+ Args:
165+ sizes: The sample sizes, matching the columns of ``chosen``.
166+ chosen: Selected bandwidths, shape ``(REPS, len(sizes))``.
167+
168+ Returns:
169+ np.ndarray: One slope per replicate.
170+ """
171+ return np .polyfit (np .log (sizes ), np .log (chosen .T ), 1 )[0 ]
172+
173+
174+ def _relative_spreads (chosen : np .ndarray ) -> list [float ]:
175+ """Interquartile range over median, at each sample size.
176+
177+ Args:
178+ chosen: Selected bandwidths, shape ``(REPS, len(sizes))``.
179+
180+ Returns:
181+ list of float: One relative spread per column.
182+ """
183+ return [
184+ float (np .percentile (column , 75 ) - np .percentile (column , 25 ))
185+ / float (np .median (column ))
186+ for column in chosen .T
187+ ]
111188
112189
113190# --------------------------------------------------------------------------
@@ -116,37 +193,43 @@ def _selected_bandwidths(
116193
117194
118195def test_the_kde_bandwidth_shrinks_at_the_theoretical_rate () -> None :
119- """``log h`` against ``log n`` must have slope near -1/5.
196+ """``log h`` against ``log n`` must have slope -1/5.
120197
121198 This is the cheapest property to state and the hardest to satisfy by
122199 accident: it constrains how the selector responds to sample size, which a
123- constant or a mis-scaled rule cannot fake. Measured -0.191.
200+ constant or a mis-scaled rule cannot fake.
201+
202+ The slope is estimated once per replicate and its mean tested against the
203+ exact ``-0.2`` by ``assert_unbiased``, so the tolerance is three Monte Carlo
204+ standard errors of that mean rather than the ``-0.28 < slope < -0.13`` this
205+ replaces. Measured: -0.190 at 100 replicates (0.5 standard errors from the
206+ truth) and -0.211 at 400 (1.3).
124207 """
125208 sizes = (100 , 200 , 400 , 800 , 1600 )
126- medians , _ = _selected_bandwidths (
209+ chosen = _selected_bandwidths (
127210 sizes ,
128211 lambda rng , n : (rng .standard_normal (n ),),
129212 lambda x : kde_bandwidth (x , max_n = None ),
130213 )
131214
132- slope = float (np .polyfit (np .log (sizes ), np .log (medians ), 1 )[0 ])
133- assert - 0.28 < slope < - 0.13 , (
134- f"bandwidth shrinks as n^({ slope :.3f} ); the second-order-kernel rate is n^(-0.2)"
135- )
215+ assert_unbiased (_study (_log_log_slopes (sizes , chosen ), - 0.2 ), "kde log-log slope" )
136216
137217
138218def test_the_regression_bandwidth_shrinks_at_the_theoretical_rate () -> None :
139- """Same rate for the Nadaraya-Watson selector. Measured -0.201."""
219+ """Same rate for the Nadaraya-Watson selector.
220+
221+ Measured -0.183 at 100 replicates (1.3 standard errors from -0.2) and -0.199
222+ at 400 (0.2).
223+ """
140224 sizes = (100 , 200 , 400 , 800 )
141225
142226 def draw (rng : np .random .Generator , n : int ) -> tuple :
143227 x = np .sort (rng .uniform (- 2.0 , 2.0 , n ))
144228 return x , np .sin (2.0 * x ) + 0.3 * rng .standard_normal (n )
145229
146- medians , _ = _selected_bandwidths (sizes , draw , lambda x , y : nw_bandwidth (x , y , max_n = None ))
230+ chosen = _selected_bandwidths (sizes , draw , lambda x , y : nw_bandwidth (x , y , max_n = None ))
147231
148- slope = float (np .polyfit (np .log (sizes ), np .log (medians ), 1 )[0 ])
149- assert - 0.28 < slope < - 0.13 , f"bandwidth shrinks as n^({ slope :.3f} ); the rate is n^(-0.2)"
232+ assert_unbiased (_study (_log_log_slopes (sizes , chosen ), - 0.2 ), "nw log-log slope" )
150233
151234
152235# --------------------------------------------------------------------------
@@ -156,15 +239,19 @@ def draw(rng: np.random.Generator, n: int) -> tuple:
156239
157240@pytest .mark .parametrize ("n" , [200 , 800 ])
158241def test_the_kde_bandwidth_matches_the_exact_gaussian_optimum (n : int ) -> None :
159- """Median selection must sit near the closed-form MISE optimum.
242+ """The selection must sit on the closed-form MISE optimum.
160243
161244 There is no tuning constant in the target: for a normal density and a
162- Gaussian kernel the MISE-optimal bandwidth is ``(4/3)^(1/5) s n^(-1/5)``.
245+ Gaussian kernel the MISE-optimal bandwidth is ``(4/3)^(1/5) s n^(-1/5)``, so
246+ the ratio of the selection to it has a population value of exactly one.
163247
164- The gate is on the median over replicates rather than a single draw, because
165- least-squares cross-validation is highly variable by nature -- its relative
166- rate of convergence to the optimum is only ``n^(-1/10)``. Measured ratios
167- across n from 100 to 1600: 0.93, 1.05, 1.08, 0.96, 1.01.
248+ The gate is on the mean ratio over replicates against three Monte Carlo
249+ standard errors of that mean, which is what least-squares cross-validation's
250+ variability -- its relative rate of convergence to the optimum is only
251+ ``n^(-1/10)`` -- makes the study able to say. Measured mean ratios: 1.00 at
252+ n=200 and 0.97 at n=800 over 100 replicates, 1.03 and 0.98 over 400. The
253+ ``0.75 < ratio < 1.35`` this replaces was wide enough that only a selector
254+ off by a third could trip it, at any replicate count.
168255
169256 Args:
170257 n: Sample size.
@@ -175,10 +262,10 @@ def test_the_kde_bandwidth_matches_the_exact_gaussian_optimum(n: int) -> None:
175262 for i in range (REPS )
176263 ]
177264 )
178- ratio = float (np .median (chosen )) / gaussian_mise_bandwidth (n )
179265
180- assert 0.75 < ratio < 1.35 , (
181- f"median bandwidth is { ratio :.3f} times the exact Gaussian MISE optimum at n={ n } "
266+ assert_unbiased (
267+ _study (chosen / gaussian_mise_bandwidth (n ), 1.0 ),
268+ f"kde bandwidth over the exact Gaussian MISE optimum at n={ n } " ,
182269 )
183270
184271
@@ -193,12 +280,20 @@ def test_the_spread_of_the_selection_is_reported_not_bounded_tightly() -> None:
193280
194281 What it does catch is a selector that has stopped responding to the data at
195282 all, which would show a spread of essentially zero.
283+
284+ Deliberately not a simcheck gate. An interquartile range over a median has no
285+ population value here and no tractable sampling distribution, so there is
286+ nothing for a band to be derived from; these two numbers describe observed
287+ behaviour and say so, which is a different thing from a tolerance that
288+ pretends to be a threshold.
196289 """
197290 sizes = (200 , 800 )
198- _ , spreads = _selected_bandwidths (
199- sizes ,
200- lambda rng , n : (rng .standard_normal (n ),),
201- lambda x : kde_bandwidth (x , max_n = None ),
291+ spreads = _relative_spreads (
292+ _selected_bandwidths (
293+ sizes ,
294+ lambda rng , n : (rng .standard_normal (n ),),
295+ lambda x : kde_bandwidth (x , max_n = None ),
296+ )
202297 )
203298
204299 for n , spread in zip (sizes , spreads , strict = True ):
@@ -222,9 +317,19 @@ def test_the_selected_bandwidth_is_close_to_the_best_available(n: int) -> None:
222317 achievable on that same sample, so a selector that hits the right rate and
223318 the right level but lands in a bad place would still be caught.
224319
225- Measured median efficiency: 0.78 at n=200 and 0.80 at n=800, rising to 0.82
226- by n=1600. It is well below 1 because a single sample's ISE-minimising
227- bandwidth is itself a moving target that no data-driven rule can match.
320+ Measured median efficiency: 0.79 at n=200 and 0.83 at n=800 over 100
321+ replicates, 0.81 and 0.86 over 400. It is well below 1 because a single
322+ sample's ISE-minimising bandwidth is itself a moving target that no
323+ data-driven rule can match.
324+
325+ The floor stays a chosen number, deliberately. Oracle efficiency has no
326+ closed-form population value at finite n -- only the asymptotic statement
327+ that it tends to 1 -- so there is no truth for ``assert_unbiased`` to test
328+ against and no rate for a binomial band to describe. What 0.55 encodes is an
329+ effect size: how far below the measured 0.79 a selector would have to fall
330+ before it is worth failing the build over. The upper check is not a tolerance
331+ either; efficiency exceeding 1 is arithmetically impossible, so ``1 + 1e-9``
332+ is an exact bound plus float slack and catches a broken oracle search.
228333
229334 Args:
230335 n: Sample size.
@@ -255,6 +360,14 @@ def test_a_deliberately_wrong_bandwidth_is_measurably_worse() -> None:
255360 If integrated squared error were insensitive to the bandwidth over the range
256361 that matters, the test above would pass for any selector at all. Doubling and
257362 halving the selection must both cost something real.
363+
364+ The 1.5 is an effect size rather than a tolerance, and stays. The question
365+ this test asks is not "is the increase larger than Monte Carlo noise" -- at
366+ these replicate counts the paired loss ratio sits 9 to 18 standard errors
367+ above 1, so a noise-based gate would pass an error measure that barely moved
368+ -- but "is the increase large enough that the efficiency test above can
369+ discriminate". Measured paired ratios: 4.1x for doubling and 1.8x for
370+ halving.
258371 """
259372 n = 400
260373 losses = {"selected" : [], "doubled" : [], "halved" : []}
@@ -285,6 +398,10 @@ def test_the_regression_bandwidth_is_close_to_the_best_available() -> None:
285398 outer quarter of the range, because Nadaraya-Watson is badly biased at the
286399 boundary for reasons that have nothing to do with the bandwidth rule and
287400 would otherwise dominate the comparison.
401+
402+ As above, the floor is an effect size and the ``1 + 1e-9`` an exact bound;
403+ neither is a Monte Carlo tolerance, and neither has a population value to be
404+ banded against.
288405 """
289406 n = 400
290407 grid = np .linspace (- 1.5 , 1.5 , 150 )
0 commit comments