|
| 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