Coverage for packages/dqm-ml-core/src/dqm_ml_core/metrics/representativeness.py: 90%

339 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-21 08:27 +0000

1"""Representativeness metric processor for evaluating distribution fit. 

2 

3This module contains the RepresentativenessProcessor class that evaluates 

4how well a dataset represents a target statistical distribution using 

5various statistical tests. 

6""" 

7 

8import json 

9import logging 

10from typing import Any 

11 

12import numpy as np 

13import pandas as pd 

14import pyarrow as pa 

15from scipy import stats 

16 

17# COMPATIBILITY : from typing import Any, override # When support of 3.10 and 3.11 will be removed 

18from typing_extensions import override 

19 

20from dqm_ml_core.api.metrics_processor import MetricsProcessor 

21from dqm_ml_core.models.processors import RepresentativenessProcessorConfig 

22 

23logger = logging.getLogger(__name__) 

24 

25 

26class RepresentativenessProcessor(MetricsProcessor): 

27 """ 

28 Evaluates how well the dataset represents a target statistical distribution. 

29 

30 This processor performs on samples discretisation statistical tests to compare the observed 

31 distribution of numerical columns against a theoretical target distribution 

32 (Normal or Uniform). 

33 

34 Supported Metrics: 

35 - Chi-square: Goodness-of-fit test for categorical/binned data. 

36 - Kolmogorov-Smirnov (KS): Non-parametric test for continuous distributions (approximated via sampling). 

37 - Shannon Entropy: Measures the information diversity of the binned data. 

38 - GRTE (Geometric Representativeness Trajectory Error): Measures the exponential gap 

39 between observed and theoretical entropy. 

40 

41 The processor uses a streaming architecture: 

42 - Batch level: Computes partial calculus. 

43 - Dataset level: Aggregates histograms and performs final statistical tests. 

44 """ 

45 

46 SUPPORTED_METRICS = { 

47 "chi-square", 

48 "grte", 

49 "shannon-entropy", 

50 "kolmogorov-smirnov", 

51 } 

52 SUPPORTED_DISTS = {"normal", "uniform"} 

53 

54 # Configuration constants - can be overridden in config 

55 DEFAULT_ALPHA = 0.05 # Significance level for statistical tests 

56 DEFAULT_SHANNON_ENTROPY_THRESHOLD = 2.0 # Threshold for high/low diversity interpretation 

57 DEFAULT_GRTE_THRESHOLD = 0.5 # Threshold for high/low representativeness interpretation 

58 DEFAULT_KS_SAMPLE_SIZE = 500 # Maximum sample size for KS test 

59 DEFAULT_KS_MIN_SAMPLE_SIZE = 50 # Minimum sample size for KS test 

60 DEFAULT_KS_SAMPLE_DIVISOR = 20 # Divisor for calculating sample size per batch 

61 DEFAULT_EPSILON = 1e-9 # Small value to avoid division by zero 

62 DEFAULT_INTERPRETATION_THRESHOLDS = { 

63 "follows_distribution": "follows_distribution", 

64 "does_not_follow_distribution": "does_not_follow_distribution", 

65 "high_diversity": "high_diversity", 

66 "low_diversity": "low_diversity", 

67 "high_representativeness": "high_representativeness", 

68 "low_representativeness": "low_representativeness", 

69 } 

70 

71 def __init__( 

72 self, 

73 name: str = "representativeness", 

74 config: dict[str, Any] | None = None, 

75 ) -> None: 

76 """ 

77 Initialize the representativeness processor. 

78 

79 Args: 

80 name: Name of the processor. 

81 config: Configuration dictionary containing: 

82 - input_columns: List of columns to analyze. 

83 - metrics: List of metrics to compute (default: all supported). 

84 - bins: Number of bins for histograms (default: 10). 

85 - distribution: Target distribution ("normal" or "uniform"). 

86 - alpha: Significance level (default: 0.05). 

87 - distribution_params: Dictionary of params (e.g., mean, std, min, max). 

88 """ 

89 super().__init__(name, config) 

90 self.name = name 

91 cfg = RepresentativenessProcessorConfig.model_validate({**self.config, "name": self.name}) 

92 self._init_from_cfg(cfg) 

93 

94 # Use device from compute config if available 

