diff --git a/autofit/non_linear/plot/samples_plotters.py b/autofit/non_linear/plot/samples_plotters.py index 2a2c1500c..98de9a919 100644 --- a/autofit/non_linear/plot/samples_plotters.py +++ b/autofit/non_linear/plot/samples_plotters.py @@ -35,15 +35,53 @@ def _corner_range_from(data): return plot_range +def _effective_sample_size(weight_list, sample_count): + """Kish effective sample size ``sum(w)**2 / sum(w**2)`` of a weight list. + + Row count alone does not say whether a sample can be plotted: a nested + sampler stopped early returns many rows of which one carries essentially all + the weight, so every weighted statistic ``corner`` computes (quantile + ``range`` most visibly) is a statistic of that single point. ``None`` or an + empty weight list means unweighted, for which every row counts. + """ + if weight_list is None: + return float(sample_count) + + weights = np.asarray(weight_list, dtype=float) + + if weights.size == 0: + return float(sample_count) + + sum_of_squares = np.sum(weights**2) + + if sum_of_squares == 0.0: + return 0.0 + + return float(np.sum(weights) ** 2 / sum_of_squares) + + @skip_in_test_mode def corner_cornerpy(samples, path=None, filename="corner", format="show", **kwargs): data = np.asarray(samples.parameter_lists) - if data.ndim < 2 or data.shape[0] <= data.shape[1]: + + sample_count = data.shape[0] if data.ndim >= 1 else 0 + parameter_count = data.shape[1] if data.ndim >= 2 else 0 + effective_sample_size = _effective_sample_size( + getattr(samples, "weight_list", None), sample_count + ) + + if ( + data.ndim < 2 + or sample_count <= parameter_count + or effective_sample_size <= parameter_count + ): logger.info( - "corner_cornerpy: skipping corner plot, only %s sample(s) for %s parameter(s) " + "corner_cornerpy: skipping corner plot, only %s sample(s) " + "(effective sample size %.2f) for %s parameter(s) " "(e.g. PYAUTO_TEST_MODE bypass or an early-iteration update).", - data.shape[0] if data.ndim >= 1 else 0, - data.shape[1] if data.ndim >= 2 else 0, + sample_count, + effective_sample_size, + parameter_count, ) return diff --git a/test_autofit/non_linear/plot/test_samples_plotters.py b/test_autofit/non_linear/plot/test_samples_plotters.py index 2beec0366..3a8068a3b 100644 --- a/test_autofit/non_linear/plot/test_samples_plotters.py +++ b/test_autofit/non_linear/plot/test_samples_plotters.py @@ -1,3 +1,5 @@ +import logging + import numpy as np import pytest @@ -53,12 +55,12 @@ def __init__(self, labels): class MockSamples: - def __init__(self, parameter_lists, weight_list=None): + def __init__(self, parameter_lists, weight_list=None, labels=None): self.parameter_lists = parameter_lists self.weight_list = ( weight_list if weight_list is not None else [1.0] * len(parameter_lists) ) - self.model = MockModel(["x", "y"]) + self.model = MockModel(labels if labels is not None else ["x", "y"]) @pytest.fixture @@ -177,3 +179,56 @@ def test__corner_cornerpy__degenerate_columns_still_render(monkeypatch): degenerate = MockSamples(parameter_lists=[[1.0, 5.0]] * 20) samples_plotters.corner_cornerpy(samples=degenerate, bins=5) + + +def test__effective_sample_size__uniform_and_degenerate_weights(): + assert samples_plotters._effective_sample_size([1.0] * 10, 10) == pytest.approx(10.0) + + # One point carrying all the weight is worth a single sample, however many + # rows sit beside it. + degenerate = [0.0] * 99 + [1.0] + assert samples_plotters._effective_sample_size(degenerate, 100) == pytest.approx(1.0) + + # No weights at all means unweighted, so every row counts. + assert samples_plotters._effective_sample_size(None, 7) == pytest.approx(7.0) + assert samples_plotters._effective_sample_size([], 7) == pytest.approx(7.0) + + +def test__corner_cornerpy__weight_degenerate_sample_is_skipped_not_plotted( + corner_call, caplog +): + # Regression (#1541): a nested sampler stopped after its first batch returns + # plenty of rows but one non-zero weight. `range=0.999` is then a weighted + # quantile over that single point, collapsing to a sliver that excludes every + # row, and `corner` raised "the provided 'range' is not valid or the sample + # is empty". The row-count guard never fired because the rows are all there. + rng = np.random.default_rng(0) + degenerate = MockSamples( + parameter_lists=rng.normal(size=(100, 3)).tolist(), + weight_list=[0.0] * 99 + [1.0], + labels=["x", "y", "z"], + ) + + with caplog.at_level(logging.INFO, logger=samples_plotters.__name__): + samples_plotters.corner_cornerpy( + samples=degenerate, + range=np.ones(3) * 0.999, + ) + + assert corner_call == {} + assert "skipping corner plot" in caplog.text + assert "effective sample size" in caplog.text + + +def test__corner_cornerpy__healthy_weights_still_plot(corner_call): + rng = np.random.default_rng(0) + healthy = MockSamples( + parameter_lists=rng.normal(size=(100, 3)).tolist(), + weight_list=[1.0] * 100, + labels=["x", "y", "z"], + ) + + samples_plotters.corner_cornerpy(samples=healthy, range=np.ones(3) * 0.999) + + assert corner_call != {} + assert corner_call["weights"] == healthy.weight_list