From d4ca7b3c1d559261eb312ad467390576577d88af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 12:00:11 +0000 Subject: [PATCH 1/3] Refuse opposing infinite tail sums in the pooled mean with a named message math.fsum raised a bare ValueError or OverflowError. Non-finite tail sums are now detected first and refused, naming the tails concerned (fitting adds the group and channel). Valid inputs are unchanged. Fixes #71 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW --- src/morphobricks_normalization/pooling.py | 23 ++++++++++++++++++++++- tests/test_pooling.py | 17 +++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/morphobricks_normalization/pooling.py b/src/morphobricks_normalization/pooling.py index 5ee29a1..35c9200 100644 --- a/src/morphobricks_normalization/pooling.py +++ b/src/morphobricks_normalization/pooling.py @@ -361,7 +361,28 @@ 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: + 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( diff --git a/tests/test_pooling.py b/tests/test_pooling.py index a2555a5..07d3a19 100644 --- a/tests/test_pooling.py +++ b/tests/test_pooling.py @@ -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") @@ -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) From b2564fd23f50955bc109bb34da6101b84df9b70d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 12:00:19 +0000 Subject: [PATCH 2/3] Say a non-finite z-score scale is an overflow, not a zero spread The shared refusal told the user the intensity spread was zero even when the scale was inf or nan (for example float64 pixels near the type's limits). The two cases now have their own messages. Fixes #70 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW --- src/morphobricks_normalization/normalization.py | 8 +++++++- tests/test_normalization.py | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/morphobricks_normalization/normalization.py b/src/morphobricks_normalization/normalization.py index cfc0df3..4e0d770 100644 --- a/src/morphobricks_normalization/normalization.py +++ b/src/morphobricks_normalization/normalization.py @@ -99,7 +99,13 @@ def robust_z_score( def _parameters(method: str, offset: float, scale: float) -> NormalizationParameters: - if not np.isfinite(scale) or scale <= 0: + 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." diff --git a/tests/test_normalization.py b/tests/test_normalization.py index 5a2eb52..4c3d616 100644 --- a/tests/test_normalization.py +++ b/tests/test_normalization.py @@ -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) From 2364ee3d9ba6d91ce21b9a4e6ab6d0e7d853a519 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 12:01:49 +0000 Subject: [PATCH 3/3] Satisfy the code rules: docstrings and a used expression in the new code Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW --- src/morphobricks_normalization/normalization.py | 1 + src/morphobricks_normalization/pooling.py | 1 + tests/test_pooling.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/morphobricks_normalization/normalization.py b/src/morphobricks_normalization/normalization.py index 4e0d770..747ee76 100644 --- a/src/morphobricks_normalization/normalization.py +++ b/src/morphobricks_normalization/normalization.py @@ -99,6 +99,7 @@ def robust_z_score( def _parameters(method: str, offset: float, scale: float) -> NormalizationParameters: + """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 " diff --git a/src/morphobricks_normalization/pooling.py b/src/morphobricks_normalization/pooling.py index 35c9200..32d3323 100644 --- a/src/morphobricks_normalization/pooling.py +++ b/src/morphobricks_normalization/pooling.py @@ -369,6 +369,7 @@ def _mean_with_tails( 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) diff --git a/tests/test_pooling.py b/tests/test_pooling.py index 07d3a19..b825989 100644 --- a/tests/test_pooling.py +++ b/tests/test_pooling.py @@ -98,5 +98,5 @@ def test_opposing_infinite_tail_sums_are_a_named_refusal(): 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 + _ = PooledHistogram([histogram]).mean assert "tail 1 of 2" in str(caught.value) and "tail 2 of 2" in str(caught.value)