Skip to content

Commit c7e1a7c

Browse files
committed
fixed the merged profiler code to the repo
1 parent 0cb5676 commit c7e1a7c

6 files changed

Lines changed: 178 additions & 24 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ One import, one line. A clean, sorted DataFrame you can read or feed into the ne
5555

5656
| Function | What it answers |
5757
|---|---|
58+
| `profile` | What is wrong with this dataset, and how do I fix it? |
5859
| `change` | Growth as numbers, columns, or a whole series |
5960
| `vif` | Which features are collinear? |
6061
| `missing` | How much of each column is missing? |

docs/documentation.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,57 @@ polars stays optional: it is only imported when you actually pass a polars objec
4545

4646
---
4747

48+
## `profile`
49+
50+
The flagship: a one-call **data diagnostician**. Instead of dumping statistics for every column, it runs a battery of checks, ranks the problems worst-first, scores the data's health, and tells you how to fix each issue. It composes the rest of the toolkit (`missing`, `imbalance`, and more) under one entry point.
51+
52+
!!! tip "Similar concept"
53+
`ydata-profiling` / `pandas.DataFrame.describe`, but diagnostic (it ranks problems and suggests fixes) rather than descriptive.
54+
55+
**Signature**
56+
57+
```python
58+
profile(data, target=None)
59+
```
60+
61+
**Example**
62+
63+
```python
64+
import pandas as pd
65+
from percentify import profile
66+
67+
df = pd.DataFrame({
68+
"user_id": range(100), # identifier
69+
"plan": ["free"] * 100, # constant
70+
"age": [20 + (i * 37) % 40 for i in range(100)],
71+
"spend": [None if i % 2 == 0 else float((i * 13) % 100) for i in range(100)],
72+
"churn": ["No"] * 96 + ["Yes"] * 4, # imbalanced target
73+
})
74+
75+
report = profile(df, target="churn")
76+
report.health # 87
77+
report.to_frame()
78+
```
79+
80+
```text
81+
severity code column message suggestion
82+
warning constant plan only one distinct value drop, carries no information
83+
warning high_missing spend 50% missing impute or drop
84+
warning id_like user_id identifier-like (100/100 unique) drop before modeling
85+
info imbalance <target> class 'Yes' is only 4.0% of rows consider resampling or class weights
86+
```
87+
88+
The report renders as a compact, color-coded summary in notebooks and terminals, and exposes everything programmatically:
89+
90+
- `report.errors`, `report.warnings`, `report.infos`: findings filtered by severity.
91+
- `report.to_frame()`: all findings as a tidy DataFrame.
92+
- `report.health`: a 0 to 100 score (100 is clean).
93+
- `assert not report.errors`: drop it straight into a CI data-quality gate.
94+
95+
Pass `target=` to also check for **leakage** (features that predict the target almost perfectly) and class imbalance. Accepts pandas or polars input.
96+
97+
---
98+
4899
## `change`
49100

50101
Percentage change — as two numbers, between two columns, or down a whole series.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ One import, one line. A clean, sorted DataFrame you can read or feed straight in
5454

5555
| Function | What it answers |
5656
|---|---|
57+
| `profile` | What is wrong with this dataset, and how do I fix it? |
5758
| `change` | How much did a value grow, across numbers, columns, or a whole series? |
5859
| `vif` | Which features are collinear? |
5960
| `missing` | How much of each column is missing? |

percentify/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
from .profiling import profile, ProfileReport, Finding
12
from .stats import (
23
change, vif, missing, cv, outliers, r_squared, pca_variance, imbalance,
34
difference, split, display, PercentifyWarning,
45
)
56

67
__all__ = [
8+
"profile", "ProfileReport", "Finding",
79
"change", "vif", "missing", "cv", "outliers", "r_squared", "pca_variance", "imbalance",
810
"difference", "split", "display", "PercentifyWarning",
911
]

percentify/profiling.py

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from __future__ import annotations
2828

29+
import html
2930
import re
3031
import warnings
3132
from dataclasses import dataclass, field
@@ -34,6 +35,8 @@
3435
import numpy as np
3536
import pandas as pd
3637

38+
from .stats import PercentifyWarning, imbalance, missing
39+
3740
__all__ = ["profile", "ProfileReport", "Finding"]
3841

3942
# Severity ordering and the penalty each level deducts from the health score.
@@ -132,18 +135,18 @@ def _encode_target(target: pd.Series) -> tuple[np.ndarray, bool]:
132135
# --------------------------------------------------------------------------- #
133136
def check_missing(df: pd.DataFrame, target) -> list[Finding]:
134137
out: list[Finding] = []
135-
n = len(df)
136-
if n == 0:
138+
if len(df) == 0:
137139
return out
138-
frac = df.isna().mean()
139-
for col, f in frac.items():
140-
if f >= 1.0:
140+
# Reuse the package's missing() so there is a single source of truth.
141+
for _, row in missing(df).iterrows():
142+
col, pct = row["column"], row["missing_pct"]
143+
if pct >= 100.0:
141144
out.append(Finding(col, "error", "all_missing",
142145
"column is entirely missing",
143146
"drop the column"))
144-
elif f >= 0.4:
147+
elif pct >= 40.0:
145148
out.append(Finding(col, "warning", "high_missing",
146-
f"{f * 100:.0f}% missing",
149+
f"{pct:.0f}% missing",
147150
"impute or drop"))
148151
return out
149152