95 compute_device = getattr(self, "compute_device", None) 

96 self.device = self._resolve_device(compute_device) if compute_device else self._resolve_device("cpu") 

97 

98 # Use seed from compute config if available 

99 compute_seed = getattr(self, "compute_seed", None) 

100 if compute_seed is not None: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true

101 self._rng = np.random.default_rng(compute_seed) 

102 else: 

103 self._rng = np.random.default_rng(42) 

104 

105 self._bin_edges: dict[str, np.ndarray] = {} 

106 self._bin_params: dict[str, dict[str, float]] = {} # caching for _compute_expected_counts 

107 self._initialized: bool = False 

108 

109 def _set_optional_subconfig(self, cfg: RepresentativenessProcessorConfig) -> None: 

110 self.bins = cfg.histogram.bins if cfg.histogram else 10 

111 self.shannon_entropy_threshold = ( 

112 cfg.shannon.threshold if cfg.shannon else self.DEFAULT_SHANNON_ENTROPY_THRESHOLD 

113 ) 

114 self.grte_threshold = cfg.grte.threshold if cfg.grte else self.DEFAULT_GRTE_THRESHOLD 

115 self.grte_scaling_factor = cfg.grte.scaling_factor if cfg.grte else -2.0 

116 self.ks_sample_size = cfg.ks.sample_size if cfg.ks else self.DEFAULT_KS_SAMPLE_SIZE 

117 self.ks_min_sample_size = cfg.ks.min_sample_size if cfg.ks else self.DEFAULT_KS_MIN_SAMPLE_SIZE 

118 self.ks_sample_divisor = cfg.ks.sample_divisor if cfg.ks else self.DEFAULT_KS_SAMPLE_DIVISOR 

119 self.interpretation_thresholds = ( 

120 dict(cfg.interpretation.model_dump()) if cfg.interpretation else self.DEFAULT_INTERPRETATION_THRESHOLDS 

121 ) 

122 

123 @staticmethod 

124 def _parse_distribution_params( 

125 cfg: RepresentativenessProcessorConfig, 

126 ) -> dict[str, dict[str, float]]: 

127 dist_params: dict[str, dict[str, float]] = {} 

128 if not cfg.distribution_params: 

129 return dist_params 

130 for p in cfg.distribution_params: 

131 dd: dict[str, float] = {} 

132 if p.mean is not None: 

133 dd["mean"] = p.mean 

134 if p.std is not None: 

135 dd["std"] = p.std 

136 if p.min is not None: 

137 dd["min"] = p.min 

138 if p.max is not None: 

139 dd["max"] = p.max 

140 dist_params[p.column] = dd 

141 return dist_params 

142 

143 def _init_from_cfg(self, cfg: RepresentativenessProcessorConfig) -> None: 

144 self.metrics = list(cfg.metrics) 

145 self._set_optional_subconfig(cfg) 

146 self.distribution = cfg.distribution 

147 self.mean_std_estimation = cfg.mean_std_estimation 

148 self.expected_counts_method = cfg.expected_counts_method 

149 self.alpha = cfg.alpha 

150 self.epsilon = cfg.epsilon 

151 self.dist_params = self._parse_distribution_params(cfg) 

152 

153 @override 

154 def generated_metrics(self) -> list[str]: 

155 """ 

156 Return the list of metric columns that will be generated. 

157 

158 Returns: 

159 List of output metric column names 

160 """ 

161 # TODO : manage output metrics names with configuration 

162 # for now we follow a fixed naming convention 

163 metrics = [] 

164 for col in self.input_columns: 

165 if "chi-square" in self.metrics: 165 ↛ 169line 165 didn't jump to line 169 because the condition on line 165 was always true

166 metrics.append(f"{col}_chi-square_p_value") 

167 metrics.append(f"{col}_chi-square_statistic") 

168 metrics.append(f"{col}_chi-square_interpretation") 

169 if "kolmogorov-smirnov" in self.metrics: 169 ↛ 173line 169 didn't jump to line 173 because the condition on line 169 was always true

170 metrics.append(f"{col}_kolmogorov-smirnov_p_value") 

171 metrics.append(f"{col}_kolmogorov-smirnov_statistic") 

172 metrics.append(f"{col}_kolmogorov-smirnov_interpretation") 

