Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions docs/reference/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Unreleased changes

### CI/CD

- Improve the unit test suite for the `ridgeplot._hist` module ({gh-pr}`365`)
- Review the coverage configuration in light of `covdefaults`, adopting its `assert_never` exclusion and `skip_covered` report setting, and raising all package coverage gates to 100% ({gh-pr}`390`)
- Adopt pytest's strict mode, following the recommendations from pytest's "Good Integration Practices" guide ({gh-pr}`387`)
- Make the e2e path sanity check independent of the checkout directory's name, so tests can run from git worktrees ({gh-pr}`391`)
Expand Down
213 changes: 168 additions & 45 deletions tests/unit/test_hist.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
import pytest

Expand All @@ -8,37 +10,59 @@
bin_trace_samples,
)

# Example data

SAMPLES_IN = [1, 2, 2, 3, 4]
NBINS = 4
# NOTE: The x values in DENSITIES_OUT correspond to the centers of
# equally spaced bins over the range [1, 4].
# This can be counterintuitive for count data, as the bins
# do not align with the integer sample values.
DENSITIES_OUT = [(1.375, 1), (2.125, 2), (2.875, 1), (3.625, 1)]
X_OUT, Y_OUT = zip(*DENSITIES_OUT, strict=True)
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any

WEIGHTS = [1, 1, 1, 1, 9]
NON_FINITE_VALUES = [np.inf, -np.inf, np.nan]

# ==============================================================
# --- estimate_density_trace()
# --- bin_trace_samples()
# ==============================================================


def test_bin_trace_samples_simple() -> None:
density_trace = bin_trace_samples(trace_samples=SAMPLES_IN, nbins=NBINS)
x, y = zip(*density_trace, strict=True)
assert x == X_OUT
assert y == Y_OUT
# --- Basic functionality ---


@pytest.mark.parametrize(
("samples", "nbins", "expected"),
[
# Basic case with repeated values
# NOTE: The expected x values correspond to the centers of
# equally spaced bins over the range [min, max] of the
# samples. This can be counterintuitive for count data,
# as the bins do not align with the integer sample values.
([1, 2, 2, 3, 4], 4, [(1.375, 1), (2.125, 2), (2.875, 1), (3.625, 1)]),
# Single bin aggregates all samples
([1, 2, 3], 1, [(2.0, 3)]),
# Uniform distribution
([0, 1, 2, 3], 4, [(0.375, 1), (1.125, 1), (1.875, 1), (2.625, 1)]),
# All identical samples fall in the rightmost bin
# (NumPy pads the zero-width range by +/-0.5)
([3, 3, 3], 2, [(2.75, 0), (3.25, 3)]),
# Negative values
([-2, -1, 0, 1], 2, [(-1.25, 2), (0.25, 2)]),
],
ids=["basic", "single_bin", "uniform", "identical", "negative"],
)
def test_basic_binning(
samples: list[float], nbins: int, expected: list[tuple[float, float]]
) -> None:
result = bin_trace_samples(samples, nbins=nbins)
assert result == expected


@pytest.mark.parametrize("nbins", [2, 5, 8, 11])
def test_bin_trace_samples_nbins(nbins: int) -> None:
density_trace = bin_trace_samples(trace_samples=SAMPLES_IN, nbins=nbins)
assert len(density_trace) == nbins
def test_float_samples_binning() -> None:
result = bin_trace_samples([0.1, 0.5, 0.9], nbins=3)
x_vals, y_vals = zip(*result, strict=True)
assert x_vals == pytest.approx((7 / 30, 0.5, 23 / 30))
assert y_vals == (1.0, 1.0, 1.0)