@@ -156,7 +159,7 @@ def check_constant(df: pd.DataFrame, target) -> list[Finding]:
156159
if df[col].nunique(dropna=True) <= 1:
157160
out.append(Finding(col, "warning", "constant",
158161
"only one distinct value",
159-
"drop carries no information"))
162+
"drop, carries no information"))
160163
return out
161164

162165

@@ -286,7 +289,7 @@ def check_collinearity(df: pd.DataFrame, target) -> list[Finding]:
286289
if pd.notna(r) and r >= 0.95 and frozenset((a, b)) not in seen:
287290
seen.add(frozenset((a, b)))
288291
out.append(Finding(f"{a} \u27f7 {b}", "warning", "collinear",
289-
f"correlation {r:.2f} redundant pair",
292+
f"correlation {r:.2f}, redundant pair",
290293
"drop one, or check vif()"))
291294
return out
292295

@@ -298,11 +301,13 @@ def check_leakage(df: pd.DataFrame, target) -> list[Finding]:
298301
y, y_is_cat = _encode_target(target)
299302
out: list[Finding] = []
300303
n = len(df)
304+
numeric_cols = set(_numeric_columns(df))
305+
text_cols = set(_text_columns(df))
301306

302307
for col in df.columns:
303308
s = df[col]
304309
# Numeric feature: near-perfect linear association with the target.
305-
if col in _numeric_columns(df):
310+
if col in numeric_cols:
306311
mask = s.notna().to_numpy() & np.isfinite(y)
307312
if mask.sum() < 10:
308313
continue
@@ -314,9 +319,9 @@ def check_leakage(df: pd.DataFrame, target) -> list[Finding]:
314319
if np.isfinite(r) and r > 0.98:
315320
out.append(Finding(col, "error", "leakage",
316321
f"predicts the target (|r|={r:.2f})",
317-
"likely leakage confirm it is known at predict time"))
322+
"likely leakage, confirm it is known at predict time"))
318323
# Categorical feature: does it almost perfectly determine the target?
319-
elif y_is_cat and col in _text_columns(df):
324+
elif y_is_cat and col in text_cols:
320325
nun = s.nunique(dropna=True)
321326
if nun <= 1 or nun >= 0.5 * n:
322327
continue # constant or id-like handled elsewhere
@@ -331,7 +336,7 @@ def check_leakage(df: pd.DataFrame, target) -> list[Finding]:
331336
out.append(Finding(col, "error", "leakage",
332337
f"almost perfectly determines the target "
333338
f"(purity {purity:.2f})",
334-
"likely leakage confirm it is known at predict time"))
339+
"likely leakage, confirm it is known at predict time"))
335340
return out
336341

337342

@@ -340,11 +345,16 @@ def check_target_imbalance(df: pd.DataFrame, target) -> list[Finding]:
340345
return []
341346
if pd.api.types.is_numeric_dtype(target) and target.nunique(dropna=True) > 20:
342347
return [] # treat as continuous
343-
counts = target.value_counts(normalize=True, dropna=True)
344-
if len(counts) >= 2 and counts.min() < 0.05:
345-
smallest = counts.idxmin()
348+
# Reuse the package's imbalance() rather than re-deriving the class balance.
349+
result = imbalance(target)
350+
summary = result.attrs.get("summary", {})
351+
if summary.get("n_classes", 0) < 2:
352+
return []
353+
minority = summary["minority_class"]
354+
min_pct = dict(zip(result["class"].tolist(), result["pct"].tolist())).get(minority)
355+
if min_pct is not None and min_pct < 5.0:
346356
return [Finding("<target>", "info", "imbalance",
347-
f"class '{smallest}' is only {counts.min() * 100:.1f}% of rows",
357+
f"class '{minority}' is only {min_pct:.1f}% of rows",
348358
"consider resampling or class weights")]
349359
return []
350360

