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
46 changes: 42 additions & 4 deletions autofit/non_linear/plot/samples_plotters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
59 changes: 57 additions & 2 deletions test_autofit/non_linear/plot/test_samples_plotters.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import logging

import numpy as np
import pytest

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading