| from __future__ import annotations |
|
|
| import csv |
| import json |
| from dataclasses import asdict |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Any |
| from typing import Iterator |
|
|
| import pandas as pd |
|
|
| from backend.pii import match_pii_columns |
|
|
|
|
| @dataclass(frozen=True) |
| class ValidationCheckResult: |
| key: str |
| label: str |
| status: str |
| detail: str |
| warn_message: str | None = None |
| |
| |
| meta: dict[str, Any] | None = None |
|
|
| def to_dict(self) -> dict[str, object]: |
| result_dict = asdict(self) |
| if self.warn_message is None: |
| result_dict.pop("warn_message") |
| if self.meta is None: |
| result_dict.pop("meta") |
| return result_dict |
|
|
|
|
| @dataclass(frozen=True) |
| class DatasetValidationSummary: |
| row_count: int |
| column_count: int |
| column_names: list[str] |
| null_counts: dict[str, int] |
| numeric_unique_values: dict[str, set[object]] |
| target_unique_count: int | None = None |
|
|
|
|
| @dataclass(frozen=True) |
| class ValidationReport: |
| session_id: str |
| passed: bool |
| blocker_count: int |
| warn_count: int |
| checks: list[ValidationCheckResult] |
|
|
| def to_dict(self) -> dict[str, object]: |
| return { |
| "session_id": self.session_id, |
| "passed": self.passed, |
| "blocker_count": self.blocker_count, |
| "warn_count": self.warn_count, |
| "checks": [check_result.to_dict() for check_result in self.checks], |
| } |
|
|
|
|
| class DataValidator: |
| check_order = [ |
| "format", |
| "rows", |
| "nulls", |
| "variance", |
| "pii", |
| "target", |
| "metadata_match", |
| ] |
| check_labels = { |
| "format": "File format & encoding", |
| "rows": "Row count", |
| "nulls": "Null density", |
| "variance": "Zero-variance scan", |
| "pii": "PII heuristic", |
| "target": "Target separability", |
| "metadata_match": "Metadata file match", |
| } |
| blocker_statuses = { |
| "format": {"fail"}, |
| "rows": {"fail"}, |
| "nulls": {"fail"}, |
| "variance": {"fail"}, |
| "target": {"fail"}, |
| "metadata_match": {"fail"}, |
| } |
|
|
| def __init__( |
| self, |
| min_rows: int, |
| null_threshold: float, |
| pii_patterns: list[str], |
| metadata_match_min_overlap: float, |
| null_drop_threshold: float = 0.5, |
| chunk_size_rows: int = 50000, |
| ) -> None: |
| self.min_rows = min_rows |
| self.null_threshold = null_threshold |
| |
| |
| |
| self.null_drop_threshold = null_drop_threshold |
| self.pii_patterns = pii_patterns |
| self.metadata_match_min_overlap = metadata_match_min_overlap |
| self.chunk_size_rows = chunk_size_rows |
|
|
| def validate( |
| self, |
| data_file: Path, |
| session_id: str, |
| target_col: str | None, |
| user_metadata_path: Path | None = None, |
| ) -> Iterator[ValidationCheckResult]: |
| summary = self._summarize_csv( |
| data_file=data_file, |
| target_col=target_col, |
| ) |
|
|
| checks_by_key = { |
| "format": self._check_format(data_file=data_file, summary=summary), |
| "rows": self._check_rows(summary=summary), |
| "nulls": self._check_nulls(summary=summary, target_col=target_col), |
| "variance": self._check_variance(summary=summary), |
| "pii": self._check_pii(column_names=summary.column_names), |
| "target": self._check_target( |
| summary=summary, |
| target_col=target_col, |
| ), |
| } |
| |
| |
| if user_metadata_path is not None and user_metadata_path.is_file(): |
| checks_by_key["metadata_match"] = self._check_metadata_match( |
| summary=summary, |
| user_metadata_path=user_metadata_path, |
| ) |
| for check_key in self.check_order: |
| if check_key in checks_by_key: |
| yield checks_by_key[check_key] |
|
|
| def build_report( |
| self, |
| session_id: str, |
| checks: list[ValidationCheckResult], |
| ) -> ValidationReport: |
| blocker_count = sum( |
| 1 |
| for check_result in checks |
| if check_result.status in self.blocker_statuses.get(check_result.key, set()) |
| ) |
| warn_count = sum(1 for check_result in checks if check_result.status == "warn") |
| return ValidationReport( |
| session_id=session_id, |
| passed=blocker_count == 0, |
| blocker_count=blocker_count, |
| warn_count=warn_count, |
| checks=checks, |
| ) |
|
|
| def _check_format( |
| self, |
| data_file: Path, |
| summary: DatasetValidationSummary, |
| ) -> ValidationCheckResult: |
| try: |
| with data_file.open("r", encoding="utf-8", newline="") as csv_file: |
| sample_text = csv_file.read(4096) |
| csv.Sniffer().sniff(sample_text) |
| except (UnicodeDecodeError, csv.Error) as error: |
| return ValidationCheckResult( |
| key="format", |
| label=self.check_labels["format"], |
| status="fail", |
| detail=f"Could not parse CSV format: {error}", |
| ) |
|
|
| return ValidationCheckResult( |
| key="format", |
| label=self.check_labels["format"], |
| status="pass", |
| detail=f"utf-8, delimiter detected, {summary.column_count} columns", |
| ) |
|
|
| def _check_rows( |
| self, |
| summary: DatasetValidationSummary, |
| ) -> ValidationCheckResult: |
| if summary.row_count < self.min_rows: |
| return ValidationCheckResult( |
| key="rows", |
| label=self.check_labels["rows"], |
| status="fail", |
| detail=f"{summary.row_count} rows, below minimum ({self.min_rows})", |
| ) |
|
|
| return ValidationCheckResult( |
| key="rows", |
| label=self.check_labels["rows"], |
| status="pass", |
| detail=f"{summary.row_count} rows, above minimum ({self.min_rows})", |
| ) |
|
|
| def _check_nulls( |
| self, |
| summary: DatasetValidationSummary, |
| target_col: str | None = None, |
| ) -> ValidationCheckResult: |
| if summary.row_count == 0: |
| return ValidationCheckResult( |
| key="nulls", |
| label=self.check_labels["nulls"], |
| status="pass", |
| detail="0 columns exceed null threshold", |
| ) |
|
|
| |
| |
| |
| |
| |
| normalized_target_col = (target_col or "").strip() |
| sparse_columns: list[dict[str, Any]] = [] |
| target_blocked = False |
| for column_name, null_count in summary.null_counts.items(): |
| null_rate = null_count / summary.row_count |
| if null_rate < self.null_drop_threshold: |
| continue |
| is_target = column_name == normalized_target_col |
| if is_target and null_rate > self.null_threshold: |
| action = "block" |
| target_blocked = True |
| else: |
| action = "auto-drop" |
| sparse_columns.append( |
| { |
| "column": column_name, |
| "null_rate": round(null_rate, 4), |
| "null_percent": round(null_rate * 100, 1), |
| "action": action, |
| } |
| ) |
|
|
| meta = { |
| "null_threshold": self.null_threshold, |
| "null_drop_threshold": self.null_drop_threshold, |
| "columns": sparse_columns, |
| } |
|
|
| if target_blocked: |
| return ValidationCheckResult( |
| key="nulls", |
| label=self.check_labels["nulls"], |
| status="fail", |
| detail=( |
| f"Target column '{normalized_target_col}' is mostly empty " |
| "and cannot be used; pick a different target or clean it" |
| ), |
| meta=meta, |
| ) |
|
|
| if sparse_columns: |
| dropped_names = ", ".join( |
| f"{item['column']} ({item['null_percent']}%)" |
| for item in sparse_columns |
| ) |
| return ValidationCheckResult( |
| key="nulls", |
| label=self.check_labels["nulls"], |
| status="warn", |
| detail=( |
| f"{len(sparse_columns)} sparse column(s) will be auto-dropped " |
| f"before training: {dropped_names}" |
| ), |
| warn_message=( |
| "Columns above the drop threshold are removed automatically. " |
| "Raise the null threshold in Run Config to keep them." |
| ), |
| meta=meta, |
| ) |
|
|
| return ValidationCheckResult( |
| key="nulls", |
| label=self.check_labels["nulls"], |
| status="pass", |
| detail="No columns exceed the null threshold", |
| meta=meta, |
| ) |
|
|
| def _check_variance( |
| self, |
| summary: DatasetValidationSummary, |
| ) -> ValidationCheckResult: |
| zero_variance_columns = [ |
| column_name |
| for column_name, unique_values in summary.numeric_unique_values.items() |
| if len(unique_values) <= 1 |
| ] |
| if zero_variance_columns: |
| return ValidationCheckResult( |
| key="variance", |
| label=self.check_labels["variance"], |
| status="fail", |
| detail=f"Constant columns detected: {', '.join(zero_variance_columns)}", |
| ) |
|
|
| return ValidationCheckResult( |
| key="variance", |
| label=self.check_labels["variance"], |
| status="pass", |
| detail="No constant numeric columns detected", |
| ) |
|
|
| def _check_pii(self, column_names: list[str]) -> ValidationCheckResult: |
| pii_columns = match_pii_columns( |
| column_names=column_names, |
| pii_patterns=self.pii_patterns, |
| ) |
| if pii_columns: |
| matched_columns = ", ".join(pii_columns) |
| return ValidationCheckResult( |
| key="pii", |
| label=self.check_labels["pii"], |
| status="warn", |
| detail=f"PII-suspect columns: {matched_columns}", |
| warn_message=f"Column names match PII patterns: {matched_columns}", |
| ) |
|
|
| return ValidationCheckResult( |
| key="pii", |
| label=self.check_labels["pii"], |
| status="pass", |
| detail="No PII-suspect column names", |
| ) |
|
|
| def _check_target( |
| self, |
| summary: DatasetValidationSummary, |
| target_col: str | None, |
| ) -> ValidationCheckResult: |
| normalized_target_col = (target_col or "").strip() |
| if not normalized_target_col: |
| return ValidationCheckResult( |
| key="target", |
| label=self.check_labels["target"], |
| status="pass", |
| detail="No target column supplied; unsupervised flow allowed", |
| ) |
| if normalized_target_col not in summary.column_names: |
| return ValidationCheckResult( |
| key="target", |
| label=self.check_labels["target"], |
| status="fail", |
| detail=f"Target column missing: {normalized_target_col}", |
| ) |
|
|
| unique_target_count = summary.target_unique_count or 0 |
| return ValidationCheckResult( |
| key="target", |
| label=self.check_labels["target"], |
| status="pass", |
| detail=f"{normalized_target_col}, {unique_target_count} unique values", |
| ) |
|
|
| def _check_metadata_match( |
| self, |
| summary: DatasetValidationSummary, |
| user_metadata_path: Path, |
| ) -> ValidationCheckResult: |
| |
| try: |
| metadata_tokens = self._extract_metadata_tokens( |
| user_metadata_path=user_metadata_path |
| ) |
| except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as error: |
| |
| |
| return ValidationCheckResult( |
| key="metadata_match", |
| label=self.check_labels["metadata_match"], |
| status="fail", |
| detail=f"Could not parse metadata file: {error}", |
| ) |
|
|
| total_columns = len(summary.column_names) |
| if total_columns == 0: |
| return ValidationCheckResult( |
| key="metadata_match", |
| label=self.check_labels["metadata_match"], |
| status="pass", |
| detail="No dataset columns to match", |
| ) |
|
|
| matched_columns = [ |
| column_name |
| for column_name in summary.column_names |
| if column_name.strip().lower() in metadata_tokens |
| ] |
| overlap_ratio = len(matched_columns) / total_columns |
| if overlap_ratio < self.metadata_match_min_overlap: |
| return ValidationCheckResult( |
| key="metadata_match", |
| label=self.check_labels["metadata_match"], |
| status="fail", |
| detail=( |
| f"Metadata file references {len(matched_columns)}/{total_columns} " |
| "dataset columns; appears unrelated to this dataset" |
| ), |
| ) |
|
|
| return ValidationCheckResult( |
| key="metadata_match", |
| label=self.check_labels["metadata_match"], |
| status="pass", |
| detail=f"Metadata file matches {len(matched_columns)}/{total_columns} columns", |
| ) |
|
|
| def _extract_metadata_tokens(self, user_metadata_path: Path) -> set[str]: |
| |
| |
| |
| file_text = user_metadata_path.read_text(encoding="utf-8") |
| if user_metadata_path.suffix.lower() == ".json": |
| parsed_payload = json.loads(file_text) |
| return self._normalize_tokens( |
| raw_tokens=self._walk_json_tokens(payload=parsed_payload) |
| ) |
|
|
| metadata_frame = pd.read_csv(user_metadata_path, dtype=str) |
| raw_tokens: list[str] = list(metadata_frame.columns) |
| for column_name in metadata_frame.columns: |
| raw_tokens.extend(metadata_frame[column_name].dropna().tolist()) |
| return self._normalize_tokens(raw_tokens=raw_tokens) |
|
|
| @classmethod |
| def _walk_json_tokens(cls, payload: Any) -> list[str]: |
| collected_tokens: list[str] = [] |
| if isinstance(payload, dict): |
| for dict_key, dict_value in payload.items(): |
| collected_tokens.append(str(dict_key)) |
| collected_tokens.extend(cls._walk_json_tokens(payload=dict_value)) |
| elif isinstance(payload, list): |
| for list_item in payload: |
| collected_tokens.extend(cls._walk_json_tokens(payload=list_item)) |
| elif isinstance(payload, str): |
| collected_tokens.append(payload) |
| return collected_tokens |
|
|
| @staticmethod |
| def _normalize_tokens(raw_tokens: list[str]) -> set[str]: |
| return { |
| str(raw_token).strip().lower() |
| for raw_token in raw_tokens |
| if str(raw_token).strip() |
| } |
|
|
| def _summarize_csv( |
| self, |
| data_file: Path, |
| target_col: str | None, |
| ) -> DatasetValidationSummary: |
| row_count = 0 |
| column_names: list[str] = [] |
| null_counts: dict[str, int] = {} |
| numeric_unique_values: dict[str, set[object]] = {} |
| target_unique_values: set[object] = set() |
| normalized_target_col = (target_col or "").strip() |
|
|
| for data_chunk in pd.read_csv(data_file, chunksize=self.chunk_size_rows): |
| if not column_names: |
| column_names = list(data_chunk.columns) |
| null_counts = {column_name: 0 for column_name in column_names} |
| row_count += len(data_chunk) |
|
|
| chunk_null_counts = data_chunk.isna().sum() |
| for column_name, null_count in chunk_null_counts.items(): |
| null_counts[column_name] = null_counts.get(column_name, 0) + int(null_count) |
|
|
| numeric_chunk = data_chunk.select_dtypes(include="number") |
| for column_name in numeric_chunk.columns: |
| unique_values = numeric_unique_values.setdefault(column_name, set()) |
| if len(unique_values) <= 1: |
| unique_values.update(numeric_chunk[column_name].dropna().unique().tolist()) |
|
|
| if normalized_target_col in data_chunk.columns: |
| target_unique_values.update( |
| data_chunk[normalized_target_col].dropna().unique().tolist() |
| ) |
|
|
| target_unique_count = ( |
| len(target_unique_values) |
| if normalized_target_col and normalized_target_col in column_names |
| else None |
| ) |
| return DatasetValidationSummary( |
| row_count=row_count, |
| column_count=len(column_names), |
| column_names=column_names, |
| null_counts=null_counts, |
| numeric_unique_values=numeric_unique_values, |
| target_unique_count=target_unique_count, |
| ) |
|
|