173 if "shannon-entropy" in self.metrics: 173 ↛ 176line 173 didn't jump to line 176 because the condition on line 173 was always true

174 metrics.append(f"{col}_shannon-entropy_entropy") 

175 metrics.append(f"{col}_shannon-entropy_interpretation") 

176 if "grte" in self.metrics: 176 ↛ 164line 176 didn't jump to line 164 because the condition on line 176 was always true

177 metrics.append(f"{col}_grte_grte_value") 

178 metrics.append(f"{col}_grte_interpretation") 

179 

180 return metrics 

181 

182 # utils functions 

183 @staticmethod 

184 def _resolve_device(device: str) -> str: 

185 """Resolve ``"auto"`` to CUDA if available, else CPU.""" 

186 if device == "auto": 

187 try: 

188 import torch 

189 

190 return "cuda" if torch.cuda.is_available() else "cpu" 

191 except ImportError: 

192 return "cpu" 

193 return device 

194 

195 @staticmethod 

196 def _convert_column_to_numeric(feature_array: pa.Array) -> pd.Series | None: 

197 """Convert a PyArrow column array to a numeric pandas Series with NaN handling. 

198 

199 Args: 

200 feature_array: PyArrow array from the batch. 

201 

202 Returns: 

203 Numeric pandas Series with NaN dropped, or None if conversion fails. 

204 """ 

205 try: 

206 np_col = np.asarray(feature_array.to_numpy(zero_copy_only=False)) 

207 except Exception: 

208 np_col = pd.Series(feature_array.to_pylist()).to_numpy(copy=True) 

209 numeric_values = pd.to_numeric(pd.Series(np_col), errors="coerce").dropna() 

210 return numeric_values if not numeric_values.empty else None 

211 

212 def _compute_batch_ks_sample(self, numeric_values: pd.Series) -> np.ndarray | None: 

213 """Compute a random KS sample from numeric values if KS or chi-square is enabled. 

214 

215 Args: 

216 numeric_values: Numeric pandas Series from a batch. 

217 

218 Returns: 

219 Sampled numpy array, or None if no sampling is needed. 

220 """ 

221 if "kolmogorov-smirnov" not in self.metrics and "chi-square" not in self.metrics: 

222 return None 

223 

224 sample_per_batch = min( 

225 self.ks_sample_size, 

226 max( 

227 self.ks_min_sample_size, 

228 len(numeric_values) // self.ks_sample_divisor, 

229 ), 

230 ) 

231 if len(numeric_values) > sample_per_batch: 

232 sample_indices = self._rng.choice(len(numeric_values), sample_per_batch, replace=False) 

233 return np.asarray(numeric_values.iloc[sample_indices]) 

234 return np.asarray(numeric_values) 

235 

236 @override 

237 def compute_batch_metric(self, features: dict[str, pa.Array]) -> dict[str, pa.Array]: 

238 """ 

239 Compute partial histogram statistics per batch for streaming aggregation. 

240 

241 Args: 

242 features: Dictionary of column arrays from this batch. 

243 

244 Returns: 

245 Dictionary containing: 

246 - {col}_count: Total valid numeric samples. 

247 - {col}_hist: Histogram counts. 

248 - {col}_ks_sample: Random subset of data for KS test. 

249 """ 

250 batch_metrics = {} 

251 

252 for col in self.input_columns: 

253 if col not in features: 

254 logger.warning(f"[{self.name}] column '{col}' not found in batch") 

255 continue 

256 

257 numeric_values = self._convert_column_to_numeric(features[col]) 

258 if numeric_values is None: 

259 logger.warning(f"[{self.name}] column '{col}' has no valid numeric values in this batch") 

260 continue 

261 

262 if not self._initialized or col not in self._bin_edges: 

263 self._initialize_bin_edges(numeric_values.to_numpy(), col) 

264 

265 edges = self._bin_edges[col] 

266 hist_counts = np.histogram(numeric_values, bins=edges)[0].astype(np.int64) 

267 

268 batch_metrics[f"{col}_count"] = pa.array([len(numeric_values)], type=pa.int64()) 

269 batch_metrics[f"{col}_hist"] = pa.FixedSizeListArray.from_arrays( 

270 hist_counts, list_size=hist_counts.shape[0] 

271 ) 

