Coverage for packages/dqm-ml-core/src/dqm_ml_core/metrics/diversity.py: 98%
92 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-21 08:27 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-21 08:27 +0000
1"""Diversity metric processor for evaluating categorical data diversity.
3This module contains the DiversityProcessor class that computes
4diversity indices (Simpson, Gini-Simpson, Shannon Entropy, Richness)
5for categorical or discretized data columns.
6"""
8from collections import Counter
9import logging
10from typing import Any
12import numpy as np
13import pyarrow as pa
14from typing_extensions import override
16from dqm_ml_core.api.metrics_processor import MetricsProcessor
17from dqm_ml_core.models.processors import DiversityProcessorConfig
19logger = logging.getLogger(__name__)
22class DiversityProcessor(MetricsProcessor):
23 """Computes diversity indices for categorical data columns.
25 Simpson Index (1 - Σn(n-1) / N(N-1)):
26 Unbiased estimator — probability two random samples belong to
27 different categories. Ranges [0, 1]; high = diverse.
29 Gini-Simpson Index (1 - Σp²):
30 Gini impurity — probability of incorrect classification.
31 Ranges [0, 1]; high = diverse.
33 Shannon Entropy (-Σp·log(p)):
34 Information content / uncertainty. Lower bound 0; no upper bound
35 (grows with category count and evenness).
37 Richness:
38 Simple count of unique categories.
40 The processor uses a streaming architecture:
41 - Batch level: Computes value counts per column.
42 - Dataset level: Merges all batch counts and computes final indices.
43 """
45 SUPPORTED_METRICS = {"simpson", "gini", "shannon", "richness"}
47 def __init__(self, name: str = "diversity", config: dict[str, Any] | None = None) -> None:
48 """Initialize the diversity processor.
50 Args:
51 name: Name of the processor.
52 config: Configuration dictionary containing:
53 - input_columns: List of columns to analyze.
54 - metrics: List of metrics to compute (default: all supported).
55 """
56 super().__init__(name, config)
58 cfg = DiversityProcessorConfig.model_validate({**self.config, "name": self.name})
60 self.metrics: list[str] = list(cfg.metrics)
62 @override
63 def generated_metrics(self) -> list[str]:
64 """Return the list of metric columns that will be generated.
66 Returns:
67 List of output metric column names.
68 """
69 metrics = []
70 for col in self.input_columns:
71 if "simpson" in self.metrics: 71 ↛ 73line 71 didn't jump to line 73 because the condition on line 71 was always true
72 metrics.append(f"{col}_simpson")
73 if "gini" in self.metrics:
74 metrics.append(f"{col}_gini")
75 if "shannon" in self.metrics:
76 metrics.append(f"{col}_shannon")
77 if "richness" in self.metrics:
78 metrics.append(f"{col}_richness")
79 return metrics
81 @override
82 def compute_batch_metric(self, features: dict[str, pa.Array]) -> dict[str, pa.Array]:
83 """Compute per-batch value counts for streaming aggregation.
85 Args:
86 features: Dictionary of column arrays from this batch.
88 Returns:
89 Dictionary of batch-level value-count pairs.
90 """
91 batch_metrics: dict[str, pa.Array] = {}
93 for col in self.input_columns:
94 if col not in features:
95 logger.warning("[%s] column '%s' not found in batch", self.name, col)
96 continue
98 col_array = features[col]
99 vc = pa.compute.value_counts(col_array)
100 if len(vc) == 0:
101 continue
103 values = vc.field("values")
104 counts = vc.field("counts")
106 values_str = pa.compute.cast(values, pa.string())
107 batch_metrics[f"{col}_values"] = values_str
108 batch_metrics[f"{col}_counts"] = counts
110 return batch_metrics
112 @override
113 def compute(self, batch_metrics: dict[str, pa.Array] | None = None) -> dict[str, Any]:
114 """Compute final dataset-level diversity indices.
116 Merges all batch-level value counts and computes diversity
117 indices for each column.
119 Args:
120 batch_metrics: Dictionary of batch-level value-count arrays.
122 Returns:
123 Dictionary containing final diversity scores.
124 """
125 if not batch_metrics:
126 return {"_metadata": {"error": "No batch metrics provided"}}
128 results: dict[str, Any] = {}
129 for col in self.input_columns:
130 col_res = self._compute_column_diversity(col, batch_metrics)
131 results.update(col_res)
132 return results
134 def _compute_column_diversity(self, col: str, batch_metrics: dict[str, pa.Array]) -> dict[str, Any]:
135 """Compute diversity indices for a single column.
137 Args:
138 col: Column name to compute diversity for.
139 batch_metrics: Dictionary of batch-level value-count arrays.
141 Returns:
142 Dictionary mapping column-specific metric names to values.
143 """
144 values_key = f"{col}_values"
145 counts_key = f"{col}_counts"
147 if values_key not in batch_metrics or counts_key not in batch_metrics:
148 logger.warning("[%s] no batch metrics for column '%s'", self.name, col)
149 return {}
151 values_arr = batch_metrics[values_key]
152 counts_arr = batch_metrics[counts_key]
154 if len(values_arr) == 0:
155 return {}
157 counter: Counter[str] = Counter()
158 total = 0
159 for i in range(len(values_arr)):
160 val = values_arr[i].as_py()
161 cnt = int(counts_arr[i])
162 counter[val] += cnt
163 total += cnt
165 if total == 0:
166 return {}
168 col_res: dict[str, Any] = {}
170 if "richness" in self.metrics:
171 col_res["richness"] = len(counter)
173 if "shannon" in self.metrics:
174 probs = np.array(list(counter.values()), dtype=np.float64) / total
175 probs = probs[probs > 0]
176 entropy = float(-(probs * np.log(probs)).sum())
177 col_res["shannon"] = entropy
179 if "gini" in self.metrics:
180 probs = np.array(list(counter.values()), dtype=np.float64) / total
181 gini = float(1.0 - (probs**2).sum())
182 col_res["gini"] = gini
184 if "simpson" in self.metrics: 184 ↛ 189line 184 didn't jump to line 189 because the condition on line 184 was always true
185 counts_np = np.array(list(counter.values()), dtype=np.float64)
186 simpson = float(1.0 - (counts_np * (counts_np - 1)).sum() / (total * (total - 1))) if total > 1 else 0.0
187 col_res["simpson"] = simpson
189 return {f"{col}_{metric_name}": value for metric_name, value in col_res.items()}
191 @override
192 def reset(self) -> None:
193 """Reset processor state for new processing run."""