Skip to content

Commit 2e0daec

Browse files
committed
fix: validate the bin count in FalsificationResult.plot, delegate the rest (#284)
The `bins < 1` guard let a float like 2.5 through to a matplotlib TypeError, which is what #284 reports. The same guard also raised `TypeError: '<' not supported` on a strategy name or an edge sequence, so neither ever reached matplotlib even though it accepts both. Check only what the bin count owns: reject a non-positive integer, and reject types matplotlib cannot take at all. Strategy names and edge sequences now pass through and matplotlib validates them, which it does more precisely ("'nonsense' is not a valid estimator for `bins`"). Widen the annotation to match what the parameter actually accepts. Add falsification edge-case tests for the bins cases and for a three-variable ~~ block.
1 parent 8370ade commit 2e0daec

2 files changed

Lines changed: 64 additions & 9 deletions

File tree

pathmc/falsify.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from __future__ import annotations
4747

4848
import math
49+
from collections.abc import Sequence
4950
from dataclasses import dataclass
5051
from itertools import permutations
5152
from typing import TYPE_CHECKING
@@ -287,7 +288,7 @@ def _repr_html_(self) -> str:
287288
def plot(
288289
self,
289290
ax: matplotlib.axes.Axes | None = None,
290-
bins: int | None = None,
291+
bins: int | str | Sequence[float] | np.ndarray | None = None,
291292
) -> matplotlib.figure.Figure:
292293
"""Plot histograms of permuted-baseline violation fractions.
293294
@@ -300,8 +301,11 @@ def plot(
300301
----------
301302
ax : matplotlib.axes.Axes | None
302303
Axes to plot on. Creates a new figure if ``None``.
303-
bins : int | None
304-
Number of histogram bins. Defaults to an automatic choice.
304+
bins : int | str | Sequence[float] | numpy.ndarray | None
305+
Passed through to ``matplotlib.axes.Axes.hist``: a positive
306+
integer bin count, a binning strategy name such as
307+
``"auto"``, or a sequence of bin edges. Defaults to an
308+
automatic choice.
305309
306310
Returns
307311
-------
@@ -312,6 +316,10 @@ def plot(
312316
------
313317
RuntimeError
314318
If the result cannot be evaluated (no LMC tests).
319+
ValueError
320+
If *bins* is neither one of the accepted types nor, for an
321+
integer bin count, positive. Strategy names and bin edges are
322+
validated by matplotlib, which reports them more precisely.
315323
"""
316324
import matplotlib.pyplot as plt
317325

@@ -321,8 +329,19 @@ def plot(
321329
"The DAG implies no testable conditional independences."
322330
)
323331

324-
if bins is not None and bins < 1:
325-
raise ValueError(f"bins must be a positive integer or None, got {bins}.")
332+
# Only the bin count is validated. A strategy name or an edge
333+
# sequence is left to matplotlib, which reports both more precisely.
334+
if bins is not None:
335+
if isinstance(bins, bool) or not isinstance(
336+
bins, (int, np.integer, Sequence, np.ndarray)
337+
):
338+
raise ValueError(
339+
f"bins must be a positive integer, a binning strategy "
340+
f"name, a sequence of bin edges, or None, got {bins!r} "
341+
f"of type {type(bins).__name__}."
342+
)
343+
if isinstance(bins, (int, np.integer)) and bins < 1:
344+
raise ValueError(f"bins must be a positive integer, got {bins}.")
326345

327346
if ax is None:
328347
fig, ax = plt.subplots(figsize=(8, 4))

tests/test_falsify.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,17 @@ def test_residual_covariance_rejected_via_method(self, confounded_data):
995995
with pytest.raises(ValueError, match="residual covariance"):
996996
m.falsify(random_seed=0)
997997

998+
def test_three_variable_residual_block_rejected(self, confounded_data):
999+
# A chain of `~~` terms (X ~~ Y, Y ~~ Z) forms one three-variable
1000+
# block. Covered only transitively elsewhere; pin it explicitly.
1001+
df = confounded_data.assign(Z=confounded_data["Y"] * 0.5)
1002+
with pytest.raises(ValueError, match="residual covariance"):
1003+
falsify_graph(
1004+
build_graph(parse_spec("W ~ X + Y + Z\nX ~~ Y\nY ~~ Z")),
1005+
_to_nw(df),
1006+
random_seed=0,
1007+
)
1008+
9981009
def test_dangling_residual_endpoint_rejected(self):
9991010
# Y appears only in a ~~ term (not a DAG node): must raise cleanly,
10001011
# not KeyError.
@@ -1088,13 +1099,38 @@ def test_explicit_huge_still_rejected(self):
10881099

10891100

10901101
class TestPlotValidation:
1091-
def test_negative_bins_rejected(self):
1102+
@pytest.fixture
1103+
def result(self):
10921104
import matplotlib
10931105

10941106
matplotlib.use("Agg")
1095-
r = TestResultDisplay()._make(p_lmc=0.5, p_tpa=0.0, n_in_mec=0)
1096-
with pytest.raises(ValueError, match="bins"):
1097-
r.plot(bins=0)
1107+
return TestResultDisplay()._make(p_lmc=0.5, p_tpa=0.0, n_in_mec=0)
1108+
1109+
@pytest.mark.parametrize("bins", [-1, 0])
1110+
def test_non_positive_bin_count_rejected(self, result, bins):
1111+
with pytest.raises(ValueError, match="bins must be a positive integer"):
1112+
result.plot(bins=bins)
1113+
1114+
@pytest.mark.parametrize("bins", [2.5, True, {1, 2}])
1115+
def test_invalid_bins_type_rejected(self, result, bins):
1116+
# 2.5 previously slipped past the `bins < 1` guard and failed inside
1117+
# matplotlib; True would have plotted a single bin silently.
1118+
with pytest.raises(ValueError, match="bins must be a positive integer"):
1119+
result.plot(bins=bins)
1120+
1121+
@pytest.mark.parametrize("bins", [None, 10, np.int64(10)])
1122+
def test_bin_count_accepted(self, result, bins):
1123+
assert result.plot(bins=bins) is not None
1124+
1125+
@pytest.mark.parametrize("bins", ["auto", "sturges", [0.0, 0.5, 1.0], (0.0, 1.0)])
1126+
def test_strategy_and_edges_delegated_to_matplotlib(self, result, bins):
1127+
# The old `bins < 1` guard raised TypeError on these before
1128+
# matplotlib ever saw them.
1129+
assert result.plot(bins=bins) is not None
1130+
1131+
def test_invalid_strategy_name_reported_by_matplotlib(self, result):
1132+
with pytest.raises(ValueError, match="not a valid estimator"):
1133+
result.plot(bins="nonsense")
10981134

10991135

11001136
class TestMiscEdgeCases:

0 commit comments

Comments
 (0)