Replace the hotspots confidence ladder with boolean arithmetic (#3737) - #3741
Open
brendancol wants to merge 2 commits into
Open
Replace the hotspots confidence ladder with boolean arithmetic (#3737)#3741brendancol wants to merge 2 commits into
brendancol wants to merge 2 commits into
Conversation
_calc_hotspots_numpy classified each Gi* z-score through a nine-branch if/elif ladder that recomputed abs(zscore) up to six times per cell. The p-value step in that ladder never changed the output: every p_value < threshold test is implied by the |z| test beside it, so the whole thing reduces to 99 for |z| > 2.58, 95 for 1.96 < |z| <= 2.58, 90 for 1.65 < |z| <= 1.96, else 0. Compute the confidence as 90*(az > 1.65) + 5*(az > 1.96) + 4*(az > 2.58) and the sign as (z > 0) - (z < 0). On a 2000x4000 float64 z-score array the classifier drops from ~18.1 ms to ~11.6 ms (random normal), ~18.3 ms to ~12.6 ms (smooth ramp) and ~18.9 ms to ~12.1 ms (10% NaN), with identical int8 results. NaN still classifies to 0 and +/-inf to +/-99. Add tests pinning the classification at each threshold and its float64 neighbours at both signs, and for NaN, +/-inf and signed zeros. The CUDA device function keeps the ladder.
brendancol
commented
Sep 4, 2026
brendancol
left a comment
Contributor
Author
There was a problem hiding this comment.
PR Review: Replace the hotspots confidence ladder with boolean arithmetic (#3737)
Blockers (must fix before merge)
None.
Suggestions (should fix, not blocking)
-
xrspatial/tests/test_focal.py:1544(test_hotspots_classifier_thresholds_3737) runs the classifier on float64, but every production caller feeds it float32 (_hotspots_numpycasts the z-scores atfocal.py:1553). Under numba the float32 value is promoted to float64 before the comparison, sonp.float32(1.65)sits just below1.65andnp.float32(1.96)just above it. That is the same behaviour as the old ladder, and the PR's A/B covered float32, but no test pins it. Add a float32 check, for example assert that_calc_hotspots_numpy(z.astype(np.float32))equals_calc_hotspots_numpy(z.astype(np.float32).astype(np.float64))for the same threshold grid, so a future dtype-specific change to the comparison cannot slip through.
Nits (optional improvements)
-
xrspatial/focal.py:1479wrapsconfidence = (90 * (az > 1.65) + 5 * (az > 1.96) + 4 * (az > 2.58))across two lines with parentheses. The expression fits on one line under the 100-column limit and reads better that way. -
xrspatial/focal.py:1472describes_gpu_hotspotsas still spelling out the ladder. If the GPU kernel is collapsed later, this comment goes stale; a shorter comment that states the three thresholds and leaves the GPU cross-reference out would survive that change.
What looks good
- The derivation in the PR body is right:
|z| > 2.58forcesp = 0.0099, and|z| > 1.96or|z| > 1.65forcep <= 0.0495, so eachp_value < ...test in the old ladder was already true whenever its|z|test was. The 1.29 and 2.33 thresholds never reached the output. hot_cold * confidencestays in int64 inside the kernel and is cast to int8 on store, and the largest magnitude is 99, so there is no overflow path.- NaN and +/-inf behaviour is identical to the ladder and now pinned by
test_hotspots_classifier_nonfinite_3737. test_hotspots_dask_cupy_matches_numpycompares the untouched CUDA ladder against the rewritten CPU classifier on standard-normal data, so the two implementations are cross-checked in the 90 and 95 bands on a CUDA box.- The labeler applied
performance, andFocalHotspotsinbenchmarks/benchmarks/focal.pyalready times this path.
Checklist
- Algorithm matches reference (same classification as the previous ladder, verified by A/B and by the derivation)
- All implemented backends produce consistent results (numpy/dask+numpy share the kernel; cupy/dask+cupy unchanged and parity-tested)
- NaN handling is correct
- Edge cases are covered by tests (threshold neighbours, NaN, inf, signed zero); float32 pin suggested above
- Dask chunk boundaries handled correctly (no change to the map_blocks wrapper)
- No premature materialization or unnecessary copies
- Benchmark exists
- README feature matrix: not applicable, no public API change
- Docstrings: not applicable, private kernel
The production callers hand the classifier float32 z-scores, but the new threshold test only ran on float64. Add a test that the float32 result matches the classification of the same values widened to float64, on a grid that hits every band at both signs. Put the confidence expression on one line and drop the cross-reference to the GPU kernel from the comment so it does not go stale if that kernel is collapsed later.
brendancol
commented
Sep 4, 2026
brendancol
left a comment
Contributor
Author
There was a problem hiding this comment.
PR Review: follow-up on b7d0446 (#3737)
Second pass after the review fixes.
Blockers (must fix before merge)
None.
Suggestions (should fix, not blocking)
None.
Nits (optional improvements)
None.
Disposition of the first-pass findings
- Float32 pin: fixed.
test_hotspots_classifier_float32_3737(xrspatial/tests/test_focal.py:1572) builds the threshold grid, casts to float32, widens it by one float32 ulp on each side, and asserts the classification equals the float64 widening of the same values. It also asserts all seven output bands appear, so the float32 grid really crosses every threshold at both signs. - One-line confidence expression: fixed at
xrspatial/focal.py:1478, 78 columns. - Comment cross-reference to
_gpu_hotspots: removed. The comment now states only the three thresholds and the NaN behaviour.
What looks good
- The A/B against the
origin/mainkernel was re-run after the reformat and stays bitwise equal on every input class; the timing is unchanged (about 0.61x to 0.66x of the old kernel on 2000x4000 float64). - Full
test_focal.pypasses (354 tests, GPU cases included).
No further changes requested.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3737
_calc_hotspots_numpyclassified each Gi* z-score through a nine-branchif/elifladder that recomputedabs(zscore)up to six times per cell. The p-value half of that ladder never changed the output: eachp_value < thresholdtest is implied by the|z|test beside it (|z| > 2.58forcesp = 0.0099,|z| > 1.96and|z| > 1.65both forcep <= 0.0495). The whole thing reduces to 99 for|z| > 2.58, 95 for1.96 < |z| <= 2.58, 90 for1.65 < |z| <= 1.96, else 0.confidence = 90*(az > 1.65) + 5*(az > 1.96) + 4*(az > 2.58)andhot_cold = (z > 0) - (z < 0). A comment above the code states the collapsed thresholds so nobody has to re-derive them. Output dtype (int8) and signature are unchanged._gpu_hotspotsstill uses the ladder. It is not on the timed CPU path and branch cost on the GPU is a separate question, so it is left alone here.Timings
_calc_hotspots_numpyalone, 2000x4000 float64 z-scores, median of 5 after warmup, 20-core host:Identical int8 results in every case.
Verification
The original function was extracted from
origin/maininto a scratch module and compared withnp.array_equalagainst the new one on:rng.normal(0, 1.5)in float64 and float32np.linspace(-4, 4, 2_000_001)sweep that crosses every threshold at both signs+/-1.29, 1.65, 1.96, 2.33, 2.58and their float64 neighbours vianp.nextafterAll equal. The threshold-neighbour and non-finite cases are now pytest tests (
test_hotspots_classifier_thresholds_3737,test_hotspots_classifier_nonfinite_3737) with hand-derived expected values.Backends: numpy and dask+numpy share this kernel and both benefit. cupy and dask+cupy use the untouched CUDA kernel.
Test plan
pytest xrspatial/tests/test_focal.py -x -q(353 passed, GPU tests included on a CUDA box)origin/mainimplementation on the inputs listed aboveNo benchmark change:
FocalHotspotsinbenchmarks/benchmarks/focal.pyalready covershotspots().