Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7e4549f
fix(matplotlib): corriger le rendu des histogrammes adaptatifs
ElouenGinat Sep 22, 2026
13830b5
feat(api): exposer hist au niveau racine
ElouenGinat Sep 22, 2026
f7c14d4
docs(demo): simplifier le guide et actualiser les figures
ElouenGinat Sep 22, 2026
63afdbd
feat(matplotlib): préciser les types de retour de hist
ElouenGinat Sep 22, 2026
731d2d4
docs(api): utiliser les imports au niveau racine
ElouenGinat Sep 22, 2026
aa94a3b
ci: tester l'installation sans matplotlib
ElouenGinat Sep 22, 2026
2355d70
fix(tests): prendre en charge assert_type sous Python 3.10
ElouenGinat Sep 22, 2026
c40fc12
docs(sphinx): masquer les surcharges dans les signatures
ElouenGinat Sep 22, 2026
fbdb9d3
docs(sphinx): restaurer les types dans les signatures
ElouenGinat Sep 22, 2026
89f47ab
Potential fix for pull request finding 'Cumulative density is incorre…
ElouenGinat Sep 22, 2026
a9e7844
fix(matplotlib): préserver l'alias ec de la couleur de bord
ElouenGinat Sep 22, 2026
6f687db
fix(matplotlib): transmettre les données sans conversion en listes
ElouenGinat Sep 23, 2026
df5fdb6
fix(version): lire la version installée avant le pyproject.toml
ElouenGinat Sep 24, 2026
54cd2f2
refactor(matplotlib): clarifier le forçage de la couleur de bord
ElouenGinat Sep 24, 2026
9424891
test(matplotlib): supprimer l'import inutile de __future__.annotations
ElouenGinat Sep 24, 2026
9c23f18
ci: extraire le test d'installation de base dans un script
ElouenGinat Sep 24, 2026
95450e0
fix(sandbox): supprimer l'import obsolète de _hist
ElouenGinat Sep 24, 2026
15b6978
doc: better changelog
ElouenGinat Sep 28, 2026
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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
paths:
- .github/workflows/ci.yaml
- pyproject.toml
- scripts/check-base-install.py
- src/**
- tests/**

Expand All @@ -30,6 +31,20 @@ 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 scripts/check-base-install.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

### Fixed
Comment thread
marcboulle marked this conversation as resolved.

- Kept very narrow adaptive histogram bins visible by drawing, by default, bar edges in the
same color as their fill.
- Prevented incorrect histogram frequencies in `hist()` for extreme finite values, including
values above `1e100` and below `-1e100`, by reusing the frequencies computed by Khisto.
- Prevented `import khisto` from failing when an unrelated `pyproject.toml` is
present in `site-packages` in a virtual environment.
- Rejected `histtype="barstacked"` in `hist()`, which is not supported because
Khisto accepts only one dataset at a time.

## [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
5 changes: 2 additions & 3 deletions sandbox/khisto_demo.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@
},
{
"cell_type": "code",
"execution_count": 5,
"execution_count": null,
"id": "b6c4ea8c",
"metadata": {},
"outputs": [
Expand All @@ -297,7 +297,6 @@
],
"source": [
"from khisto.matplotlib import hist\n",
"from khisto.matplotlib.hist import _hist\n",
"\n",
"# Basic histogram plot\n",
"fig, ax = plt.subplots(figsize=(8, 5))\n",
Expand Down Expand Up @@ -655,7 +654,7 @@
],
"metadata": {
"kernelspec": {
"display_name": "khisto-python",
"display_name": "khisto (3.12.3)",
"language": "python",
"name": "python3"
},
Expand Down
19 changes: 19 additions & 0 deletions scripts/check-base-install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright (c) 2025-2026 Orange. All rights reserved.
# This software is distributed under the BSD 3-Clause-clear License, the text of which is available
# at https://spdx.org/licenses/BSD-3-Clause-Clear.html or see the "LICENSE" file for more details.

"""Check that the base API works without optional dependencies."""

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
25 changes: 12 additions & 13 deletions src/khisto/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,34 @@

import logging
import os
from importlib.metadata import version
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

ROOT_DIR = Path(__file__).resolve().parent.parent
KHISTO_BIN_DIR = os.environ.get("KHISTO_BIN_DIR", "khisto")

if (ROOT_DIR / "pyproject.toml").exists():
# Development mode: package not installed; pyproject.toml present
# Metadata first: a pyproject.toml check broke installs with a stray one in site-packages.
try:
__version__ = version("khisto")
except PackageNotFoundError:
# Source checkout without install: read the repository's pyproject.toml.
# TODO : Remove on Python 3.10 EOL
try:
import tomllib as tomli
import tomllib
except ModuleNotFoundError:
import tomli

with open(ROOT_DIR / "pyproject.toml", "rt") as f:
__version__ = tomli.load(f)["project"]["version"]
else:
# User mode: package installed; pyproject.toml not directly accessible
from importlib.metadata import version
import tomli as tomllib

__version__ = version("khisto")
with open(Path(__file__).resolve().parents[2] / "pyproject.toml", "rb") as f:
__version__ = tomllib.load(f)["project"]["version"]

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

__all__ = [
"HistogramResult",
"hist",
"histogram",
]
129 changes: 114 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,60 @@ 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. "
"The patches should all be of type Polygon."
)
else:
raise TypeError(
"Matplotlib returned unexpected histogram patches of type "
f"{type(patches).__name__}; expected BarContainer or list of Polygon."
)

# Adaptive bins can be narrower than a pixel and vanish when only filled.
# Drawing the edge in the face color gives them a visible minimal width.
# The patches are rendered at draw time, so updating them here still applies.
if histtype == "bar" and not {"edgecolor", "ec"}.intersection(kwargs.keys()):
if not isinstance(histogram_patches, BarContainer):
raise TypeError("Matplotlib unexpectedly returned non-bar patches.")
for patch in histogram_patches.patches:
Comment thread
marcboulle marked this conversation as resolved.
patch.set_edgecolor(patch.get_facecolor())

return values, edges, histogram_patches
Loading
Loading