272 

273 ks_sample = self._compute_batch_ks_sample(numeric_values) 

274 if ks_sample is not None: 274 ↛ 252line 274 didn't jump to line 252 because the condition on line 274 was always true

275 batch_metrics[f"{col}_ks_sample"] = pa.array(ks_sample.tolist(), type=pa.float64()) 

276 

277 if not self._initialized and batch_metrics: 

278 self._initialized = True 

279 

280 return batch_metrics 

281 

282 def _get_normal_init_params(self, col: str, sample_data: np.ndarray) -> tuple[float, float]: 

283 col_params = self.dist_params.get(col, {}) 

284 if self.mean_std_estimation == "user_provided": 

285 if "mean" not in col_params or "std" not in col_params: 

286 raise ValueError( 

287 f"[{self.name}] user_provided strategy requires 'mean' and 'std' " 

288 f"in distribution_params for column '{col}'" 

289 ) 

290 mean = float(col_params["mean"]) 

291 std = float(col_params["std"]) 

292 else: 

293 mean = float(col_params.get("mean", np.mean(sample_data))) 

294 std = float(col_params.get("std", np.std(sample_data, ddof=0))) 

295 std = std if std > 0.0 else self.epsilon 

296 return mean, std 

297 

298 def _get_uniform_init_params(self, col: str, sample_data: np.ndarray) -> tuple[float, float]: 

299 col_params = self.dist_params.get(col, {}) 

300 if self.mean_std_estimation == "user_provided": 

301 if "min" not in col_params or "max" not in col_params: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true

302 raise ValueError( 

303 f"[{self.name}] user_provided strategy requires 'min' and 'max' " 

304 f"in distribution_params for column '{col}'" 

305 ) 

306 min_val = float(col_params["min"]) 

307 max_val = float(col_params["max"]) 

308 else: 

309 min_val = float(col_params.get("min", np.min(sample_data))) 

310 max_val = float(col_params.get("max", np.max(sample_data))) 

311 if max_val <= min_val: 

312 max_val = min_val + self.epsilon 

313 return min_val, max_val 

314 

315 def _initialize_bin_edges(self, sample_data: np.ndarray, col: str) -> None: 

316 sample_data = sample_data[np.isfinite(sample_data)] 

317 if len(sample_data) == 0: 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true

318 sample_data = np.array([0.0]) 

319 if self.distribution == "normal": 

320 mean, std = self._get_normal_init_params(col, sample_data) 

321 edges = self._bin_edges_normal(mean, std, self.bins) 

322 self._bin_params[col] = {"mean": mean, "std": std} 

323 else: 

324 min_val, max_val = self._get_uniform_init_params(col, sample_data) 

325 edges = self._bin_edges_uniform(min_val, max_val, self.bins, sample_data) 

326 self._bin_edges[col] = edges 

327 

328 def _aggregate_column_metrics( 

329 self, batch_metrics: dict[str, pa.Array], col: str 

330 ) -> tuple[int, np.ndarray, np.ndarray] | None: 

331 """Aggregate histogram and count for a single column across all batches. 

332 

333 Args: 

334 batch_metrics: Dictionary of batch-level metrics. 

335 col: Column name. 

336 

337 Returns: 

338 Tuple of (total_count, obs_counts, edges) or None if aggregation fails. 

339 """ 

340 count_key = f"{col}_count" 

341 hist_key = f"{col}_hist" 

342 if count_key not in batch_metrics or hist_key not in batch_metrics: 

343 logger.warning(f"[{self.name}] no batch metrics for column '{col}'") 

344 return None 

345 

346 hist_batch_arrays = np.asarray(batch_metrics[hist_key].to_numpy(zero_copy_only=False)) 

347 if hist_batch_arrays.shape[0] == 0: 

348 logger.warning(f"[{self.name}] no histogram batch for '{col}'") 

349 return None 

350 

351 total_count = int(np.sum(batch_metrics[count_key].to_numpy())) 

352 hist_arrays = hist_batch_arrays[0].copy() 

353 for batch_hist in hist_batch_arrays[1:]: 

354 hist_arrays += batch_hist 

355 obs_counts = hist_arrays.astype(float) 

356 

