dataforge-playground / dataforge /detectors /categorical_normalization.py
Praneshrajan15's picture
Deploy DataForge playground API
13fe504 verified
Raw
History Blame Contribute Delete
4.73 kB
"""Detector for categorical normalization variants (case/whitespace/punctuation).
Categorical columns often contain the same logical value written several ways:
``"New York"`` / ``"new york"`` / ``"NEW YORK"``. This detector clusters values
by a normalization key and, within a cluster that has a clear dominant exact
form, flags the minority variants as ``categorical_normalization`` with the
dominant form as the expected value.
Precision guards: only categorical columns are considered (low distinct
cardinality with real repetition), and a variant is only flagged when its
cluster has a strict-majority canonical form. Free-text and high-cardinality
columns are skipped.
The detector is pure: no LLM calls, no I/O, no side effects.
"""
from __future__ import annotations
import re
from collections import Counter, defaultdict
from dataforge.detectors.base import Issue, Schema, Severity
from dataforge.table import TableLike, column_names, column_values
_MIN_VALUES = 8
# A column is categorical when distinct normalized keys are a small fraction of
# its non-empty values (i.e. values repeat).
_MAX_DISTINCT_RATIO = 0.6
_WS = re.compile(r"\s+")
def normalization_key(value: str) -> str:
"""Return the case/whitespace/punctuation-insensitive key for a value."""
lowered = value.strip().lower()
collapsed = _WS.sub(" ", lowered)
return "".join(ch for ch in collapsed if ch.isalnum() or ch == " ").strip()
class CategoricalNormalizationDetector:
"""Flags minority spelling/format variants of a categorical value.
Example:
>>> import pandas as pd
>>> detector = CategoricalNormalizationDetector()
>>> col = ["NY", "NY", "ny", "NY", "CA", "CA", "CA", "NY", "ca", "NY"]
>>> issues = detector.detect(pd.DataFrame({"state": col}))
>>> sorted(i.row for i in issues)
[2, 8]
"""
def detect(self, df: TableLike, schema: Schema | None = None) -> list[Issue]:
"""Detect categorical-normalization issues across categorical columns."""
issues: list[Issue] = []
for col_name in column_names(df):
issues.extend(self._check_column(df, str(col_name)))
return issues
def _check_column(self, df: TableLike, col_name: str) -> list[Issue]:
"""Flag minority variants within near-duplicate clusters of one column."""
entries = [
(i, str(v).strip()) for i, v in enumerate(column_values(df, col_name)) if str(v).strip()
]
if len(entries) < _MIN_VALUES:
return []
keys = {value: normalization_key(value) for _, value in entries}
distinct_keys = {k for k in keys.values() if k}
if not distinct_keys or len(distinct_keys) / len(entries) > _MAX_DISTINCT_RATIO:
return [] # unique-ID-like or free-text column
# Group exact forms and row indices per normalization key.
forms_by_key: dict[str, Counter[str]] = defaultdict(Counter)
rows_by_key_form: dict[tuple[str, str], list[int]] = defaultdict(list)
for row_idx, value in entries:
key = keys[value]
if not key:
continue
forms_by_key[key][value] += 1
rows_by_key_form[(key, value)].append(row_idx)
issues: list[Issue] = []
for key, form_counts in forms_by_key.items():
if len(form_counts) < 2:
continue # no variants in this cluster
ranked = form_counts.most_common()
canonical, top = ranked[0]
second = ranked[1][1]
if top <= second:
continue # no strict-majority canonical; ambiguous, skip
cluster_total = sum(form_counts.values())
confidence = round(min(0.95, 0.5 + top / cluster_total / 2), 2)
for form, _count in ranked[1:]:
for row_idx in rows_by_key_form[(key, form)]:
issues.append(
Issue(
row=row_idx,
column=col_name,
issue_type="categorical_normalization",
severity=Severity.REVIEW,
confidence=confidence,
expected=canonical,
actual=form,
reason=(
f"Value '{form}' is a normalization variant of the dominant "
f"form '{canonical}' in column '{col_name}'."
),
)
)
return issues