@@ -444,12 +454,12 @@ def _repr_html_(self) -> str:
444454
rows = ""
445455
for f in self.findings:
446456
c = colors[f.severity]
447-
sug = f" &rarr; {f.suggestion}" if f.suggestion else ""
457+
sug = f" &rarr; {html.escape(f.suggestion)}" if f.suggestion else ""
448458
rows += (
449459
f'<tr><td style="color:{c};font-weight:600;padding:2px 10px">'
450460
f'{f.severity}</td>'
451-
f'<td style="font-family:monospace;padding:2px 10px">{f.column}</td>'
452-
f'<td style="padding:2px 10px">{f.message}'
461+
f'<td style="font-family:monospace;padding:2px 10px">{html.escape(f.column)}</td>'
462+
f'<td style="padding:2px 10px">{html.escape(f.message)}'
453463
f'<span style="color:#888">{sug}</span></td></tr>'
454464
)
455465
if not rows:
@@ -459,8 +469,8 @@ def _repr_html_(self) -> str:
459469
for _, r in self.summary.iterrows():
460470
spark = self._sparklines.get(r["column"], "")
461471
cols += (
462-
f'<tr><td style="font-family:monospace;padding:2px 10px">{r["column"]}</td>'
463-
f'<td style="padding:2px 10px;color:#888">{r["dtype"]}</td>'
472+
f'<tr><td style="font-family:monospace;padding:2px 10px">{html.escape(str(r["column"]))}</td>'
473+
f'<td style="padding:2px 10px;color:#888">{html.escape(str(r["dtype"]))}</td>'
464474
f'<td style="font-family:monospace;padding:2px 10px">{spark}</td>'
465475
f'<td style="padding:2px 10px">{r["missing_pct"]:.0f}%</td>'
466476
f'<td style="padding:2px 10px">{r["cardinality"]:,}</td></tr>'
@@ -504,14 +514,14 @@ def profile(data, target: Optional[str] = None) -> ProfileReport:
504514
df = df.drop(columns=[target])
505515
else:
506516
warnings.warn(f"target {target!r} not found in columns; ignoring",
507-
stacklevel=2)
517+
PercentifyWarning, stacklevel=2)
508518

509519
findings: list[Finding] = []
510520
for check in CHECKS:
511521
try:
512522
findings.extend(check(df, target_series))
513523
except Exception as exc: # a broken check must never sink the report
514-
warnings.warn(f"{check.__name__} failed: {exc}", stacklevel=2)
524+
warnings.warn(f"{check.__name__} failed: {exc}", PercentifyWarning, stacklevel=2)
515525

516526
findings.sort(key=lambda f: (_SEVERITY_ORDER[f.severity], f.column))
517527

tests/test_profiling.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import pytest
2+
import numpy as np
3+
import pandas as pd
4+
from percentify import profile, ProfileReport, Finding, PercentifyWarning
5+
6+
7+
@pytest.fixture
8+
def messy_df():
9+
np.random.seed(0)
10+
base = np.random.randn(200)
11+
return pd.DataFrame({
12+
"id": range(200), # id-like
13+
"empty": [None] * 200, # entirely missing
14+
"const": [7] * 200, # constant
15+
"x": base,
16+
"x_dup": base * 1.0, # collinear with x
17+
"churn": ["No"] * 195 + ["Yes"] * 5, # 2.5% minority target
18+
})
19+
20+
21+
def test_profile_returns_report(messy_df):
22+
assert isinstance(profile(messy_df), ProfileReport)
23+
24+
25+
def test_profile_all_missing_is_error(messy_df):
26+
codes = {(f.column, f.code) for f in profile(messy_df).errors}
27+
assert ("empty", "all_missing") in codes
28+
29+
30+
def test_profile_flags_constant_and_id(messy_df):
31+
warn_codes = {f.code for f in profile(messy_df).warnings}
32+
assert "constant" in warn_codes
33+
assert "id_like" in warn_codes
34+
assert "collinear" in warn_codes
35+
36+
37+
def test_profile_target_imbalance(messy_df):
38+
infos = {f.code for f in profile(messy_df, target="churn").infos}
39+
assert "imbalance" in infos
40+
41+
42+
def test_profile_to_frame(messy_df):
43+
frame = profile(messy_df).to_frame()
44+
assert isinstance(frame, pd.DataFrame)
45+
assert list(frame.columns) == ["severity", "code", "column", "message", "suggestion"]
46+
47+
48+
def test_profile_findings_are_findings(messy_df):
49+
assert all(isinstance(f, Finding) for f in profile(messy_df).findings)
50+
51+
52+
def test_profile_clean_data_full_health():
53+
clean = pd.DataFrame({
54+
"a": [x % 10 for x in range(30)],
55+
"b": [x % 7 for x in range(30)],
56+
})
57+
report = profile(clean)
58+
assert report.findings == []
59+
assert report.health == 100
60+
61+
62+
def test_profile_health_drops_with_issues(messy_df):
63+
assert profile(messy_df).health < 100
64+
65+
66+
def test_profile_target_not_found_warns(messy_df):
67+
with pytest.warns(PercentifyWarning):
68+
profile(messy_df, target="does_not_exist")
69+
70+
71+
def test_profile_html_escapes_column_names():
72+
evil = pd.DataFrame({"<script>": [None] * 200, "ok": range(200)})
73+
rendered = profile(evil)._repr_html_()
74+
assert "&lt;script&gt;" in rendered
75+
assert "<script>" not in rendered
76+
77+
78+
def test_profile_composes_missing_high_warning():
79+
# 60% missing should surface as a high_missing warning (via missing()).
80+
df = pd.DataFrame({"col": [1.0, 2.0] + [None] * 3})
81+
codes = {f.code for f in profile(df).warnings}
82+
assert "high_missing" in codes
83+
84+
85+
def test_profile_polars_input(messy_df):
86+
pl = pytest.importorskip("polars")
87+
report = profile(pl.from_pandas(messy_df), target="churn")
88+
assert isinstance(report, ProfileReport)
89+
assert any(f.code == "all_missing" for f in report.errors)

0 commit comments

Comments
 (0)