357 if total_count <= 0 or obs_counts.sum() <= 0: 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true

358 logger.warning(f"[{self.name}] no valid data for column '{col}'") 

359 return None 

360 

361 if col not in self._bin_edges: 

362 logger.warning(f"[{self.name}] no bin edges for column '{col}' - skipping") 

363 return None 

364 

365 return total_count, obs_counts, self._bin_edges[col] 

366 

367 def _resolve_normal_params(self, col: str, batch_metrics: dict[str, pa.Array]) -> tuple[float, float]: 

368 if self.mean_std_estimation == "from_first_batch": 

369 params = self._bin_params.get(col) 

370 if params is not None: 370 ↛ 372line 370 didn't jump to line 372 because the condition on line 370 was always true

371 return params["mean"], params["std"] 

372 return self._estimate_normal_params(col, batch_metrics) 

373 if self.mean_std_estimation == "per_batch": 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true

374 return self._estimate_normal_params(col, batch_metrics) 

375 if self.mean_std_estimation == "user_provided": 

376 col_params = self.dist_params.get(col, {}) 

377 if "mean" not in col_params or "std" not in col_params: 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true

378 raise ValueError( 

379 f"[{self.name}] user_provided strategy requires 'mean' and 'std' " 

380 f"in distribution_params for column '{col}'" 

381 ) 

382 std = float(col_params["std"]) 

383 return float(col_params["mean"]), std if std > 0.0 else self.epsilon 

384 raise NotImplementedError( 

385 f"[{self.name}] from_all_data strategy requires two-pass processing and is not yet implemented" 

386 ) 

387 

388 def _compute_expected_counts( 

389 self, 

390 col: str, 

391 batch_metrics: dict[str, pa.Array], 

392 total_count: int, 

393 edges: np.ndarray, 

394 ) -> np.ndarray: 

395 if self.distribution == "normal": 

396 mean, std = self._resolve_normal_params(col, batch_metrics) 

397 if self.expected_counts_method == "cdf": 397 ↛ 400line 397 didn't jump to line 400 because the condition on line 397 was always true

398 expected_probs = stats.norm.cdf(edges[1:], mean, std) - stats.norm.cdf(edges[:-1], mean, std) 

399 return np.asarray((expected_probs * total_count).astype(np.float64)) 

400 expected_values = self._rng.normal(mean, std, total_count) 

401 else: 

402 col_params = self.dist_params.get(col, {}) 

403 min_val = float(col_params.get("min", edges[0])) 

404 max_val = float(col_params.get("max", edges[-1])) 

405 if self.expected_counts_method == "cdf": 405 ↛ 410line 405 didn't jump to line 410 because the condition on line 405 was always true

406 expected_probs = stats.uniform.cdf(edges[1:], min_val, max_val - min_val) - stats.uniform.cdf( 

407 edges[:-1], min_val, max_val - min_val 

408 ) 

409 return np.asarray((expected_probs * total_count).astype(np.float64)) 

410 expected_values = self._rng.uniform(min_val, max_val, total_count) 

411 return np.histogram(expected_values, bins=edges)[0].astype(np.float64) 

412 

413 def _estimate_from_samples( 

414 self, 

415 col_params: dict[str, Any], 

416 sample_key: str, 

417 batch_metrics: dict[str, pa.Array], 

418 ) -> tuple[float, float]: 

419 """Estimate normal params from KS sample data or fall back to defaults.""" 

420 if sample_key in batch_metrics: 420 ↛ 427line 420 didn't jump to line 427 because the condition on line 420 was always true

421 sample_arrays = batch_metrics[sample_key].to_numpy() 

422 if sample_arrays.ndim > 1: 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true

423 sample_arrays = sample_arrays.flatten() 

424 mean = float(col_params.get("mean", np.mean(sample_arrays))) 

425 std = float(col_params.get("std", np.std(sample_arrays, ddof=0))) 

426 else: 

427 mean = float(col_params.get("mean", 0.0)) 

428 std = float(col_params.get("std", 1.0)) 

429 return mean, std 

430 

431 def _estimate_normal_params(self, col: str, batch_metrics: dict[str, pa.Array]) -> tuple[float, float]: 

