Skip to content

Commit 9f6f822

Browse files
authored
feat: add normality and variance homogeneity statistical tests (#67)
* feat: add normality and variance homogeneity statistical tests - config.py: add StatisticalTestThresholds (normality p-value, Shapiro max-n cutoff, min sample size, Levene p-value and min group size) wired into HashPrepConfig - checks/statistical_tests.py: two new checks: - normality: Shapiro-Wilk (n ≤ 5000) or D'Agostino-Pearson (n > 5000) per numeric column; flags non-normal distributions with stat + p-value in the issue description - variance_homogeneity: Levene's test (median-centred, robust) across target-column groups; reports std ratio alongside the test result; skipped when no target column is set or groups are too small - summaries/variables.py: embed normality result (test name, statistic, p_value, is_normal bool) into each numeric column's summary dict; also fix a pre-existing crash where infinite values in a column caused np.histogram to receive range=(-inf, inf) — all distribution stats now computed on finite-only values - checks/__init__.py + core/analyzer.py: register normality and variance_homogeneity in CHECKS registry and ALL_CHECKS list - tests/test_statistical_tests.py: 30 tests covering normality check unit, Levene check unit, summary embedding, and end-to-end integration via DatasetAnalyzer; all 180 tests pass (150 existing + 30 new) * style: apply ruff format to statistical_tests.py
1 parent 8e97549 commit 9f6f822

6 files changed

Lines changed: 472 additions & 17 deletions

File tree

hashprep/checks/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
_check_outliers,
2323
_check_skewness,
2424
)
25+
from .statistical_tests import _check_normality, _check_variance_homogeneity
2526

2627

2728
def _check_dataset_drift(analyzer):
@@ -57,6 +58,8 @@ def _check_dataset_drift(analyzer):
5758
"infinite_values": _check_infinite_values,
5859
"constant_length": _check_constant_length,
5960
"empty_dataset": _check_empty_dataset,
61+
"normality": _check_normality,
62+
"variance_homogeneity": _check_variance_homogeneity,
6063
}
6164