@pytest.mark.parametrize("nbins", [1, 2, 5, 10, 50])
def test_output_length_matches_nbins(nbins: int) -> None:
result = bin_trace_samples([1, 2, 3, 4, 5], nbins=nbins)
assert len(result) == nbins
@pytest.mark.parametrize(
"non_finite_value",
[np.inf, np.nan, float("inf"), float("nan")],
Expand All @@ -50,22 +74,94 @@ def test_bin_trace_samples_fails_for_non_finite_values(non_finite_value: float)
bin_trace_samples(trace_samples=[*SAMPLES_IN[:-1], non_finite_value], nbins=NBINS)


def test_bin_trace_samples_weights() -> None:
density_trace = bin_trace_samples(
trace_samples=SAMPLES_IN,
nbins=NBINS,
weights=WEIGHTS,
)
x, y = zip(*density_trace, strict=True)
assert x == X_OUT
assert np.argmax(y) == len(y) - 1
@pytest.mark.parametrize(
"input_type",
[list, tuple, np.asarray],
ids=["list", "tuple", "ndarray"],
)
def test_accepts_various_input_types(input_type: Callable[[list[int]], Any]) -> None:
result = bin_trace_samples(input_type([1, 2, 3]), nbins=2)
assert len(result) == 2
# The output should always be normalised to built-in floats
# (note: isinstance() checks wouldn't cut it here since
# np.float64 is also a subclass of the built-in float)
assert {type(value) for xy_pair in result for value in xy_pair} == {float}


def test_counts_sum_to_sample_size() -> None:
samples = list(range(100))
result = bin_trace_samples(samples, nbins=7)
total_count = sum(y for _, y in result)
assert total_count == len(samples)


def test_bin_centers_within_data_range() -> None:
samples = [10, 20, 30, 40, 50]
result = bin_trace_samples(samples, nbins=5)
centers = [x for x, _ in result]
assert all(min(samples) <= c <= max(samples) for c in centers)


# --- Weights ---


def test_bin_trace_samples_weights_not_same_length() -> None:
with pytest.raises(
ValueError, match="The weights array should have the same length as the samples array"
):
bin_trace_samples(trace_samples=SAMPLES_IN, nbins=NBINS, weights=[1, 1, 1])
@pytest.mark.parametrize(
("samples", "weights", "nbins", "expected_counts"),
[
# Each sample falls in its own bin, so the weights become the counts
([1, 2, 3], [10, 1, 1], 3, [10, 1, 1]),
# Zero weights effectively exclude samples
([1, 2, 3], [1, 0, 1], 3, [1, 0, 1]),
# Fractional weights
([1, 2], [0.5, 1.5], 2, [0.5, 1.5]),
],
ids=["heavy_first", "zero_weight", "fractional"],
)
def test_weights_affect_counts(
samples: list[float],
weights: list[float],
nbins: int,
expected_counts: list[float],
) -> None:
result = bin_trace_samples(samples, nbins=nbins, weights=weights)
counts = [y for _, y in result]
assert counts == pytest.approx(expected_counts)


def test_weighted_counts_sum_to_weight_sum() -> None:
samples = [1, 2, 3, 4, 5]
weights = [2.0, 3.0, 1.5, 0.5, 4.0]
result = bin_trace_samples(samples, nbins=3, weights=weights)
assert sum(y for _, y in result) == pytest.approx(sum(weights))


# --- Error handling ---


@pytest.mark.parametrize("non_finite", NON_FINITE_VALUES)
def test_rejects_non_finite_samples(non_finite: float) -> None:
with pytest.raises(ValueError, match="samples array should not contain any infs or NaNs"):
bin_trace_samples([1, 2, non_finite], nbins=2)
Comment on lines +152 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove stale tests that reference deleted constants

The new non-finite-value cases duplicate rather than replace the existing tests at lines 71–74 and 172–180, while this patch deletes SAMPLES_IN, NBINS, and WEIGHTS. Consequently, collecting this file runs eight cases that raise NameError before reaching bin_trace_samples; the targeted pytest suite reports 8 failures. Delete those stale tests or make them self-contained so the unit suite passes.

AGENTS.md reference: AGENTS.md:L163-L163

Useful? React with 👍 / 👎.



@pytest.mark.parametrize("non_finite", NON_FINITE_VALUES)
def test_rejects_non_finite_weights(non_finite: float) -> None:
with pytest.raises(ValueError, match="weights array should not contain any infs or NaNs"):
bin_trace_samples([1, 2, 3], nbins=2, weights=[1, non_finite, 1])


@pytest.mark.parametrize(
("samples", "weights"),
[
([1, 2, 3], [1, 2]),
([1, 2], [1, 2, 3]),
([1], []),
],
ids=["weights_short", "weights_long", "empty_weights"],
)
def test_rejects_mismatched_weights_length(samples: list[float], weights: list[float]) -> None:
with pytest.raises(ValueError, match="weights array should have the same length"):
bin_trace_samples(samples, nbins=2, weights=weights)


@pytest.mark.parametrize(
Expand All @@ -86,19 +182,46 @@ def test_bin_trace_samples_weights_fails_for_non_finite_values(


# ==============================================================
# --- estimate_densities()
# --- bin_samples()
# ==============================================================


def test_bin_samples() -> None:
samples = [1, 2, 2, 3, 4]
expected_trace = [(1.375, 1.0), (2.125, 2.0), (2.875, 1.0), (3.625, 1.0)]
densities = bin_samples(samples=[[samples], [samples]], nbins=4)
assert densities == [[expected_trace], [expected_trace]]


def test_bin_samples_preserves_shape() -> None:
densities = bin_samples(samples=[[[0, 1], [2, 3, 4]], [[5, 6, 7, 8]]], nbins=3)
assert [len(row) for row in densities] == [2, 1]
assert all(len(trace) == 3 for row in densities for trace in row)


def test_bin_samples_broadcasts_flat_weights() -> None:
"""A single flat weights vector should be applied to all traces."""
trace_a, trace_b = [1, 2, 3], [4, 5, 6]
weights = [1, 2, 3]
densities = bin_samples(samples=[[trace_a, trace_b]], nbins=2, sample_weights=weights)
assert densities == [
[
bin_trace_samples(trace_a, nbins=2, weights=weights),
bin_trace_samples(trace_b, nbins=2, weights=weights),
]
]


def test_bin_samples_per_trace_weights() -> None:
"""Per-trace weights (shallow form) should be matched to each trace."""
trace_a, trace_b = [1, 2, 3], [4, 5, 6, 7]
weights_a = [1, 2, 3]
densities = bin_samples(
samples=[[SAMPLES_IN], [SAMPLES_IN]],
nbins=NBINS,
samples=[[trace_a], [trace_b]],
nbins=2,
sample_weights=[weights_a, None],
)
assert len(densities) == 2
for densities_row in densities:
assert len(densities_row) == 1
density_trace = next(iter(densities_row))
x, y = zip(*density_trace, strict=True)
assert x == X_OUT
assert y == Y_OUT
assert densities == [
[bin_trace_samples(trace_a, nbins=2, weights=weights_a)],
[bin_trace_samples(trace_b, nbins=2)],
]
Loading