Skip to content

Commit a297f79

Browse files
committed
Refactor histogram computation: remove cli_adapter, add backend tests
- Deleted the cli_adapter.py module, consolidating histogram computation logic. - Introduced a new test suite for the backend module, covering histogram parsing and processing. - Enhanced test coverage for histogram results and CLI interactions. - Ensured proper handling of various histogram file types and edge cases in tests.
1 parent cb458d2 commit a297f79

7 files changed

Lines changed: 660 additions & 809 deletions

File tree

sandbox/khisto_demo.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@
470470
" Granularity 3: 4 bins\n",
471471
" Granularity 4: 7 bins\n",
472472
" Granularity 5: 8 bins\n",
473-
" Granularity 6: 7 bins ← BEST\n"
473+
" Granularity 7: 7 bins ← BEST\n"
474474
]
475475
}
476476
],

src/khisto/array/histogram/api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import numpy as np
1212
from numpy.typing import ArrayLike, NDArray
1313

14-
from khisto.core import HistogramResult, compute_histogram
14+
from khisto.core import HistogramResult, compute_histograms
1515

1616

1717
def _select_histogram(
@@ -130,7 +130,7 @@ def histogram(
130130
min_val, max_val = range
131131
arr = arr[(arr >= min_val) & (arr <= max_val)]
132132

133-
results = compute_histogram(arr)
133+
results = compute_histograms(arr)
134134
result = _select_histogram(results, max_bins=max_bins)
135135

136136
if density:

src/khisto/core/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
# This software is distributed under the BSD 3-Clause-clear License, the text of which is available
33
# at https://spdx.org/licenses/BSD-3-Clause-Clear.html or see the "LICENSE" file for more details.
44

5-
from .cli_adapter import compute_histogram, HistogramResult
5+
from .backend import compute_histograms, HistogramResult
66

7-
__all__ = ["compute_histogram", "HistogramResult"]
7+
__all__ = ["compute_histograms", "HistogramResult"]

src/khisto/core/backend.py

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
# Copyright (c) 2025-2026 Orange. All rights reserved.
2+
# This software is distributed under the BSD 3-Clause-clear License, the text of which is available
3+
# at https://spdx.org/licenses/BSD-3-Clause-Clear.html or see the "LICENSE" file for more details.
4+
5+
"""Backend module for computing optimal histograms using the khisto CLI.
6+
7+
This module provides the interface to the khisto binary for computing
8+
optimal histograms using the Khiops algorithm.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import pathlib
15+
import shutil
16+
import subprocess
17+
import tempfile
18+
from dataclasses import dataclass
19+
from typing import Any, Optional
20+
21+
import numpy as np
22+
from numpy.typing import NDArray
23+
24+
from khisto import KHISTO_BIN_DIR, logger
25+
26+
27+
@dataclass
28+
class HistogramResult:
29+
"""Result of optimal histogram computation.
30+
31+
Attributes
32+
----------
33+
lower_bound : NDArray[np.float64]
34+
Lower bounds of each bin.
35+
upper_bound : NDArray[np.float64]
36+
Upper bounds of each bin.
37+
frequency : NDArray[np.int64]
38+
Count of values in each bin.
39+
probability : NDArray[np.float64]
40+
Probability of each bin (frequency / total).
41+
density : NDArray[np.float64]
42+
Density of each bin (probability / bin_width).
43+
is_best : bool
44+
Whether this histogram is the optimal one.
45+
granularity : int
46+
The granularity level of this histogram.
47+
"""
48+
49+
lower_bound: NDArray[np.float64]
50+
upper_bound: NDArray[np.float64]
51+
frequency: NDArray[np.int64]
52+
probability: NDArray[np.float64]
53+
density: NDArray[np.float64]
54+
is_best: bool = False
55+
granularity: int = 0
56+
57+
@property
58+
def bin_edges(self) -> NDArray[np.float64]:
59+
"""Return bin edges array (n_bins + 1 values)."""
60+
return np.concatenate([self.lower_bound, [self.upper_bound[-1]]])
61+
62+
@property
63+
def bin_widths(self) -> NDArray[np.float64]:
64+
"""Return width of each bin."""
65+
return self.upper_bound - self.lower_bound
66+
67+
@property
68+
def bin_centers(self) -> NDArray[np.float64]:
69+
"""Return center of each bin."""
70+
return (self.lower_bound + self.upper_bound) / 2
71+
72+
def __len__(self) -> int:
73+
"""Return number of bins."""
74+
return len(self.lower_bound)
75+
76+
77+
def _parse_file_type(
78+
file_path: pathlib.Path,
79+
) -> None:
80+
"""Validate the exploratory JSON output file name.
81+
82+
Parameters
83+
----------
84+
file_path : pathlib.Path
85+
Path to the JSON output file.
86+
87+
Raises
88+
------
89+
ValueError
90+
If the filename pattern is not recognized.
91+
"""
92+
if file_path.suffix != ".json" or ".series" not in file_path.stem:
93+
raise ValueError(f"Unrecognized histogram file name: {file_path.name}")
94+
95+
96+
def _json_float_array(payload: dict[str, Any], key: str) -> NDArray[np.float64]:
97+
"""Read a JSON numeric list as a float64 array."""
98+
values = payload.get(key)
99+
if not isinstance(values, list):
100+
raise ValueError(f"Missing or invalid histogram field: {key}")
101+
return np.asarray(values, dtype=np.float64)
102+
103+
104+
def _json_int_array(payload: dict[str, Any], key: str) -> NDArray[np.int64]:
105+
"""Read a JSON numeric list as an int64 array."""
106+
values = payload.get(key)
107+
if not isinstance(values, list):
108+
raise ValueError(f"Missing or invalid histogram field: {key}")
109+
return np.asarray(values, dtype=np.int64)
110+
111+
112+
def _build_histogram_result(
113+
data: dict[str, Any],
114+
*,
115+
is_best: bool,
116+
granularity: int,
117+
) -> HistogramResult:
118+
"""Build a histogram result object from parsed JSON data."""
119+
lower_bound = _json_float_array(data, "lowerBounds")
120+
upper_bound = _json_float_array(data, "upperBounds")
121+
frequency = _json_int_array(data, "frequencies")
122+
probability = _json_float_array(data, "probabilities")
123+
density = _json_float_array(data, "densities")
124+
125+
expected_size = len(lower_bound)
126+
if not all(
127+
len(array) == expected_size
128+
for array in (upper_bound, frequency, probability, density)
129+
):
130+
raise ValueError("Inconsistent histogram payload lengths")
131+
132+
return HistogramResult(
133+
lower_bound=lower_bound,
134+
upper_bound=upper_bound,
135+
frequency=frequency,
136+
probability=probability,
137+
density=density,
138+
is_best=is_best,
139+
granularity=granularity,
140+
)
141+
142+
143+
def _same_histogram(
144+
left: HistogramResult,
145+
right: HistogramResult,
146+
) -> bool:
147+
"""Return True when two histogram results describe the same bins."""
148+
return all(
149+
np.array_equal(left_array, right_array)
150+
for left_array, right_array in (
151+
(left.lower_bound, right.lower_bound),
152+
(left.upper_bound, right.upper_bound),
153+
(left.frequency, right.frequency),
154+
(left.probability, right.probability),
155+
(left.density, right.density),
156+
)
157+
)
158+
159+
160+
def _best_histogram_index_from_series(series_data: dict[str, Any]) -> Optional[int]:
161+
"""Return the finest interpretable histogram index from the series metadata."""
162+
interpretable_histogram_number = series_data.get("interpretableHistogramNumber")
163+
if not isinstance(interpretable_histogram_number, int):
164+
return None
165+
if interpretable_histogram_number <= 0:
166+
return None
167+
return interpretable_histogram_number - 1
168+
169+
170+
def _read_histogram_json(file_path: pathlib.Path) -> dict[str, Any]:
171+
"""Read the khisto exploratory JSON output file."""
172+
with file_path.open("r", encoding="utf-8") as stream:
173+
payload = json.load(stream)
174+
175+
if not isinstance(payload, dict):
176+
raise ValueError("Invalid histogram JSON payload")
177+
return payload
178+
179+
180+
def _process_histogram_files(
181+
temp_dir: str,
182+
base_name: str,
183+
) -> list[HistogramResult]:
184+
"""Process exploratory JSON generated by khisto CLI.
185+
186+
Parameters
187+
----------
188+
temp_dir : str
189+
Directory containing histogram files.
190+
base_name : str
191+
Base name of the histogram JSON file (without extension).
192+
193+
Returns
194+
-------
195+
list[HistogramResult]
196+
List of histogram results at all granularity levels,
197+
sorted from coarsest to finest.
198+
"""
199+
output_file = pathlib.Path(temp_dir) / f"{base_name}.json"
200+
_parse_file_type(output_file)
201+
202+
payload = _read_histogram_json(output_file)
203+
best_histogram_payload = payload.get("bestHistogram")
204+
best_histogram = None
205+
if isinstance(best_histogram_payload, dict):
206+
best_histogram = _build_histogram_result(
207+
best_histogram_payload,
208+
is_best=False,
209+
granularity=0,
210+
)
211+
212+
series_data = payload.get("histogramSeries")
213+
if not isinstance(series_data, dict):
214+
if best_histogram is None:
215+
raise ValueError("No histogram data found")
216+
best_histogram.is_best = True
217+
return [best_histogram]
218+
219+
histogram_payloads = series_data.get("histograms")
220+
if not isinstance(histogram_payloads, list) or not histogram_payloads:
221+
if best_histogram is None:
222+
raise ValueError("No histogram data found")
223+
best_histogram.is_best = True
224+
return [best_histogram]
225+
226+
granularities_payload = series_data.get("granularities")
227+
if isinstance(granularities_payload, list) and len(granularities_payload) == len(
228+
histogram_payloads
229+
):
230+
granularities = [int(granularity) for granularity in granularities_payload]
231+
else:
232+
granularities = list(range(len(histogram_payloads)))
233+
234+
best_index = _best_histogram_index_from_series(series_data)
235+
236+
results = []
237+
for index, (granularity, histogram_payload) in enumerate(
238+
zip(granularities, histogram_payloads)
239+
):
240+
if not isinstance(histogram_payload, dict):
241+
raise ValueError("Invalid histogram payload in histogramSeries")
242+
243+
result = _build_histogram_result(
244+
histogram_payload,
245+
is_best=best_index == index,
246+
granularity=granularity,
247+
)
248+
249+
if not result.is_best and best_index is None and best_histogram is not None:
250+
result.is_best = _same_histogram(result, best_histogram)
251+
252+
results.append(result)
253+
254+
if results:
255+
return results
256+
257+
raise ValueError("No histogram data found")
258+
259+
260+
def compute_histograms(
261+
x: np.ndarray,
262+
) -> list[HistogramResult]:
263+
"""Compute optimal histogram of an array using khisto CLI binary input.
264+
265+
Parameters
266+
----------
267+
x : np.ndarray
268+
Array of numeric values.
269+
270+
Returns
271+
-------
272+
list[HistogramResult]
273+
A list of HistogramResult, one per granularity level,
274+
sorted from coarsest to finest. Each result contains:
275+
- lower_bound : array of lower bin bounds
276+
- upper_bound : array of upper bin bounds
277+
- frequency : count in each bin
278+
- probability : probability of each bin
279+
- density : density of each bin
280+
- is_best : whether this is the optimal histogram
281+
- granularity : the granularity level
282+
283+
Raises
284+
------
285+
RuntimeError
286+
If khisto CLI execution fails.
287+
TypeError
288+
If input array is not numeric.
289+
ValueError
290+
If input array is empty after filtering.
291+
"""
292+
# Convert to numpy array if needed
293+
x = np.asarray(x, dtype=np.float64)
294+
295+
# Remove NaN values
296+
x = x[~np.isnan(x)]
297+
298+
if len(x) == 0:
299+
raise ValueError("Input array is empty after filtering")
300+
301+
with tempfile.NamedTemporaryFile(
302+
mode="wb", suffix=".bin", delete=False
303+
) as temp_input:
304+
x.tofile(temp_input)
305+
temp_input_path = temp_input.name
306+
307+
# Create temporary directory for output files
308+
temp_dir = tempfile.mkdtemp(prefix="khisto_output_")
309+
output_file = pathlib.Path(temp_dir) / "histogram.series.json"
310+
cmd = []
311+
312+
try:
313+
# Build command arguments - always use exploratory mode
314+
cmd = [
315+
str(KHISTO_BIN_DIR),
316+
"-b",
317+
"-e",
318+
"-j",
319+
temp_input_path,
320+
str(output_file),
321+
]
322+
323+
# Execute khisto CLI
324+
subprocess.run(cmd, capture_output=True, text=True, check=True)
325+
326+
result = _process_histogram_files(
327+
temp_dir,
328+
output_file.stem,
329+
)
330+
331+
return result
332+
333+
except subprocess.CalledProcessError as e:
334+
stdout = e.stdout.strip()
335+
stderr = e.stderr.strip()
336+
details = "\n".join(part for part in (stdout, stderr) if part)
337+
message = f"khisto failed with exit code {e.returncode} while running: {' '.join(cmd)}"
338+
if details:
339+
message = f"{message}\n{details}"
340+
logger.error(message)
341+
raise RuntimeError(message) from e
342+
finally:
343+
# Clean up temporary input file and directory
344+
pathlib.Path(temp_input_path).unlink(missing_ok=True)
345+
shutil.rmtree(temp_dir, ignore_errors=True)

0 commit comments

Comments
 (0)