Skip to content

Commit dc42626

Browse files
authored
Merge pull request #440 from jhlegarreta/ref/avoid-divide-by-zero
REF: Avoid divide by zero warnings when denominator is zero
2 parents 0b51bd4 + 04c7006 commit dc42626

2 files changed

Lines changed: 152 additions & 7 deletions

File tree

src/nifreeze/data/filtering.py

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,31 @@
3535
BVAL_ATOL = 100.0
3636
"""b-value tolerance value."""
3737

38+
CLIPPING_EMPTY_SELECTION_ERROR_MSG = """\
39+
Empty percentile selection after applying {constraints}finite filtering (p_min={p_min}, p_max={p_max}).
40+
"""
41+
"""Clipping empty selection error message."""
42+
43+
CLIPPING_NONFINITE_THRESHOLDS_ERROR_MSG = """\
44+
Percentile thresholds are not finite (a_min={a_min}, a_max={a_max}, p_min={p_min}, p_max={p_max}, nonnegative={nonnegative}).
45+
"""
46+
"""Clipping non-finite thresholds error message."""
47+
48+
CLIPPING_INVALID_THRESHOLDS_ERROR_MSG = """\
49+
Invalid percentile thresholds (a_max <= a_min) (a_min={a_min}, a_max={a_max}, p_min={p_min}, p_max={p_max}, nonnegative={nonnegative}).
50+
"""
51+
"""Clipping invalid percentile thresholds error message."""
52+
53+
CLIPPING_DEGENERATE_RANGE_ERROR_MSG = """\
54+
Degenerate dynamic range after clipping/shift (den={den}, a_min={a_min}, a_max={a_max}, \
55+
nonnegative={nonnegative}, data_finite={data_finite}, unique~={unique}).
56+
"""
57+
"""Clipping degenerate dynamic range error message."""
58+
59+
60+
class ClippingValueError(RuntimeError):
61+
"""Raised when clipping cannot compute a valid clipping/scaling."""
62+
3863

