Skip to content
Open
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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,35 @@ jobs:
- name: Run pre-commit hooks
run: uv run pre-commit run --all-files

test-base-install:
name: Test base install without Matplotlib
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v9.0.0
with:
enable-cache: true
version: "0.11.32"

- name: Test base API without optional dependencies
run: |
uv run --isolated --no-project --with . python - <<'PY'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would put this Python snippet in a separate script for easier maintenance.

import importlib.util

import numpy as np

import khisto

assert importlib.util.find_spec("matplotlib") is None
counts, edges = khisto.histogram(
np.array([1.0, 2.0, 3.0]),
density=False,
)
assert counts.sum() == 3
assert len(edges) == len(counts) + 1
PY

get-python-versions:
name: Get Python versions
runs-on: ubuntu-latest
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

All notable changes to Khisto are documented in this file.

## Unreleased

### Changed

- Draw bar edges in their face color by default so that very narrow adaptive bins
remain visible.
- Reject ``histtype="barstacked"` because Khisto only accepts a single dataset.

### Fixed

- Reuse Khisto frequencies when plotting so values remain assigned to the bins
selected by Khisto, including for extreme finite values.

## [1.0.2] - 2026-09-15

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/api_comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ khisto.matplotlib.hist(
| **Reverse cumulative** | Supported with negative `cumulative` | Supported with negative `cumulative` |
| **Stacked** | Supported | Not supported |
| **Weights** | Supported | Not supported |
| **Unsupported histogram args** | None | `bins`, `stacked`, and `weights` raise a `TypeError` |
| **Unsupported histogram args** | None | `bins`, `stacked`, and `weights` raise a `TypeError`; `histtype="barstacked"` raises a `ValueError` |
| **Multiple datasets** | Supported | Not supported; only 1-D arrays are accepted |

#### Usage Comparison
Expand Down
15 changes: 15 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
import os
import re
import sys
from functools import wraps
from pathlib import Path
from importlib import metadata

import khisto.matplotlib

DOCS_DIR = Path(__file__).resolve().parent
ROOT_DIR = DOCS_DIR.parent

Expand Down Expand Up @@ -55,6 +58,18 @@
"special-members": False,
}

_runtime_hist = khisto.matplotlib.hist


@wraps(_runtime_hist)
def _documented_hist(*args, **kwargs):
return _runtime_hist(*args, **kwargs)


_documented_hist.__module__ = khisto.matplotlib.__name__
_documented_hist.__qualname__ = "hist"
khisto.matplotlib.hist = _documented_hist

## Intersphinx extension config
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
Expand Down
142 changes: 49 additions & 93 deletions docs/demo.ipynb

Large diffs are not rendered by default.

Binary file modified docs/images/counts-vs-density.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/gaussian-quick-start.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/pareto-quick-start.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ Get started
.. code-block:: python

import numpy as np
from khisto import histogram
import khisto

data = np.random.normal(0, 1, 10_000)
hist, bin_edges = histogram(data) # optimal bins, no guessing
hist, bin_edges = khisto.histogram(data) # optimal bins, no guessing

.. grid:: 1 1 2 2
:gutter: 3
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ test = [
"pytest-xdist>=3.6",
"pytest-cov>=6",
"pytest-sugar>=1.0",
"typing-extensions>=4.0; python_version < '3.11'",
]
lint = [
"pre-commit>=4.1",
Expand Down
2 changes: 1 addition & 1 deletion sandbox/khisto_demo.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,7 @@
],
"metadata": {
"kernelspec": {
"display_name": "khisto-python",
"display_name": "khisto-python (3.12.3)",
"language": "python",
"name": "python3"
},
Expand Down
2 changes: 2 additions & 0 deletions src/khisto/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@

from .core import HistogramResult
from .histogram import histogram
from .matplotlib import hist

__all__ = [
"HistogramResult",
"hist",
"histogram",
]
120 changes: 105 additions & 15 deletions src/khisto/matplotlib/hist.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,75 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal, overload

import numpy as np
from matplotlib.axes import Axes

from khisto.histogram import histogram as khisto_histogram

if TYPE_CHECKING:
from numpy.typing import ArrayLike
from matplotlib.axes import Axes
from matplotlib.container import BarContainer
from matplotlib.patches import Polygon
from numpy.typing import ArrayLike, NDArray


@overload
def hist(
x: ArrayLike,
range: tuple[float, float] | None = None,
max_bins: int | None = None,
density: bool = True,
*,
ax: Axes | None = None,
histtype: Literal["bar"] = "bar",
**kwargs: Any,
) -> tuple[np.ndarray, np.ndarray, Any]:
) -> tuple[NDArray[np.float64], NDArray[np.float64], BarContainer]: ...


@overload
def hist(
x: ArrayLike,
range: tuple[float, float] | None = None,
max_bins: int | None = None,
density: bool = True,
*,
ax: Axes | None = None,
histtype: Literal["step", "stepfilled"],
**kwargs: Any,
) -> tuple[NDArray[np.float64], NDArray[np.float64], list[Polygon]]: ...


@overload
def hist(
x: ArrayLike,
range: tuple[float, float] | None = None,
max_bins: int | None = None,
density: bool = True,
*,
ax: Axes | None = None,
histtype: str,
**kwargs: Any,
) -> tuple[
NDArray[np.float64],
NDArray[np.float64],
BarContainer | list[Polygon],
]: ...


def hist(
x: ArrayLike,
range: tuple[float, float] | None = None,
max_bins: int | None = None,
density: bool = True,
*,
ax: Axes | None = None,
**kwargs: Any,
) -> tuple[
NDArray[np.float64],
NDArray[np.float64],
BarContainer | list[Polygon],
]:
"""Compute and plot an optimal histogram.

Parameters
Expand All @@ -49,7 +98,8 @@ def hist(
Axes object to plot on. If not provided, the current axes will be used.
**kwargs :
other keyword arguments are described in ``matplotlib.pyplot.hist``. The ``bins``,
``weights``, and stacked/multiple dataset features are not supported.
``weights``, ``stacked``, ``histtype="barstacked"``, and multiple dataset
features are not supported.

Returns
-------
Expand All @@ -58,7 +108,7 @@ def hist(
bins : ndarray
Bin edges.
patches
Container with the bar patches.
Container with the bar patches, or a list containing the step polygon.

.. note::
Khiops bins are left-open and right-closed, ``(lower, upper]``, unlike
Expand All @@ -70,6 +120,11 @@ def hist(
matplotlib.pyplot.hist : Matplotlib's histogram function.
khisto.histogram : Underlying histogram computation.
"""
# optional dependency; only import if strictly needed.
import matplotlib.pyplot as plt
from matplotlib.container import BarContainer
from matplotlib.patches import Polygon

unsupported_kwargs = {
"bins": "Use max_bins to limit the number of bins.",
"stacked": "Stacked histograms are not supported.",
Expand All @@ -79,16 +134,51 @@ def hist(
if name in kwargs:
raise TypeError(f"{name} is not supported. {hint}")

# Compute histogram using khisto
_, bin_edges = khisto_histogram(x, range=range, max_bins=max_bins, density=density)
histtype = kwargs.get("histtype", "bar")
if histtype == "barstacked":
raise ValueError(
"histtype='barstacked' is not supported. Khisto only accepts a single dataset."
)

if ax is None:
# optional dependency; only import if strictly needed.
import matplotlib.pyplot as plt
# Use frequencies so Matplotlib applies density and cumulative only once.
frequencies, bin_edges = khisto_histogram(
x,
range=range,
max_bins=max_bins,
density=False,
)

if ax is None:
ax = plt.gca()

# 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)
# Weighted left edges preserve Khiops' right-closed bins and [-1e100, 1e100]
# clamping when Matplotlib renders its left-closed bins.
cumulative = kwargs.get("cumulative", False)
plot_weights = (
frequencies / frequencies.sum() if density and cumulative else frequencies
)
values, edges, patches = ax.hist(
x=bin_edges[:-1],
bins=bin_edges.tolist(),
weights=plot_weights,
density=density and not cumulative,
**kwargs,
)
if isinstance(values, list):
raise TypeError("Matplotlib unexpectedly returned multiple histograms.")
if isinstance(patches, BarContainer):
histogram_patches: BarContainer | list[Polygon] = patches
elif isinstance(patches, list):
histogram_patches = [patch for patch in patches if isinstance(patch, Polygon)]
if len(histogram_patches) != len(patches):
raise TypeError("Matplotlib returned unexpected histogram patches.")
else:
raise TypeError("Matplotlib returned unexpected histogram patches.")

if histtype == "bar" and not {"edgecolor", "ec"} & kwargs.keys():
if not isinstance(histogram_patches, BarContainer):
raise TypeError("Matplotlib unexpectedly returned non-bar patches.")
for patch in histogram_patches.patches:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Selon ma compréhension, tu forces un edgecolor de même couleur que facecolor si ce paramètre n'est pas utilisé, pour forcer un épaisseur minimale aux bins:

  • mais comment-est pris en compte alors que c'est fait après l'appel à ax.hist?
  • quid si on a mis paramétré linewidth à 0? (pas de problème après tout?)
  • quid si on a mis paramétré edgecolor à blanc? (on ne verra pas le bin, qui restera d'épaisseur nulle?)

Davantage commenter le code: sans le contexte, on ne comprend pas l'intention.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Oui, il y a des parametrages qui feront qu'on ne verra plus les bin fines. Mais là je n'ai pas vraiment de solution...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Le changement de edge color est effectivement fait après le ax.hist. Ce qui s'affiche à l'écran est l'objet retourné par ax.hist, ce n'ai pas ax.hist qui affiche.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ok: merci pour l'éclaircissement.
Un petit commentaire serait utile

patch.set_edgecolor(patch.get_facecolor())

return values, edges, histogram_patches
Loading
Loading