Skip to content
Merged
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion docs/counts_vs_density.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions src/khisto/core/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
41 changes: 25 additions & 16 deletions src/khisto/histogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/khisto/matplotlib/hist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
13 changes: 13 additions & 0 deletions tests/array/test_histogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
63 changes: 33 additions & 30 deletions tests/plot/test_matplotlib_histogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -27,15 +34,14 @@ 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)
assert isinstance(bins, np.ndarray)
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."""
Expand All @@ -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."""
Expand Down
Loading