File size: 7,343 Bytes
e201ae4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | """analyze_data_quality β per-column data-quality profile (Β§0.9, renamed from analyze_profile).
Rename + activate of the former `analyze_profile` (`quality.py`): profiles each
column's health β dtype, inferred type, completeness (null count/rate), cardinality
(distinct count/rate, constant flag), and β for numeric columns β min/max/mean plus
an IQR-based outlier count; for non-numeric columns the most frequent value. Now
also returns an issues summary and ships a **missing-rate bar chart** as a package.
Follows docs/tools/ANALYTICAL_TOOLS_CONVENTIONS.md: pure synchronous compute (no
model fit β stays sync), Pattern A `data`, errors escape to the invoker's never-throw
seam. Deterministic.
"""
from __future__ import annotations
import pandas as pd
from pydantic import BaseModel, Field
from src.tools.analytics.charts import LAYOUT_PRESET, _axis, envelope
from src.tools.analytics.descriptive import ColumnNotFoundError
from src.tools.spec_builder import build_description
def _clean(value: object) -> object:
"""Convert numpy/pandas scalars to plain Python so the output is JSON-clean.
`top_value` (most frequent value) can be a `pandas.Timestamp` when profiling a
datetime column β neither `Timestamp` nor numpy scalars are JSON-safe.
"""
if isinstance(value, pd.Timestamp):
return value.isoformat()
if hasattr(value, "item"):
return value.item()
return value
def _profile_one(series: pd.Series) -> dict[str, object]:
"""Build the quality profile for a single column."""
total = len(series)
non_null = series.dropna()
nn = len(non_null)
distinct = int(series.nunique(dropna=True))
is_bool = pd.api.types.is_bool_dtype(series)
is_datetime = pd.api.types.is_datetime64_any_dtype(series)
# bool is technically numeric in pandas; treat it as its own type.
is_numeric = pd.api.types.is_numeric_dtype(series) and not is_bool
if is_bool:
inferred = "boolean"
elif is_datetime:
inferred = "datetime"
elif is_numeric:
inferred = "numeric"
else:
inferred = "categorical"
out: dict[str, object] = {
"dtype": str(series.dtype),
"inferred_type": inferred,
"count": int(total),
"null_count": int(series.isna().sum()),
"null_rate": float(series.isna().mean()) if total else 0.0,
"distinct_count": distinct,
"distinct_rate": (distinct / nn) if nn else 0.0, # over non-null values
"is_constant": distinct <= 1,
}
if is_numeric and nn > 0:
out["min"] = _clean(non_null.min())
out["max"] = _clean(non_null.max())
out["mean"] = _clean(non_null.mean())
# IQR rule: values outside [Q1 - 1.5*IQR, Q3 + 1.5*IQR] are outliers.
# Needs enough points for stable quartiles.
if nn >= 4:
q1 = non_null.quantile(0.25)
q3 = non_null.quantile(0.75)
iqr = q3 - q1
lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr
out["outlier_count"] = int(((non_null < lower) | (non_null > upper)).sum())
else:
out["outlier_count"] = None
elif not is_numeric and nn > 0:
counts = non_null.value_counts()
out["top_value"] = _clean(counts.index[0])
out["top_freq"] = int(counts.iloc[0])
return out
class DataQualityInput(BaseModel):
# `data` FIRST β property order follows field order (planner-prompt readability).
data: str = Field(..., description="Placeholder ${t<id>} of the upstream table (Pattern A).")
column_ids: list[str] | None = Field(
None, description="Columns to profile; omit to profile every column."
)
DESCRIPTION = build_description(
summary="Per-column data-quality profile β dtype, completeness (nulls), cardinality, "
"constants, and IQR outliers for numeric columns.",
use_when="the question is about the HEALTH of the data, not its statistics β missing "
"values, data types, duplicates/unique, constant columns, outliers, 'is this clean "
"enough to analyze' β 'kualitas', 'data kosong', 'tipe data', 'duplikat', 'outlier'.",
dont_use_when=[
"the user wants statistics like mean/median/std/skew -> analyze_descriptive",
"it groups or compares -> analyze_aggregate / analyze_ab_test",
],
output="a per-column profile (types, null rate, distinct, outliers), an issues summary "
"(constant + high-null columns), and a bar chart of missing rate per column.",
examples=[
"is this dataset clean enough to analyze?",
"which columns have a lot of missing values?",
"data types and unique counts per column?",
],
)
# Columns with at least this null rate are surfaced in the issues summary.
_HIGH_NULL_THRESHOLD = 0.2
def _missing_rate_chart(columns: dict[str, dict[str, object]]) -> dict[str, object]:
"""Bar chart of null rate per column (the headline data-quality signal)."""
cols = list(columns)
null_rates = [round(float(columns[c]["null_rate"]), 4) for c in cols]
title = "Missing rate by column"
layout = {
**LAYOUT_PRESET,
"title": {"text": title},
"showlegend": False,
"xaxis": _axis("column"),
"yaxis": _axis("null rate"),
}
traces = [{"type": "bar", "x": cols, "y": null_rates, "name": "null rate"}]
return envelope("bar", title, traces, layout)
def analyze_data_quality(
df: pd.DataFrame,
column_ids: list[str] | None = None,
) -> dict[str, object]:
"""Per-column data-quality profile.
`data` is materialised upstream (Pattern A) and arrives as `df`.
Returns a dict with: n_rows, n_columns, `columns` ({column: profile}),
`duplicate_rows` ({count, rate} β full duplicate rows over the profiled columns),
`issues` (constant_columns + high_null_columns + duplicate_row_count), and `charts`
(the reserved auto-chart key).
Raises (wrapped by the invoker's never-throw seam):
ColumnNotFoundError: if any column_id is absent from df.
"""
cols = list(column_ids) if column_ids is not None else list(df.columns)
missing = [c for c in cols if c not in df.columns]
if missing:
raise ColumnNotFoundError(f"columns not found: {missing}")
columns = {col: _profile_one(df[col]) for col in cols}
# Full-row duplicates over the profiled columns β `use_when` advertises "duplikat",
# so it must be computed, not implied by distinct_rate. `keep="first"` counts the
# redundant copies (rows beyond the first of each group); over a column subset this
# is a candidate-key duplicate check. Always reported (0 when none), never absent.
n_rows = int(len(df))
dup_count = int(df[cols].duplicated().sum())
duplicate_rows = {"count": dup_count, "rate": (dup_count / n_rows) if n_rows else 0.0}
issues = {
"constant_columns": [c for c, p in columns.items() if p["is_constant"]],
"high_null_columns": [
c for c, p in columns.items() if p["null_rate"] >= _HIGH_NULL_THRESHOLD
],
"duplicate_row_count": dup_count,
}
return {
"n_rows": n_rows,
"n_columns": len(cols),
"columns": columns,
"duplicate_rows": duplicate_rows,
"issues": issues,
"charts": [_missing_rate_chart(columns)],
}
|