Skip to content

Commit b44ac7d

Browse files
committed
Replace the hotspots confidence ladder with boolean arithmetic (#3737)
_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.
1 parent 1dbeb02 commit b44ac7d

2 files changed

Lines changed: 50 additions & 23 deletions

File tree

xrspatial/focal.py

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1469,29 +1469,16 @@ def _calc_hotspots_numpy(z_array):
14691469
for x in prange(cols):
14701470
zscore = z_array[y, x]
14711471

1472-
# find p value
1473-
p_value = 1.0
1474-
if abs(zscore) >= 2.33:
1475-
p_value = 0.0099
1476-
elif abs(zscore) >= 1.65:
1477-
p_value = 0.0495
1478-
elif abs(zscore) >= 1.29:
1479-
p_value = 0.0985
1480-
1481-
# confidence
1482-
confidence = 0
1483-
if abs(zscore) > 2.58 and p_value < 0.01:
1484-
confidence = 99
1485-
elif abs(zscore) > 1.96 and p_value < 0.05:
1486-
confidence = 95
1487-
elif abs(zscore) > 1.65 and p_value < 0.1:
1488-
confidence = 90
1489-
1490-
hot_cold = 0
1491-
if zscore > 0:
1492-
hot_cold = 1
1493-
elif zscore < 0:
1494-
hot_cold = -1
1472+
# Confidence is 99 for |z| > 2.58, 95 for 1.96 < |z| <= 2.58,
1473+
# 90 for 1.65 < |z| <= 1.96, else 0. The GPU twin
1474+
# (_gpu_hotspots) spells this out as a p-value / confidence
1475+
# ladder; the p-value tests there are implied by the |z|
1476+
# tests, so the ladder collapses to these three thresholds.
1477+
# NaN compares False everywhere and classifies to 0.
1478+
az = abs(zscore)
1479+
confidence = (90 * (az > 1.65) + 5 * (az > 1.96)
1480+
+ 4 * (az > 2.58))
1481+
hot_cold = (zscore > 0) - (zscore < 0)
14951482

14961483
out[y, x] = hot_cold * confidence
14971484
return out

xrspatial/tests/test_focal.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,46 @@ def test_hotspots_dask_cupy():
15411541
dask_cupy_hotspots.data[pad:-pad, pad:-pad].compute().get())
15421542

15431543

1544+
def test_hotspots_classifier_thresholds_3737():
1545+
# Regression for #3737: the z-score classifier was rewritten from a
1546+
# nine-branch threshold ladder into boolean arithmetic. Pin the
1547+
# classification at every threshold and its float64 neighbours, at
1548+
# both signs, so the open/closed side of each interval cannot drift.
1549+
from xrspatial.focal import _calc_hotspots_numpy
1550+
1551+
thresholds = np.array([1.29, 1.65, 1.96, 2.33, 2.58])
1552+
below = np.nextafter(thresholds, -np.inf)
1553+
above = np.nextafter(thresholds, np.inf)
1554+
z = np.stack([below, thresholds, above])
1555+
z = np.concatenate([z, -z])
1556+
1557+
# Confidence is 99 for |z| > 2.58, 95 for 1.96 < |z| <= 2.58,
1558+
# 90 for 1.65 < |z| <= 1.96, else 0. 1.29 and 2.33 are p-value
1559+
# thresholds in the original ladder and never change the output.
1560+
expected = np.array([
1561+
[0, 0, 90, 95, 95], # just below each threshold
1562+
[0, 0, 90, 95, 95], # exactly on each threshold
1563+
[0, 90, 95, 95, 99], # just above each threshold
1564+
], dtype=np.int8)
1565+
expected = np.concatenate([expected, -expected])
1566+
1567+
out = _calc_hotspots_numpy(z)
1568+
assert out.dtype == np.int8
1569+
np.testing.assert_array_equal(out, expected)
1570+
1571+
1572+
def test_hotspots_classifier_nonfinite_3737():
1573+
# NaN compares False against every threshold and classifies to 0;
1574+
# +/-inf land in the top band with the matching sign; signed zeros
1575+
# are neither hot nor cold.
1576+
from xrspatial.focal import _calc_hotspots_numpy
1577+
1578+
z = np.array([[np.nan, np.inf, -np.inf, 0.0, -0.0]])
1579+
out = _calc_hotspots_numpy(z)
1580+
assert out.dtype == np.int8
1581+
np.testing.assert_array_equal(out, [[0, 99, -99, 0, 0]])
1582+
1583+
15441584
@dask_array_available
15451585
@cuda_and_cupy_available
15461586
def test_hotspots_dask_cupy_matches_numpy():

0 commit comments

Comments
 (0)