Coverage for packages/dqm-ml-core/src/dqm_ml_core/metrics/completeness.py: 100%
102 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"""Completeness metric processor for evaluating data completeness.
3This module contains the CompletenessProcessor class that evaluates
4the completeness of tabular data by computing non-null value ratios.
5"""
7import json
8import logging
9from typing import Any
11import numpy as np
12import pyarrow as pa
14# COMPATIBILITY : from typing import Any, override # When support of 3.10 and 3.11 will be removed
15from typing_extensions import override
17from dqm_ml_core.api.metrics_processor import MetricsProcessor
18from dqm_ml_core.models.processors import CompletenessProcessorConfig
20logger = logging.getLogger(__name__)
23class CompletenessProcessor(MetricsProcessor):
24 """
25 Data completeness processor that evaluates the completeness of tabular data.
27 This processor calculates completeness scores (ratio of non-null to
28 total values) for specified columns and provides overall dataset
29 completeness metrics.
31 The processor operates at multiple levels:
32 - Batch level: Aggregated counts for streaming processing
33 - Dataset level: Final completeness scores and statistics
34 """
36 def __init__(self, name: str = "completeness", config: dict[str, Any] | None = None) -> None:
37 """
38 Initialize the completeness processor.
40 Args:
41 name: Name of the processor.
42 config: Configuration dictionary containing:
43 - input_columns: List of columns to analyze.
44 - output_metrics: Mapping of metric names to column names.
45 - include_per_column: Include per-column completeness scores.
46 - include_overall: Include overall completeness score.
47 """
48 super().__init__(name, config)
50 cfg = CompletenessProcessorConfig.model_validate({**self.config, "name": self.name})
52 # Which completeness levels to compute: per-column, overall, and metadata
53 self.include_per_column: bool = cfg.include_per_column
54 self.include_overall: bool = cfg.include_overall
55 self.include_metadata: bool = cfg.include_metadata
57 # Output column mappings
58 self.output_metrics: dict[str, str] = {}
60 @override
61 def generated_metrics(self) -> list[str]:
62 """
63 Return the list of metric columns that will be generated.
65 Returns:
66 List of output metric column names
67 """
68 # TODO : manage output metrics names with configuration
69 metrics = []
71 if self.include_overall:
72 overall_key = self.output_metrics.get("overall_completeness", "completeness_overall")
73 metrics.append(overall_key)
75 if self.include_per_column:
76 for col in self.input_columns:
77 col_key = self.output_metrics.get(f"completeness_{col}", f"completeness_{col}")
78 metrics.append(col_key)
80 return metrics
82 @override
83 def select_columns(
84 self,
85 batch: pa.RecordBatch,
86 prev_features: dict[str, pa.Array] | None = None,
87 ) -> dict[str, pa.Array]:
88 """
89 Extract the needed columns from the batch for completeness analysis.
91 This method simply passes through the columns we need to analyze,
92 as completeness calculation is done at batch and dataset levels.
94 Args:
95 batch: Input batch of data
96 prev_features: Previous features (not used in this processor)
98 Returns:
99 Dictionary containing the columns to analyze
100 """
101 features = {}
103 columns_to_analyze = self.input_columns if self.input_columns else batch.column_names
105 for col in columns_to_analyze:
106 if col not in batch.schema.names:
107 logger.warning(f"[{self.name}] column '{col}' not found in batch")
108 continue
110 # Simply pass through the column data for batch-level processing
111 features[col] = batch.column(col)
113 return features
115 @override
116 def compute_batch_metric(self, features: dict[str, pa.Array]) -> dict[str, pa.Array]:
117 """
118 Compute batch-level completeness counts for streaming aggregation.
120 This counts total and non-null values per column in this batch,
121 which will be aggregated across all batches for final dataset completeness.
123 Args:
124 features: Dictionary of column arrays from this batch
126 Returns:
127 Dictionary of batch-level completeness counts
128 """
129 batch_metrics = {}
131 for col, col_array in features.items():
132 total_count = len(col_array)
134 # Count complete (non-null, non-NaN) samples in this batch.
135 # pa.compute.is_valid() returns True for non-null values, but
136 # float NaN from numpy is preserved as a valid float in Arrow
137 # (not as a null), so we subtract NaN positions for float cols.
138 valid_count = pa.compute.sum(pa.compute.is_valid(col_array)).as_py()
139 if pa.types.is_floating(col_array.type):
140 nan_count = pa.compute.sum(pa.compute.is_nan(col_array)).as_py()
141 complete_count = valid_count - nan_count
142 else:
143 complete_count = valid_count
145 # store counts for aggregation across batches
146 batch_metrics[f"{col}_total_count"] = pa.array([total_count], type=pa.int64())
147 batch_metrics[f"{col}_complete_count"] = pa.array([complete_count], type=pa.int64())
149 return batch_metrics
151 @staticmethod
152 def _select_columns_from_metrics(
153 batch_metrics: dict[str, pa.Array],
154 ) -> list[str]:
155 """Extract column names from batch metrics keys ending in _total_count.
157 Args:
158 batch_metrics: Dictionary of batch-level metrics.
160 Returns:
161 List of column names.
162 """
163 columns = []
164 for key in batch_metrics:
165 if key.endswith("_total_count"):
166 columns.append(key.replace("_total_count", ""))
167 return columns
169 @staticmethod
170 def _compute_column_completeness(col: str, batch_metrics: dict[str, pa.Array]) -> float | None:
171 """Compute completeness score for a single column.
173 Args:
174 col: Column name.
175 batch_metrics: Dictionary of batch-level metrics.
177 Returns:
178 Completeness score (0.0-1.0) or None if metrics are missing.
179 """
180 total_key = f"{col}_total_count"
181 complete_key = f"{col}_complete_count"
182 if total_key not in batch_metrics or complete_key not in batch_metrics:
183 return None
185 col_total = int(np.sum(batch_metrics[total_key].to_numpy()))
186 col_complete = int(np.sum(batch_metrics[complete_key].to_numpy()))
187 return col_complete / col_total if col_total > 0 else 0.0
189 def _write_output_metrics(
190 self,
191 results: dict[str, Any],
192 per_column_completeness: dict[str, float],
193 ) -> None:
194 """Write per-column and overall completeness metrics to results.
196 Args:
197 results: Output dict to populate.
198 per_column_completeness: Dict of column -> completeness score.
199 """
200 if self.include_per_column:
201 for col, score in per_column_completeness.items():
202 output_key = self.output_metrics.get(f"completeness_{col}", f"completeness_{col}")
203 results[output_key] = score
205 if self.include_overall:
206 overall = (
207 sum(per_column_completeness.values()) / len(per_column_completeness) if per_column_completeness else 0.0
208 )
209 output_key = self.output_metrics.get("overall_completeness", "completeness_overall")
210 results[output_key] = overall
212 @override
213 def compute(self, batch_metrics: dict[str, pa.Array] | None = None) -> dict[str, Any]:
214 """
215 Compute final dataset-level completeness metrics.
217 This aggregates the batch-level counts to compute final completeness scores
218 for each column and overall dataset completeness.
220 Args:
221 batch_metrics: Dictionary of batch-level metrics to aggregate
223 Returns:
224 Dictionary of final completeness metrics
225 """
226 if not batch_metrics:
227 return {"_metadata": {"error": "No batch metrics provided"}}
229 columns_analyzed = self._select_columns_from_metrics(batch_metrics)
230 if not columns_analyzed:
231 logger.warning(f"[{self.name}] No columns found in batch metrics")
232 return {"_metadata": {"error": "No columns found in batch metrics"}}
234 per_column_completeness: dict[str, float] = {}
235 total_samples = 0
237 for col in columns_analyzed:
238 score = self._compute_column_completeness(col, batch_metrics)
239 if score is None:
240 logger.warning(f"[{self.name}] Missing batch metrics for column '{col}'")
241 continue
242 per_column_completeness[col] = score
243 total_key = f"{col}_total_count"
244 total_samples += int(np.sum(batch_metrics[total_key].to_numpy()))
246 results: dict[str, Any] = {}
247 self._write_output_metrics(results, per_column_completeness)
249 if self.include_metadata:
250 metadata = {
251 "columns_analyzed": columns_analyzed,
252 "total_samples_per_column": total_samples // len(columns_analyzed) if columns_analyzed else 0,
253 "per_column_scores": per_column_completeness,
254 "overall_score": sum(per_column_completeness.values()) / len(per_column_completeness)
255 if per_column_completeness
256 else 0.0,
257 }
258 results["_metadata"] = json.dumps(metadata)
260 return results
262 @override
263 def reset(self) -> None:
264 """Reset processor state for new processing run.
266 The completeness processor has no persistent state across runs,
267 so this is a no-op. Provided for interface compliance.
268 """
269 # No persistent state to reset for completeness processor