432 """Estimate normal distribution parameters from config or KS samples. 

433 

434 Args: 

435 col: Column name. 

436 batch_metrics: Batch-level metrics dict. 

437 

438 Returns: 

439 Tuple of (mean, std). 

440 """ 

441 col_params = self.dist_params.get(col, {}) 

442 if self.mean_std_estimation == "user_provided": 

443 if "mean" not in col_params or "std" not in col_params: 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true

444 raise ValueError( 

445 f"[{self.name}] user_provided strategy requires 'mean' and 'std' " 

446 f"in distribution_params for column '{col}'" 

447 ) 

448 mean = float(col_params["mean"]) 

449 std = float(col_params["std"]) 

450 else: 

451 sample_key = f"{col}_ks_sample" 

452 mean, std = self._estimate_from_samples(col_params, sample_key, batch_metrics) 

453 std = std if std > 0.0 else self.epsilon 

454 return mean, std 

455 

456 def _compute_chi_square_metric(self, obs_counts: np.ndarray, exp_counts: np.ndarray) -> dict[str, Any]: 

457 """Compute chi-square goodness-of-fit test between observed and expected counts. 

458 

459 Args: 

460 obs_counts: Observed frequency counts per bin. 

461 exp_counts: Expected frequency counts per bin. 

462 

463 Returns: 

464 Dict with p_value, statistic, interpretation keys. 

465 """ 

466 mask = exp_counts > 0 

467 if mask.sum() < 2: 

468 return { 

469 "p_value": float("nan"), 

470 "statistic": float("nan"), 

471 "interpretation": "insufficient_bins", 

472 } 

473 

474 obs_sum = obs_counts[mask].sum() 

475 exp_sum = exp_counts[mask].sum() 

476 

477 exp_counts_normalized = exp_counts[mask] * (obs_sum / exp_sum) 

478 try: 

479 chi = stats.chisquare(f_obs=obs_counts[mask], f_exp=exp_counts_normalized) 

480 return { 

481 "p_value": float(chi.pvalue), 

482 "statistic": float(chi.statistic), 

483 "interpretation": self.interpretation_thresholds.get( 

484 "follows_distribution" if chi.pvalue >= self.alpha else "does_not_follow_distribution", 

485 "follows_distribution", 

486 ), 

487 } 

488 except ValueError as e: 

489 return { 

490 "p_value": float("nan"), 

491 "statistic": float("nan"), 

492 "interpretation": f"chi_square_failed: {e!s}", 

493 "note": "using observed counts only due to statistical constraints", 

494 } 

495 

496 def _compute_ks_metric(self, col: str, batch_metrics: dict[str, pa.Array]) -> dict[str, Any]: 

497 """Compute Kolmogorov-Smirnov test using sampled data. 

498 

499 Args: 

500 col: Column name. 

501 batch_metrics: Batch-level metrics dict containing KS samples. 

502 

503 Returns: 

504 Dict with p_value, statistic, interpretation keys. 

505 """ 

506 sample_key = f"{col}_ks_sample" 

507 if sample_key not in batch_metrics: 

508 return { 

509 "p_value": float("nan"), 

510 "statistic": float("nan"), 

511 "interpretation": "no_sample_data_found", 

512 } 

513 

514 sample_arrays = batch_metrics[sample_key].to_numpy() 

515 ks_samples = sample_arrays if sample_arrays.ndim == 1 else sample_arrays.flatten() 

516 

517 if len(ks_samples) == 0: 

518 return { 

519 "p_value": float("nan"), 

520 "statistic": float("nan"), 

521 "interpretation": "no_samples_available", 

522 } 

523 

524 if self.distribution == "normal": 

525 mean, std = self._estimate_normal_params(col, batch_metrics) 

526 ks = stats.kstest(ks_samples, stats.norm.cdf, args=(mean, std)) 

527 else: 

528 col_params = self.dist_params.get(col, {}) 

529 min_val = float(col_params.get("min", np.min(ks_samples))) 

530 max_val = float(col_params.get("max", np.max(ks_samples))) 

531 if max_val <= min_val: 

532 max_val = min_val + self.epsilon 

533 ks = stats.kstest(ks_samples, stats.uniform.cdf, args=(min_val, max_val - min_val)) 

534 