6265
CORRELATION_CHECKS = {"feature_correlation", "categorical_correlation", "mixed_correlation"}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""
2+
Statistical tests: normality (Shapiro-Wilk / D'Agostino-Pearson) and
3+
variance homogeneity (Levene's test across target-column groups).
4+
"""
5+
6+
import numpy as np
7+
from scipy.stats import levene, normaltest, shapiro
8+
9+
from ..config import DEFAULT_CONFIG
10+
from .core import Issue
11+
12+
_ST = DEFAULT_CONFIG.statistical_tests
13+
14+
15+
def _run_normality_test(series) -> tuple[str, float, float]:
16+
"""
17+
Return (test_name, statistic, p_value) for the most appropriate normality test.
18+
Uses Shapiro-Wilk for n <= shapiro_max_n, D'Agostino-Pearson otherwise.
19+
"""
20+
n = len(series)
21+
if n <= _ST.shapiro_max_n:
22+
stat, p = shapiro(series)
23+
return "shapiro_wilk", float(stat), float(p)
24+
else:
25+
stat, p = normaltest(series)
26+
return "dagostino_pearson", float(stat), float(p)
27+
28+
29+
def _check_normality(analyzer) -> list[Issue]:
30+
"""
31+
Flag numeric columns whose distribution is significantly non-normal.
32+
Uses Shapiro-Wilk for n <= 5000, D'Agostino-Pearson for larger samples.
33+
Non-normality matters for linear models, t-tests, and certain imputation strategies.
34+
"""
35+
issues = []
36+
37+
for col in analyzer.df.select_dtypes(include="number").columns:
38+
series = analyzer.df[col].dropna()
39+
n = len(series)
40+
if n < _ST.normality_min_n:
41+
continue
42+
if series.nunique() <= 1:
43+
continue
44+
45+
test_name, stat, p_val = _run_normality_test(series)
46+
47+
if p_val < _ST.normality_p_value:
48+
# Severity: very small p → critical (strong evidence), otherwise warning
49+
severity = "critical" if p_val < 0.001 else "warning"
50+
impact = "high" if severity == "critical" else "medium"
51+
test_label = "Shapiro-Wilk" if test_name == "shapiro_wilk" else "D'Agostino-Pearson"
52+
53+
issues.append(
54+
Issue(
55+
category="normality",
56+
severity=severity,
57+
column=col,
58+
description=(f"Column '{col}' is non-normal ({test_label}: stat={stat:.4f}, p={p_val:.4g}, n={n})"),
59+
impact_score=impact,
60+
quick_fix=(
61+
"Options:\n"
62+
"- Transform: Log, sqrt, or Box-Cox/Yeo-Johnson often normalise skewed data.\n"
63+
"- Use robust models: Tree-based models (XGBoost, RF) make no normality assumption.\n"
64+
"- Normalise for linear models: Required for OLS residuals and LDA.\n"
65+
"- Investigate outliers: Extreme values are a common cause of non-normality."
66+
),
67+
)
68+
)
69+
70+
return issues
71+
72+
73+
def _check_variance_homogeneity(analyzer) -> list[Issue]:
74+
"""
75+
Run Levene's test across groups defined by the target column.
76+
Unequal variances (heteroscedasticity) across classes violate assumptions of
77+
linear discriminant analysis and ANOVA; they also indicate scale differences
78+
that may harm distance-based models.
79+
80+
Only runs when a target column is set and has at least 2 groups with
81+
sufficient data.
82+
"""
83+
issues = []
84+
85+
if analyzer.target_col is None:
86+
return issues
87+
88+
target = analyzer.df[analyzer.target_col].dropna()
89+
groups_labels = target.unique()
90+
91+
for col in analyzer.df.select_dtypes(include="number").columns:
92+
if col == analyzer.target_col:
93+
continue
94+
95+
series = analyzer.df[col]
96+
97+
# Build per-group arrays, filtering out groups that are too small
98+
groups = []
99+
for label in groups_labels:
100+
mask = analyzer.df[analyzer.target_col] == label
101+
grp = series[mask].dropna().values
102+
if len(grp) >= _ST.levene_min_group_size:
103+
groups.append(grp)
104+
105+
if len(groups) < 2:
106+
continue
107+
108+
try:
109+
stat, p_val = levene(*groups, center="median") # median-centre is most robust
110+
except ValueError:
111+
continue
112+
113+
if p_val < _ST.levene_p_value:
114+
# Compute per-group stds to add colour to the description
115+
stds = [float(np.std(g, ddof=1)) for g in groups]
116+
std_ratio = max(stds) / min(stds) if min(stds) > 0 else float("inf")
117+
severity = "critical" if std_ratio > 3.0 else "warning"
118+
impact = "high" if severity == "critical" else "medium"
119+
120+
issues.append(
121+
Issue(
122+
category="variance_homogeneity",
123+
severity=severity,
124+
column=col,
125+
description=(
126+
f"Column '{col}' has unequal variances across '{analyzer.target_col}' groups "
127+
f"(Levene: stat={stat:.4f}, p={p_val:.4g}; std ratio={std_ratio:.2f}×)"
128+
),
129+
impact_score=impact,
130+
quick_fix=(
131+
"Options:\n"
132+
"- Scale per class: Normalise within each target group before training.\n"
133+
"- Transform feature: Log or sqrt often equalises spread.\n"
134+
"- Use Welch's t-test / robust ANOVA: Accounts for unequal variances.\n"
135+
"- Use tree-based models: Decision trees are invariant to feature scaling."
136+
),
137+
)
138+
)
139+
140+
return issues

hashprep/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,22 @@ class ImbalanceThresholds:
126126
majority_class_ratio: float = 0.9
127127

128128

129+
@dataclass(frozen=True)
130+
class StatisticalTestThresholds:
131+
"""Thresholds for normality and variance homogeneity tests."""
132+
133+
# p-value below which we flag non-normality
134+
normality_p_value: float = 0.05
135+
# Shapiro-Wilk is used up to this sample size; D'Agostino-Pearson above it
136+
shapiro_max_n: int = 5000
137+
# Minimum samples to run any normality test
138+
normality_min_n: int = 8
139+
# p-value below which Levene's test flags unequal variances across groups
140+
levene_p_value: float = 0.05
141+
# Minimum group size to include a target group in Levene's test
142+
levene_min_group_size: int = 8
143+
144+
129145
@dataclass(frozen=True)
130146
class DateTimeThresholds:
131147
"""Thresholds for datetime-specific checks."""
@@ -190,6 +206,7 @@ class HashPrepConfig:
190206
drift: DriftThresholds = field(default_factory=DriftThresholds)
191207
distribution: DistributionThresholds = field(default_factory=DistributionThresholds)
192208
imbalance: ImbalanceThresholds = field(default_factory=ImbalanceThresholds)
209+
statistical_tests: StatisticalTestThresholds = field(default_factory=StatisticalTestThresholds)
193210
datetime: DateTimeThresholds = field(default_factory=DateTimeThresholds)
194211
type_inference: TypeInferenceConfig = field(default_factory=TypeInferenceConfig)
195212
sampling: SamplingDefaults = field(default_factory=SamplingDefaults)

hashprep/core/analyzer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ class DatasetAnalyzer:
5959
"unique_values",
6060
"infinite_values",
6161
"constant_length",
62+
"normality",
63+
"variance_homogeneity",
6264
]
6365

6466
def __init__(

hashprep/summaries/variables.py

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44

55
import numpy as np
66
import pandas as pd
7-
from scipy.stats import median_abs_deviation
7+
from scipy.stats import median_abs_deviation, normaltest, shapiro
88

99
from ..config import DEFAULT_CONFIG
1010

1111
_SUMMARY = DEFAULT_CONFIG.summaries
12+
_ST = DEFAULT_CONFIG.statistical_tests
1213

1314

1415
def get_monotonicity(series: pd.Series) -> str:
@@ -85,10 +86,12 @@ def _summarize_numeric(df, col):
8586
zeros_percentage = zeros_count / n * 100
8687
negative_count = int((series < 0).sum())
8788
negative_percentage = negative_count / n * 100
88-
mean_val = float(series.mean())
89-
min_val = float(series.min())
90-
max_val = float(series.max())
91-
q = series.quantile([0, 0.05, 0.25, 0.5, 0.75, 0.95, 1.0])
89+
# Use finite-only series for distribution statistics to avoid np.histogram crashing
90+
finite = series[np.isfinite(series)]
91+
mean_val = float(finite.mean()) if not finite.empty else float("nan")
92+
min_val = float(finite.min()) if not finite.empty else float("nan")
93+
max_val = float(finite.max()) if not finite.empty else float("nan")
94+
q = finite.quantile([0, 0.05, 0.25, 0.5, 0.75, 0.95, 1.0])
9295
quantiles = {
9396
"minimum": float(q[0]),
9497
"p5": float(q[0.05]),
@@ -100,29 +103,47 @@ def _summarize_numeric(df, col):
100103
"range": float(q[1.0] - q[0]),
101104
"iqr": float(q[0.75] - q[0.25]),
102105
}
103-
cv = float(series.std() / abs(mean_val)) if mean_val != 0 else None
106+
cv = float(finite.std() / abs(mean_val)) if mean_val != 0 else None
104107
descriptive = {
105-
"standard_deviation": float(series.std()),
108+
"standard_deviation": float(finite.std()),
106109
"coefficient_of_variation": cv,
107-
"kurtosis": float(series.kurtosis()),
110+
"kurtosis": float(finite.kurtosis()),
108111
"mean": mean_val,
109-
"mad": float(median_abs_deviation(series)),
110-
"skewness": float(series.skew()),
111-
"sum": float(series.sum()),
112-
"variance": float(series.var()),
113-
"monotonicity": get_monotonicity(series),
112+
"mad": float(median_abs_deviation(finite)),
113+
"skewness": float(finite.skew()),
114+
"sum": float(finite.sum()),
115+
"variance": float(finite.var()),
116+
"monotonicity": get_monotonicity(finite),
114117
}
115-
hist, bin_edges = np.histogram(series, bins=_SUMMARY.histogram_bins, range=(min_val, max_val))
118+
hist, bin_edges = np.histogram(finite, bins=_SUMMARY.histogram_bins, range=(min_val, max_val))
116119
histogram = {
117120
"bin_edges": [float(x) for x in bin_edges],
118121
"counts": [int(x) for x in hist],
119122
}
120-
vc = series.value_counts().head(_SUMMARY.top_n_values)
123+
vc = finite.value_counts().head(_SUMMARY.top_n_values)
121124
common_values = {str(v): {"count": int(c), "percentage": float(c / n * 100)} for v, c in vc.items()}
122125
extremes = {
123-
"minimum_10": [float(x) for x in sorted(series)[: _SUMMARY.extreme_values_count]],
124-
"maximum_10": [float(x) for x in sorted(series)[-_SUMMARY.extreme_values_count :]],
126+
"minimum_10": [float(x) for x in sorted(finite)[: _SUMMARY.extreme_values_count]],
127+
"maximum_10": [float(x) for x in sorted(finite)[-_SUMMARY.extreme_values_count :]],
125128
}
129+
# Normality test (Shapiro-Wilk for small n, D'Agostino-Pearson for large n)
130+
normality = None
131+
if n >= _ST.normality_min_n and series.nunique() > 1:
132+
finite = series[np.isfinite(series)]
133+
if len(finite) >= _ST.normality_min_n:
134+
if len(finite) <= _ST.shapiro_max_n:
135+
norm_stat, norm_p = shapiro(finite)
136+
norm_test = "shapiro_wilk"
137+
else:
138+
norm_stat, norm_p = normaltest(finite)
139+
norm_test = "dagostino_pearson"
140+
normality = {
141+
"test": norm_test,
142+
"statistic": float(norm_stat),
143+
"p_value": float(norm_p),
144+
"is_normal": float(norm_p) >= _ST.normality_p_value,
145+
}
146+
126147
stats = {
127148
"infinite_count": infinite_count,
128149
"infinite_percentage": float(infinite_percentage),
@@ -137,6 +158,7 @@ def _summarize_numeric(df, col):
137158
"histogram": histogram,
138159
"common_values": common_values,
139160
"extreme_values": extremes,
161+
"normality": normality,
140162
}
141163
return stats
142164

0 commit comments

Comments
 (0)