3964
def advanced_clip(
4065
data: np.ndarray,
@@ -94,17 +119,55 @@ def advanced_clip(
94119
# Calculate stats on denoised version to avoid outlier bias
95120
denoised = median_filter(data, footprint=ball(3))
96121

97-
a_min = np.percentile(
98-
np.asarray([denoised[denoised >= 0] if nonnegative else denoised]), p_min
99-
)
100-
a_max = np.percentile(
101-
np.asarray([denoised[denoised >= 0] if nonnegative else denoised]), p_max
102-
)
122+
# Select values for percentile computation.
123+
# If nonnegative=True, we must have at least one finite >=0 value.
124+
sel = denoised[denoised >= 0] if nonnegative else denoised
125+
sel = sel[np.isfinite(sel)]
126+
127+
if sel.size == 0:
128+
constraints = "nonnegative constraint and " if nonnegative else ""
129+
raise ClippingValueError(
130+
CLIPPING_EMPTY_SELECTION_ERROR_MSG.format(
131+
constraints=constraints, p_min=p_min, p_max=p_max
132+
)
133+
)
134+
135+
a_min = float(np.percentile(sel, p_min))
136+
a_max = float(np.percentile(sel, p_max))
137+
138+
if not np.isfinite(a_min) or not np.isfinite(a_max):
139+
raise ClippingValueError(
140+
CLIPPING_NONFINITE_THRESHOLDS_ERROR_MSG.format(
141+
a_min=a_min, a_max=a_max, p_min=p_min, p_max=p_max, nonnegative=nonnegative
142+
)
143+
)
144+
145+
if a_max <= a_min:
146+
raise ClippingValueError(
147+
CLIPPING_INVALID_THRESHOLDS_ERROR_MSG.format(
148+
a_min=a_min, a_max=a_max, p_min=p_min, p_max=p_max, nonnegative=nonnegative
149+
)
150+
)
103151

104152
# Clip and scale data
105153
np.clip(data, a_min=a_min, a_max=a_max, out=data)
106154
data -= data.min()
107-
data /= data.max()
155+
den = float(data.max()) # max-min because min is now 0
156+
if not np.isfinite(den) or den == 0.0:
157+
# Degenerate dynamic range after clipping
158+
unique = len(np.unique(data)) if data.size < 1_000_000 else "too big"
159+
raise ClippingValueError(
160+
CLIPPING_DEGENERATE_RANGE_ERROR_MSG.format(
161+
den=den,
162+
a_min=a_min,
163+
a_max=a_max,
164+
nonnegative=nonnegative,
165+
data_finite=bool(np.isfinite(data).all()),
166+
unique=unique,
167+
)
168+
)
169+
170+
data /= den
108171

109172
if invert:
110173
np.subtract(1.0, data, out=data)

test/test_filtering.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,95 @@
3030

3131
from nifreeze.data.filtering import (
3232
BVAL_ATOL,
33+
CLIPPING_DEGENERATE_RANGE_ERROR_MSG,
34+
CLIPPING_EMPTY_SELECTION_ERROR_MSG,
35+
CLIPPING_INVALID_THRESHOLDS_ERROR_MSG,
36+
CLIPPING_NONFINITE_THRESHOLDS_ERROR_MSG,
37+
ClippingValueError,
3338
advanced_clip,
3439
dwi_select_shells,
3540
grand_mean_normalization,
3641
robust_minmax_normalization,
3742
)
3843

3944

45+
def test_advanced_clip_empty_selection():
46+
data = -np.ones((5, 5, 5), dtype=np.float32)
47+
48+
with pytest.raises(ClippingValueError) as exc:
49+
advanced_clip(data, inplace=False, nonnegative=True)
50+
51+
expected = CLIPPING_EMPTY_SELECTION_ERROR_MSG.format(
52+
constraints="nonnegative constraint and ", p_min=35.0, p_max=99.98
53+
)
54+
assert str(exc.value) == expected
55+
56+
57+
def test_advanced_clip_nonfinite_thresholds(monkeypatch):
58+
data = np.ones((5, 5, 5), dtype=np.float32)
59+
60+
# Force non-finite thresholds regardless of input data
61+
def _percentile_returns_nan(*args, **kwargs):
62+
return np.nan
63+
64+
from nifreeze.data import filtering
65+
66+
monkeypatch.setattr(filtering.np, "percentile", _percentile_returns_nan)
67+
68+
with pytest.raises(ClippingValueError) as exc:
69+
advanced_clip(data, inplace=False, nonnegative=True, p_min=35.0, p_max=99.98)
70+
71+
expected = CLIPPING_NONFINITE_THRESHOLDS_ERROR_MSG.format(
72+
a_min=float("nan"), a_max=float("nan"), p_min=35.0, p_max=99.98, nonnegative=True
73+
)
74+
assert str(exc.value) == expected
75+
76+
77+
def test_advanced_clip_invalid_thresholds():
78+
data = np.arange(27, dtype=np.float32).reshape((3, 3, 3))
79+
p = 50.0
80+
81+
with pytest.raises(ClippingValueError) as exc:
82+
advanced_clip(data, inplace=False, nonnegative=True, p_min=p, p_max=p)
83+
84+
# With p_min == p_max, percentiles are equal (deterministically for this data)
85+
sel = data[np.isfinite(data)]
86+
a = float(np.percentile(sel, p))
87+
expected = CLIPPING_INVALID_THRESHOLDS_ERROR_MSG.format(
88+
a_min=a, a_max=a, p_min=p, p_max=p, nonnegative=True
89+
)
90+
assert str(exc.value) == expected
91+
92+
93+
def test_advanced_clip_degenerate_range(monkeypatch):
94+
data = np.arange(27, dtype=np.float32).reshape((3, 3, 3))
95+
96+
# Ensure thresholds are valid (a_max > a_min) and deterministic.
97+
# advanced_clip calls np.percentile twice (for a_min and a_max).
98+
percentiles = iter([0.0, 1.0])
99+
100+
def _percentile(_arr, _q):
101+
return next(percentiles)
102+
103+
monkeypatch.setattr(np, "percentile", _percentile)
104+
105+
# Force clipping to collapse all values to a constant after thresholds are deemed valid
106+
def _clip(_data, a_min=None, a_max=None, out=None):
107+
out.fill(0.5)
108+
return out
109+
110+
monkeypatch.setattr(np, "clip", _clip)
111+
112+
with pytest.raises(ClippingValueError) as exc:
113+
advanced_clip(data, inplace=False, nonnegative=True, p_min=35.0, p_max=99.98)
114+
115+
# After our fake clip: data is constant 0.5; subtract min: all zeros; den == 0.0
116+
expected = CLIPPING_DEGENERATE_RANGE_ERROR_MSG.format(
117+
den=0.0, a_min=0.0, a_max=1.0, nonnegative=True, data_finite=True, unique=1
118+
)
119+
assert str(exc.value) == expected
120+
121+
40122
@pytest.mark.random_uniform_ndim_data((32, 32, 32), 0.0, 2.0)
41123
@pytest.mark.parametrize(
42124
"p_min, p_max, nonnegative, dtype, invert, inplace",

0 commit comments

Comments
 (0)