535 return { 

536 "p_value": float(ks.pvalue), 

537 "statistic": float(ks.statistic), 

538 "interpretation": self.interpretation_thresholds.get( 

539 "follows_distribution" if ks.pvalue >= self.alpha else "does_not_follow_distribution", 

540 "follows_distribution", 

541 ), 

542 "sample_size": len(ks_samples), 

543 "note": "approximated_from_random_samples", 

544 } 

545 

546 def _compute_shannon_entropy_metric(self, exp_counts: np.ndarray) -> dict[str, Any]: 

547 """Compute Shannon entropy from expected frequency counts. 

548 

549 Args: 

550 exp_counts: Expected frequency counts per bin. 

551 

552 Returns: 

553 Dict with entropy and interpretation. 

554 """ 

555 p_exp = exp_counts / exp_counts.sum() 

556 h_exp = float(stats.entropy(p_exp)) 

557 is_high = h_exp > self.shannon_entropy_threshold 

558 return { 

559 "entropy": h_exp, 

560 "interpretation": self.interpretation_thresholds.get( 

561 "high_diversity" if is_high else "low_diversity", 

562 "high_diversity", 

563 ), 

564 } 

565 

566 def _compute_grte_metric(self, obs_counts: np.ndarray, exp_counts: np.ndarray) -> dict[str, Any]: 

567 """Compute GRTE (exponential gap between observed and theoretical entropy). 

568 

569 Args: 

570 obs_counts: Observed frequency counts per bin. 

571 exp_counts: Expected frequency counts per bin. 

572 

573 Returns: 

574 Dict with grte_value and interpretation. 

575 """ 

576 p_obs = obs_counts / obs_counts.sum() 

577 p_exp = exp_counts / exp_counts.sum() 

578 h_obs = float(stats.entropy(p_obs)) 

579 h_exp = float(stats.entropy(p_exp)) 

580 grte = float(np.exp(self.grte_scaling_factor * abs(h_exp - h_obs))) 

581 is_high = grte > self.grte_threshold 

582 return { 

583 "grte_value": grte, 

584 "interpretation": self.interpretation_thresholds.get( 

585 "high_representativeness" if is_high else "low_representativeness", 

586 "high_representativeness", 

587 ), 

588 } 

589 

590 @staticmethod 

591 def _build_compute_metadata( 

592 total_samples: int, 

593 batch_metrics: dict[str, pa.Array], 

594 input_columns: list[str], 

595 distribution: str, 

596 metrics: list[str], 

597 bins: int, 

598 ) -> str: 

599 """Build metadata JSON string for compute results. 

600 

601 Args: 

602 total_samples: Total number of samples processed. 

603 batch_metrics: Batch-level metrics dict. 

604 input_columns: Input column names. 

605 distribution: Target distribution name. 

606 metrics: List of computed metric names. 

607 bins: Number of histogram bins. 

608 

609 Returns: 

610 JSON-encoded metadata string. 

611 """ 

612 return json.dumps( 

613 { 

614 "bins": bins, 

615 "distribution": distribution, 

616 "metrics_computed": metrics, 

617 "total_samples": total_samples, 

618 "columns_analyzed": [c for c in input_columns if f"{c}_count" in batch_metrics], 

619 "ks_sampling_enabled": "kolmogorov-smirnov" in metrics, 

620 "note": "KS test uses random sampling approximation for scalability", 

621 } 

622 ) 

623 

624 @staticmethod 

625 def _flatten_col_results(col_res: dict[str, Any], col: str, results: dict[str, Any]) -> None: 

626 """Flatten nested metric dicts into flat output keys. 

627 

628 Args: 

629 col_res: Per-column metric results (potentially nested). 

630 col: Column name. 

631 results: Output dict to populate. 

632 """ 

633 for key, value in col_res.items(): 

634 if isinstance(value, dict): 634 ↛ 638line 634 didn't jump to line 638 because the condition on line 634 was always true

635 for prop, content in value.items(): 

636 results[f"{col}_{key}_{prop}"] = content 

637 else: 

638 results[f"{col}_{key}"] = value 

639 

640 def _compute_column_results( 

641 self, 

642 col: str, 

643 batch_metrics: dict[str, pa.Array], 

644 results: dict[str, Any], 

645 ) -> int: 

