diff --git a/CHANGELOG.md b/CHANGELOG.md index 663d9d4..6ad6cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to Khisto are documented in this file. +## [1.0.2] - 2026-09-15 + +### Added + +- Explain counts, densities, and variable-width bins in a dedicated guide. + +### Fixed + +- Correct Matplotlib plots for observations exactly on internal bin edges. Khiops + assigns these values to right-closed bins, `(lower, upper]`, while Matplotlib's + default left-closed convention, `[lower, upper)`, previously shifted them into + the next bin and produced incorrect counts and densities. +- Prevent `max_bins` from selecting a histogram finer than the best interpretable + one. + +### Changed + +- Highlight and update links to the official Khiops histogram documentation. +- Clarify the bin-boundary semantics in the array and Matplotlib API documentation. + ## [1.0.1] - 2026-09-07 ### Fixed @@ -20,4 +40,5 @@ First stable release. - Prebuilt wheels for Linux, macOS, and Windows. - Public API documentation and usage examples. +[1.0.2]: https://github.com/KhiopsML/khisto-python/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/KhiopsML/khisto-python/compare/v1.0.0...v1.0.1 diff --git a/docs/counts_vs_density.rst b/docs/counts_vs_density.rst index a0cbf6a..09d743e 100644 --- a/docs/counts_vs_density.rst +++ b/docs/counts_vs_density.rst @@ -23,5 +23,5 @@ Reading variable-width histograms :widths: 35, 35, 30 "How many observations?", "Counts (``density=False``)", "Bar heights" - "What fraction of observations?", "Counts divided by :math:`N`, or density", "Bar areas" + "What fraction of observations?", "Counts divided by :math:`N`, or density times bin width", "Bar areas" "Where are values concentrated?", "Density (``density=True``, the Khisto default)", "Bar heights" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2479c45..869c308 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "khisto" -version = "1.0.1" +version = "1.0.2" description = "Optimal histogram visualization using the Khiops algorithm" readme = "README.md" license = "BSD-3-Clause-Clear" @@ -129,6 +129,7 @@ ignore = [ ] [tool.scikit-build] +build-dir = "build/{wheel_tag}" # Reuse CMake and Ninja artifacts across uv sync runs wheel.py-api = "py3" # We specify full compatibility with all Python 3 releases cmake.version = "CMakeLists.txt" # Read CMake minimal version from file diff --git a/src/khisto/core/backend.py b/src/khisto/core/backend.py index 1e50bef..d3aac25 100644 --- a/src/khisto/core/backend.py +++ b/src/khisto/core/backend.py @@ -105,6 +105,10 @@ def from_dict(cls, data: dict[str, Any]) -> _KhistoOutput: class HistogramResult: """Result of optimal histogram computation. + .. note:: + Khiops bins are left-open and right-closed, ``(lower, upper]``, unlike + NumPy bins which are left-closed and right-open, ``[lower, upper)``. + Attributes ---------- lower_bounds : NDArray[np.float64] @@ -238,6 +242,10 @@ def _process_histogram_file(file_path: Path) -> list[HistogramResult]: def compute_histograms(x: NDArray[np.float64]) -> list[HistogramResult]: """Compute optimal histogram of an array using khisto CLI binary input. + .. note:: + Khiops bins are left-open and right-closed, ``(lower, upper]``, unlike + NumPy bins which are left-closed and right-open, ``[lower, upper)``. + Parameters ---------- x : NDArray[np.float64] diff --git a/src/khisto/histogram.py b/src/khisto/histogram.py index 4290983..e3e93e7 100644 --- a/src/khisto/histogram.py +++ b/src/khisto/histogram.py @@ -30,22 +30,27 @@ def _select_histogram( HistogramResult The selected histogram result. """ - if max_bins is not None: - # Find the finest granularity that respects max_bins - for r in reversed(histogram_results): - if len(r) <= max_bins: - return r - # If no histogram respects the constraint, use the coarsest one - return histogram_results[0] - - # Return the best histogram (optimal in terms of interpretability) - # There is only one best histogram, so we return the first one we find - for r in reversed(histogram_results): - if r.is_best: - return r - # Fallback to finest granularity if no best is marked - # It is assumed to be the best because it is the finest granularity - return histogram_results[-1] + + # Find the best histogram marked as is_best, + # or default to the last one if none is marked. + for result in reversed(histogram_results): + if result.is_best: + best_histogram = result + break + else: + best_histogram = histogram_results[-1] + + if max_bins is None: + return best_histogram + + # Select the histogram with the highest granularity + # that does not exceed max_bins. + # Histograms finer than the best interpretable one are skipped. + for result in reversed(histogram_results): + if result.granularity <= best_histogram.granularity and len(result) <= max_bins: + return result + + return histogram_results[0] def histogram( @@ -56,6 +61,10 @@ def histogram( ) -> tuple[NDArray[np.float64], NDArray[np.float64]]: """Compute an optimal histogram using the Khiops binning algorithm. + .. note:: + Khiops bins are left-open and right-closed, ``(lower, upper]``, unlike + NumPy bins which are left-closed and right-open, ``[lower, upper)``. + Parameters ---------- a : array_like diff --git a/src/khisto/matplotlib/hist.py b/src/khisto/matplotlib/hist.py index 625baf4..96dcb40 100644 --- a/src/khisto/matplotlib/hist.py +++ b/src/khisto/matplotlib/hist.py @@ -60,6 +60,11 @@ def hist( patches Container with the bar patches. + .. note:: + Khiops bins are left-open and right-closed, ``(lower, upper]``, unlike + Matplotlib bins which are left-closed and right-open, ``[lower, upper)``. + The displayed plot is still correct. + See Also -------- matplotlib.pyplot.hist : Matplotlib's histogram function. @@ -83,4 +88,7 @@ def hist( ax = plt.gca() - return ax.hist(x, bin_edges, density=density, range=range, **kwargs) + # Khiops bins are right-closed, whereas Matplotlib bins are left-closed. + # Moving each value down one ULP preserves Khiops assignments at shared edges. + plot_values = np.nextafter(np.asarray(x, dtype=np.float64), -np.inf) + return ax.hist(plot_values, bin_edges, density=density, range=range, **kwargs) diff --git a/tests/array/test_histogram.py b/tests/array/test_histogram.py index 8205883..6b56a76 100644 --- a/tests/array/test_histogram.py +++ b/tests/array/test_histogram.py @@ -45,6 +45,19 @@ def bimodal_data(): class TestHistogram: """Test cases for histogram function.""" + def test_max_bins_does_not_select_finer_than_best(self): + """Test that max_bins cannot select a granularity after the best one.""" + # This distribution produces histograms finer than the best one. + data = np.repeat([1, 2, 3, 4, 5, 6], [458, 82, 43, 11, 3, 2]) + expected_values, expected_edges = histogram(data, density=False) + + # A loose bin limit must still select the best interpretable histogram. + values, edges = histogram(data, max_bins=100, density=False) + + # The limit must not expose a finer histogram after the best one. + np.testing.assert_array_equal(values, expected_values) + np.testing.assert_array_equal(edges, expected_edges) + def test_histogram_with_list(self, simple_data): """Test histogram with Python list input.""" hist, bin_edges = histogram(simple_data) diff --git a/tests/plot/test_matplotlib_histogram.py b/tests/plot/test_matplotlib_histogram.py index 82c6214..eca0bf1 100644 --- a/tests/plot/test_matplotlib_histogram.py +++ b/tests/plot/test_matplotlib_histogram.py @@ -16,6 +16,13 @@ from khisto.matplotlib import hist +@pytest.fixture(autouse=True) +def close_figures(): + """Close every figure, including when a test fails.""" + yield + plt.close("all") + + class TestHistBasic: """Test basic hist functionality.""" @@ -27,7 +34,7 @@ def normal_data(self): def test_simple_array(self, normal_data): """Test hist with simple numpy array.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() n, bins, patches = hist(normal_data, ax=ax) assert isinstance(n, np.ndarray) @@ -35,7 +42,6 @@ def test_simple_array(self, normal_data): assert patches is not None assert len(n) > 0 assert len(bins) == len(n) + 1 - plt.close(fig) def test_without_ax(self, normal_data): """Test hist without explicit ax parameter.""" @@ -44,122 +50,119 @@ def test_without_ax(self, normal_data): assert isinstance(n, np.ndarray) assert isinstance(bins, np.ndarray) assert len(n) > 0 - plt.close() def test_density_histogram(self, normal_data): """Test density histogram.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() n, bins, _ = hist(normal_data, density=True, ax=ax) # Density should integrate to 1 bin_widths = np.diff(bins) total = np.sum(n * bin_widths) assert np.isclose(total, 1.0, rtol=1e-5) - plt.close(fig) def test_frequency_histogram(self, normal_data): """Test frequency histogram is the default behavior.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() n, _, _ = hist(normal_data, ax=ax, density=False) # Frequencies should sum to total count assert np.sum(n) == len(normal_data) - plt.close(fig) + + @pytest.mark.parametrize("density", [False, True]) + def test_histogram_matches_khiops_at_internal_edges(self, density): + """Test that observations on internal edges keep their Khiops bins.""" + data = np.repeat([1, 2, 3, 4, 5, 6], [458, 82, 43, 11, 3, 2]) + expected, expected_bins = histogram(data, max_bins=100, density=density) + _fig, ax = plt.subplots() + + values, bins, _ = hist(data, max_bins=100, density=density, ax=ax) + + np.testing.assert_array_equal(bins, expected_bins) + np.testing.assert_allclose(values, expected) def test_horizontal_orientation(self, normal_data): """Test horizontal histogram.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() n, _, _ = hist(normal_data, orientation="horizontal", ax=ax) assert isinstance(n, np.ndarray) assert len(n) > 0 - plt.close(fig) def test_with_max_bins(self, normal_data): """Test histogram with max_bins parameter.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() n, _, _ = hist(normal_data, max_bins=5, ax=ax) assert len(n) <= 5 - plt.close(fig) def test_with_range(self, normal_data): """Test histogram with range parameter.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() _, bins, _ = hist(normal_data, range=(-1, 1), ax=ax) assert bins[0] >= -1 assert bins[-1] <= 1 - plt.close(fig) def test_log_scale(self, normal_data): """Test histogram with log scale.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() hist(normal_data, log=True, ax=ax) assert ax.get_yscale() == "log" - plt.close(fig) def test_color_parameter(self, normal_data): """Test histogram with color parameter.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() _, _, patches = hist(normal_data, color="red", ax=ax) assert patches is not None - plt.close(fig) def test_step_histtype(self, normal_data): """Test histogram with step histtype.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() n, _, _ = hist(normal_data, histtype="step", ax=ax) assert isinstance(n, np.ndarray) - plt.close(fig) def test_stepfilled_histtype(self, normal_data): """Test histogram with stepfilled histtype.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() n, _, _ = hist(normal_data, histtype="stepfilled", ax=ax) assert isinstance(n, np.ndarray) - plt.close(fig) def test_cumulative_density_histogram(self, normal_data): """Test cumulative density histogram.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() n, _, _ = hist(normal_data, density=True, cumulative=True, ax=ax) assert np.isclose(n[-1], 1.0, rtol=1e-5) - plt.close(fig) def test_cumulative_hist_matches_array_api(self, normal_data): """Test that the plotting wrapper matches cumulative histogram values.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() n, bins, _ = hist(normal_data, density=True, cumulative=True, ax=ax) density, expected_bins = histogram(normal_data, density=True) expected = np.cumsum(density * np.diff(expected_bins)) np.testing.assert_array_equal(bins, expected_bins) np.testing.assert_allclose(n, expected) - plt.close(fig) def test_reverse_cumulative_frequency_histogram(self, normal_data): """Test reverse cumulative frequency histogram.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() n, _, _ = hist(normal_data, density=False, cumulative=-1, ax=ax) assert np.isclose(n[0], len(normal_data)) - plt.close(fig) def test_unsupported_bins_parameter(self, normal_data): """Test that bins raises a clear error message.""" - fig, ax = plt.subplots() + _fig, ax = plt.subplots() with pytest.raises(TypeError, match="bins is not supported"): hist(normal_data, bins=10, ax=ax) - plt.close(fig) - class TestHistReturnValues: """Test return values match matplotlib.pyplot.hist interface."""