2626
2727from __future__ import annotations
2828
29+ import html
2930import re
3031import warnings
3132from dataclasses import dataclass , field
3435import numpy as np
3536import 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# --------------------------------------------------------------------------- #
133136def 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" → { f .suggestion } " if f .suggestion else ""
457+ sug = f" → { 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
0 commit comments