646 """Compute all metrics for a single column and write them to results. 

647 

648 Args: 

649 col: Column name. 

650 batch_metrics: Batch-level metrics dict. 

651 results: Output dict to populate (mutated in place). 

652 

653 Returns: 

654 Number of samples processed (0 if column has no valid data). 

655 """ 

656 agg = self._aggregate_column_metrics(batch_metrics, col) 

657 if agg is None: 

658 return 0 

659 

660 total_count, obs_counts, edges = agg 

661 exp_counts = self._compute_expected_counts(col, batch_metrics, total_count, edges) 

662 

663 col_res: dict[str, Any] = {} 

664 

665 if "chi-square" in self.metrics: 665 ↛ 668line 665 didn't jump to line 668 because the condition on line 665 was always true

666 col_res["chi-square"] = self._compute_chi_square_metric(obs_counts, exp_counts) 

667 

668 if "kolmogorov-smirnov" in self.metrics: 668 ↛ 671line 668 didn't jump to line 671 because the condition on line 668 was always true

669 col_res["kolmogorov-smirnov"] = self._compute_ks_metric(col, batch_metrics) 

670 

671 if "shannon-entropy" in self.metrics: 671 ↛ 674line 671 didn't jump to line 674 because the condition on line 671 was always true

672 col_res["shannon-entropy"] = self._compute_shannon_entropy_metric(exp_counts) 

673 

674 if "grte" in self.metrics: 674 ↛ 677line 674 didn't jump to line 677 because the condition on line 674 was always true

675 col_res["grte"] = self._compute_grte_metric(obs_counts, exp_counts) 

676 

677 if col_res: 677 ↛ 680line 677 didn't jump to line 680 because the condition on line 677 was always true

678 self._flatten_col_results(col_res, col, results) 

679 

680 return total_count 

681 

682 @override 

683 def compute(self, batch_metrics: dict[str, pa.Array] | None = None) -> dict[str, Any]: 

684 """ 

685 Compute final dataset-level metrics by aggregating batch histograms. 

686 

687 Args: 

688 batch_metrics: Dictionary of batch-level metrics collected during processing. 

689 

690 Returns: 

691 Dictionary containing final scores and interpretations. 

692 """ 

693 if not batch_metrics: 

694 return {"_metadata": {"error": "No batch metrics provided"}} 

695 

696 results: dict[str, Any] = {} 

697 total_samples = 0 

698 

699 for col in self.input_columns: 

700 total_samples += self._compute_column_results(col, batch_metrics, results) 

701 

702 results["_metadata"] = self._build_compute_metadata( 

703 total_samples, 

704 batch_metrics, 

705 self.input_columns or [], 

706 self.distribution, 

707 self.metrics, 

708 self.bins, 

709 ) 

710 return results 

711 

712 @override 

713 def reset(self) -> None: 

714 """Reset processor state for new processing run.""" 

715 self._bin_edges = {} 

716 self._bin_params = {} 

717 self._initialized = False 

718 

719 # utils methods for bin edge calculation 

720 

721 def _bin_edges_normal(self, mean: float, std: float, bins: int) -> np.ndarray: 

722 """Calculate bin edges using the PPF of a Normal distribution. 

723 

724 This ensures bins represent equal probability mass under the 

725 theoretical distribution. The first and last bins are extended 

726 to -inf and +inf respectively. 

727 """ 

728 # logic from dqm-ml v1: use stats.norm.ppf with linspace(1/bins, 1, bins) 

729 bin_edges_list = [stats.norm.ppf(i / bins, mean, std) for i in range(1, bins)] 

730 return np.array([-np.inf] + bin_edges_list + [np.inf]) 

731 

732 def _bin_edges_uniform(self, param_min: float, param_max: float, bins: int, data: np.ndarray) -> np.ndarray: 

733 """ 

734 Calculate linearly spaced bin edges for a Uniform distribution. 

735 

736 The range is determined by the minimum/maximum of both the configured 

737 parameters and the actual observed data. 

738 """ 

739 low_edge = min(param_min, float(np.min(data))) 

740 high_edge = max(param_max, float(np.max(data))) 

741 # Handle degenerate case where all data is identical 

742 if high_edge <= low_edge: 

743 high_edge = low_edge + self.epsilon 

744 return np.linspace(low_edge, high_edge, bins + 1)