Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/morphobricks_normalization/normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,14 @@ def robust_z_score(


def _parameters(method: str, offset: float, scale: float) -> NormalizationParameters:
if not np.isfinite(scale) or scale <= 0:
"""Make the parameters, refusing a scale that is not finite or not positive, by cause."""
if not np.isfinite(scale):
raise ValueError(
f"The {method} normalization cannot be used for this group: its intensity spread "
f"is {scale!r}, not a finite number, because the pixel values are too large for "
"float64 sums of squares (overflow). Choose another method or a different range."
)
if scale <= 0:
raise ValueError(
f"The {method} normalization cannot be used for this group: its intensity spread "
"is zero (all pixels have the same value). Choose a larger group or another method."
Expand Down
24 changes: 23 additions & 1 deletion src/morphobricks_normalization/pooling.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,29 @@ def pooled_statistics(
def _mean_with_tails(
values: np.ndarray, weights: np.ndarray, tails: list[tuple[float, float, float]], total: float
) -> float:
return (weighted_sum(weights, values) + math.fsum(s for _, s, _ in tails)) / total
sums = [s for _, s, _ in tails]
if not all(math.isfinite(s) for s in sums):
# math.fsum would raise a bare ValueError or OverflowError from the standard library.
raise ValueError(_non_finite_tail_message(sums))
return (weighted_sum(weights, values) + math.fsum(sums)) / total


def _non_finite_tail_message(sums: list[float]) -> str:
"""Refusal text for tail sums that are infinite or NaN, naming each such tail."""
bad = ", ".join(
f"tail {number} of {len(sums)} has sum {s!r}"
for number, s in enumerate(sums, 1)
if not math.isfinite(s)
)
opposing = any(s == math.inf for s in sums) and any(s == -math.inf for s in sums)
reason = (
"infinite sums of opposite sign have no mean" if opposing else "a sum that is not finite"
)
return (
f"The mean cannot be pooled: {bad} ({reason}). The pixels outside the robust range "
"are too large for float64 sums. Use a method that does not need the mean, or "
"another range."
)


def _std_with_tails(
Expand Down
8 changes: 8 additions & 0 deletions tests/test_normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,11 @@ def test_zero_spread_is_rejected_with_a_clear_message():
for method in (min_max, z_score, robust_z_score):
with pytest.raises(ValueError, match="spread is zero"):
method(constant)


def test_non_finite_scale_is_refused_as_overflow_not_zero_spread():
plane = np.array([[-1.7e308, 1.7e308], [-1.7e308, 1.7e308]])
pooled = PooledHistogram([compute_plane_histogram(plane)])
with pytest.raises(ValueError, match="not a finite number") as caught:
z_score(pooled)
assert "spread is zero" not in str(caught.value)
17 changes: 17 additions & 0 deletions tests/test_pooling.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
pooled_statistics,
)
from morphobricks_normalization.pooling import DEFAULT_PERCENTILES
from morphobricks_normalization.ranges import HistogramSettings
from simulation import direct_stats, make_records, simulate_experiment

ROBUST = HistogramSettings(histogram_range="robust", robust_quantiles=(1.0, 99.0))

LEVELS = ("image", "technical_repeat", "biological_repeat", "condition")


Expand Down Expand Up @@ -83,3 +86,17 @@ def test_percentile_limits():
assert pooled.percentile(0) == 0 and pooled.percentile(100) == 9
with pytest.raises(ValueError):
pooled.percentile(101)


def test_opposing_infinite_tail_sums_are_a_named_refusal():
# Float64 pixels at both ends overflow the tails' sums to +inf and -inf; the mean then
# needs a refusal that says why, not a bare error from math.fsum.
plane = np.full((80, 80), 1.0)
plane.ravel()[:5] = -1.7e308
plane.ravel()[5:10] = 1.7e308
plane.ravel()[10:] = np.linspace(0, 10, 6390)
histogram = compute_plane_histogram(plane, settings=ROBUST)
assert histogram.underflow.sum == -np.inf and histogram.overflow.sum == np.inf
with pytest.raises(ValueError, match="opposite sign") as caught:
_ = PooledHistogram([histogram]).mean
assert "tail 1 of 2" in str(caught.value) and "tail 2 of 2" in str(caught.value)
Loading