data-advisor 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data_advisor/__init__.py +38 -0
- data_advisor/__main__.py +9 -0
- data_advisor/advisor.py +436 -0
- data_advisor/cli.py +160 -0
- data_advisor/codegen.py +515 -0
- data_advisor/rules.py +380 -0
- data_advisor/scanner.py +655 -0
- data_advisor/search.py +679 -0
- data_advisor/visualizer.py +236 -0
- data_advisor-0.1.0.dist-info/METADATA +303 -0
- data_advisor-0.1.0.dist-info/RECORD +14 -0
- data_advisor-0.1.0.dist-info/WHEEL +5 -0
- data_advisor-0.1.0.dist-info/entry_points.txt +2 -0
- data_advisor-0.1.0.dist-info/top_level.txt +1 -0
data_advisor/scanner.py
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
"""
|
|
2
|
+
scanner.py - Core dataset inspection, profiling, and statistical testing engine.
|
|
3
|
+
|
|
4
|
+
Designed for robust execution against unknown, messy real-world datasets during
|
|
5
|
+
timed data analysis examinations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from scipy import stats
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_dataset(source: Union[str, Path, pd.DataFrame]) -> pd.DataFrame:
|
|
20
|
+
"""
|
|
21
|
+
Safely load a dataset from a file path or return an existing DataFrame copy.
|
|
22
|
+
Supports CSV, TSV, Excel, Parquet, JSON, and Feather formats.
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(source, pd.DataFrame):
|
|
25
|
+
return source.copy()
|
|
26
|
+
|
|
27
|
+
path = Path(source)
|
|
28
|
+
if not path.exists():
|
|
29
|
+
raise FileNotFoundError(f"Dataset path does not exist: {path}")
|
|
30
|
+
|
|
31
|
+
ext = path.suffix.lower()
|
|
32
|
+
if ext in [".csv", ".txt"]:
|
|
33
|
+
# Try default comma, fallback to tab or semicolon if 1 column detected
|
|
34
|
+
try:
|
|
35
|
+
df = pd.read_csv(path)
|
|
36
|
+
if len(df.columns) == 1:
|
|
37
|
+
df_tab = pd.read_csv(path, sep="\t")
|
|
38
|
+
if len(df_tab.columns) > 1:
|
|
39
|
+
df = df_tab
|
|
40
|
+
except Exception:
|
|
41
|
+
df = pd.read_csv(path, sep=None, engine="python")
|
|
42
|
+
elif ext == ".tsv":
|
|
43
|
+
df = pd.read_csv(path, sep="\t")
|
|
44
|
+
elif ext in [".xlsx", ".xls"]:
|
|
45
|
+
df = pd.read_excel(path)
|
|
46
|
+
elif ext == ".parquet":
|
|
47
|
+
df = pd.read_parquet(path)
|
|
48
|
+
elif ext == ".json":
|
|
49
|
+
df = pd.read_json(path)
|
|
50
|
+
elif ext == ".feather":
|
|
51
|
+
df = pd.read_feather(path)
|
|
52
|
+
else:
|
|
53
|
+
# Fallback to read_csv
|
|
54
|
+
df = pd.read_csv(path)
|
|
55
|
+
|
|
56
|
+
return df
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DataScanner:
|
|
60
|
+
"""
|
|
61
|
+
Exhaustive data profiler and statistical testing engine.
|
|
62
|
+
Analyzes structure, data quality, distributions, outliers, cardinality,
|
|
63
|
+
and runs relevant bivariate hypothesis tests.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
df: pd.DataFrame,
|
|
69
|
+
target: Optional[str] = None,
|
|
70
|
+
id_cols: Optional[List[str]] = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.df = df.copy()
|
|
73
|
+
self.target = target
|
|
74
|
+
self.id_cols = set(id_cols or [])
|
|
75
|
+
self._cache: Dict[str, Any] = {}
|
|
76
|
+
|
|
77
|
+
# -------------------------------------------------------------------------
|
|
78
|
+
# 1. Structure & Overview
|
|
79
|
+
# -------------------------------------------------------------------------
|
|
80
|
+
def scan_overview(self) -> Dict[str, Any]:
|
|
81
|
+
"""Compute basic dataset dimensions, memory footprint, and row duplicates."""
|
|
82
|
+
n_rows, n_cols = self.df.shape
|
|
83
|
+
mem_bytes = self.df.memory_usage(deep=True).sum()
|
|
84
|
+
mem_mb = round(mem_bytes / (1024 * 1024), 2)
|
|
85
|
+
dup_count = int(self.df.duplicated().sum())
|
|
86
|
+
dup_pct = round((dup_count / n_rows * 100) if n_rows > 0 else 0.0, 2)
|
|
87
|
+
|
|
88
|
+
dtypes_summary: Dict[str, int] = {}
|
|
89
|
+
for dt in self.df.dtypes:
|
|
90
|
+
dt_name = str(dt)
|
|
91
|
+
dtypes_summary[dt_name] = dtypes_summary.get(dt_name, 0) + 1
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"n_rows": n_rows,
|
|
95
|
+
"n_cols": n_cols,
|
|
96
|
+
"memory_mb": mem_mb,
|
|
97
|
+
"duplicate_rows": dup_count,
|
|
98
|
+
"duplicate_pct": dup_pct,
|
|
99
|
+
"dtypes_summary": dtypes_summary,
|
|
100
|
+
"column_names": list(self.df.columns),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# -------------------------------------------------------------------------
|
|
104
|
+
# 2. Missing Values & Data Quality
|
|
105
|
+
# -------------------------------------------------------------------------
|
|
106
|
+
def scan_missing_values(self) -> Dict[str, Dict[str, Any]]:
|
|
107
|
+
"""Identify missing value counts, percentages, and imputation strategy candidates."""
|
|
108
|
+
n_rows = len(self.df)
|
|
109
|
+
missing_report: Dict[str, Dict[str, Any]] = {}
|
|
110
|
+
|
|
111
|
+
for col in self.df.columns:
|
|
112
|
+
null_count = int(self.df[col].isna().sum())
|
|
113
|
+
null_pct = round((null_count / n_rows * 100) if n_rows > 0 else 0.0, 2)
|
|
114
|
+
dtype_str = str(self.df[col].dtype)
|
|
115
|
+
is_numeric = pd.api.types.is_numeric_dtype(self.df[col])
|
|
116
|
+
|
|
117
|
+
missing_report[col] = {
|
|
118
|
+
"null_count": null_count,
|
|
119
|
+
"null_pct": null_pct,
|
|
120
|
+
"dtype": dtype_str,
|
|
121
|
+
"is_numeric": is_numeric,
|
|
122
|
+
"all_null": null_count == n_rows,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return missing_report
|
|
126
|
+
|
|
127
|
+
# -------------------------------------------------------------------------
|
|
128
|
+
# 3. Data Type Mismatches & Semantic Roles
|
|
129
|
+
# -------------------------------------------------------------------------
|
|
130
|
+
def scan_data_types(self) -> Dict[str, Any]:
|
|
131
|
+
"""
|
|
132
|
+
Flag data type mismatches:
|
|
133
|
+
- Numeric IDs stored as numbers
|
|
134
|
+
- Dates stored as strings
|
|
135
|
+
- Low-cardinality integers that are categoricals
|
|
136
|
+
- Constant / zero variance columns
|
|
137
|
+
- High-cardinality text/ID columns
|
|
138
|
+
"""
|
|
139
|
+
n_rows = len(self.df)
|
|
140
|
+
potential_dates: List[str] = []
|
|
141
|
+
potential_ids: List[str] = []
|
|
142
|
+
low_card_numerics: List[Dict[str, Any]] = []
|
|
143
|
+
constant_columns: List[str] = []
|
|
144
|
+
high_card_objects: List[str] = []
|
|
145
|
+
|
|
146
|
+
id_name_patterns = ("id", "key", "code", "uuid", "guid", "hash", "ssn", "token")
|
|
147
|
+
|
|
148
|
+
for col in self.df.columns:
|
|
149
|
+
series = self.df[col]
|
|
150
|
+
non_null = series.dropna()
|
|
151
|
+
n_non_null = len(non_null)
|
|
152
|
+
nunique = int(non_null.nunique())
|
|
153
|
+
col_lower = str(col).lower()
|
|
154
|
+
|
|
155
|
+
# Constant column
|
|
156
|
+
if nunique <= 1:
|
|
157
|
+
constant_columns.append(col)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# Check potential dates in object/string columns
|
|
161
|
+
if pd.api.types.is_object_dtype(series) or pd.api.types.is_string_dtype(series):
|
|
162
|
+
# High cardinality string / hash
|
|
163
|
+
if n_non_null > 0 and (nunique / n_non_null) > 0.85 and nunique > 30:
|
|
164
|
+
high_card_objects.append(col)
|
|
165
|
+
|
|
166
|
+
# Heuristic date parsing on sample
|
|
167
|
+
if any(k in col_lower for k in ("date", "time", "timestamp", "year", "dob", "created", "updated")):
|
|
168
|
+
potential_dates.append(col)
|
|
169
|
+
elif n_non_null > 0:
|
|
170
|
+
sample = non_null.head(20).astype(str)
|
|
171
|
+
parsed_success = 0
|
|
172
|
+
for val in sample:
|
|
173
|
+
if len(val) >= 6 and (("-" in val) or ("/" in val) or (":" in val)):
|
|
174
|
+
try:
|
|
175
|
+
pd.to_datetime(val, errors="raise")
|
|
176
|
+
parsed_success += 1
|
|
177
|
+
except Exception:
|
|
178
|
+
break
|
|
179
|
+
if parsed_success >= max(5, int(len(sample) * 0.7)):
|
|
180
|
+
potential_dates.append(col)
|
|
181
|
+
|
|
182
|
+
# Check potential IDs
|
|
183
|
+
if col in self.id_cols:
|
|
184
|
+
potential_ids.append(col)
|
|
185
|
+
elif any(col_lower.endswith(pat) or col_lower.startswith(pat) or f"_{pat}" in col_lower for pat in id_name_patterns):
|
|
186
|
+
if nunique > 20 and (n_non_null > 0 and (nunique / n_non_null) > 0.6):
|
|
187
|
+
potential_ids.append(col)
|
|
188
|
+
elif pd.api.types.is_integer_dtype(series) and n_non_null > 50:
|
|
189
|
+
if nunique == n_non_null:
|
|
190
|
+
potential_ids.append(col)
|
|
191
|
+
|
|
192
|
+
# Low cardinality numeric acting as categorical
|
|
193
|
+
if pd.api.types.is_numeric_dtype(series) and col not in potential_ids:
|
|
194
|
+
if 2 <= nunique <= 6 and nunique < (n_rows * 0.05):
|
|
195
|
+
low_card_numerics.append({
|
|
196
|
+
"column": col,
|
|
197
|
+
"unique_count": nunique,
|
|
198
|
+
"unique_values": sorted(non_null.unique().tolist()),
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
"potential_dates": potential_dates,
|
|
203
|
+
"potential_ids": list(set(potential_ids)),
|
|
204
|
+
"low_card_numerics": low_card_numerics,
|
|
205
|
+
"constant_columns": constant_columns,
|
|
206
|
+
"high_card_objects": high_card_objects,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# -------------------------------------------------------------------------
|
|
210
|
+
# 4. Distributions, Outliers & Skewness
|
|
211
|
+
# -------------------------------------------------------------------------
|
|
212
|
+
def scan_distributions(self) -> Dict[str, Dict[str, Any]]:
|
|
213
|
+
"""
|
|
214
|
+
Calculate descriptive statistics, skewness, kurtosis, and outlier counts
|
|
215
|
+
using both IQR criteria (1.5x and 3x) and Z-score criteria (|z| > 3).
|
|
216
|
+
"""
|
|
217
|
+
num_cols = self.df.select_dtypes(include=[np.number]).columns
|
|
218
|
+
dist_report: Dict[str, Dict[str, Any]] = {}
|
|
219
|
+
|
|
220
|
+
for col in num_cols:
|
|
221
|
+
series = self.df[col].dropna()
|
|
222
|
+
n_count = len(series)
|
|
223
|
+
|
|
224
|
+
if n_count < 3 or series.nunique() <= 1:
|
|
225
|
+
# Insufficient variance to compute skewness/outliers
|
|
226
|
+
dist_report[col] = {
|
|
227
|
+
"count": n_count,
|
|
228
|
+
"mean": float(series.mean()) if n_count > 0 else 0.0,
|
|
229
|
+
"std": 0.0,
|
|
230
|
+
"median": float(series.median()) if n_count > 0 else 0.0,
|
|
231
|
+
"min": float(series.min()) if n_count > 0 else 0.0,
|
|
232
|
+
"max": float(series.max()) if n_count > 0 else 0.0,
|
|
233
|
+
"skewness": 0.0,
|
|
234
|
+
"kurtosis": 0.0,
|
|
235
|
+
"is_non_negative": bool(series.min() >= 0) if n_count > 0 else True,
|
|
236
|
+
"iqr_outliers": 0,
|
|
237
|
+
"iqr_outlier_pct": 0.0,
|
|
238
|
+
"extreme_outliers": 0,
|
|
239
|
+
"z_outliers": 0,
|
|
240
|
+
"z_outlier_pct": 0.0,
|
|
241
|
+
"lower_fence": float(series.min()) if n_count > 0 else 0.0,
|
|
242
|
+
"upper_fence": float(series.max()) if n_count > 0 else 0.0,
|
|
243
|
+
}
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
mean_val = float(series.mean())
|
|
247
|
+
std_val = float(series.std(ddof=1))
|
|
248
|
+
median_val = float(series.median())
|
|
249
|
+
min_val = float(series.min())
|
|
250
|
+
max_val = float(series.max())
|
|
251
|
+
|
|
252
|
+
# Skewness & Kurtosis
|
|
253
|
+
skew_val = float(stats.skew(series, bias=False))
|
|
254
|
+
kurt_val = float(stats.kurtosis(series, bias=False))
|
|
255
|
+
|
|
256
|
+
# IQR Outliers
|
|
257
|
+
q1 = float(np.percentile(series, 25))
|
|
258
|
+
q3 = float(np.percentile(series, 75))
|
|
259
|
+
iqr = q3 - q1
|
|
260
|
+
|
|
261
|
+
lower_fence = q1 - 1.5 * iqr
|
|
262
|
+
upper_fence = q3 + 1.5 * iqr
|
|
263
|
+
extreme_lower = q1 - 3.0 * iqr
|
|
264
|
+
extreme_upper = q3 + 3.0 * iqr
|
|
265
|
+
|
|
266
|
+
iqr_outliers = int(((series < lower_fence) | (series > upper_fence)).sum())
|
|
267
|
+
iqr_outlier_pct = round((iqr_outliers / n_count) * 100, 2)
|
|
268
|
+
|
|
269
|
+
extreme_outliers = int(((series < extreme_lower) | (series > extreme_upper)).sum())
|
|
270
|
+
|
|
271
|
+
# Z-score Outliers
|
|
272
|
+
if std_val > 0:
|
|
273
|
+
z_scores = np.abs((series - mean_val) / std_val)
|
|
274
|
+
z_outliers = int((z_scores > 3.0).sum())
|
|
275
|
+
z_outlier_pct = round((z_outliers / n_count) * 100, 2)
|
|
276
|
+
else:
|
|
277
|
+
z_outliers = 0
|
|
278
|
+
z_outlier_pct = 0.0
|
|
279
|
+
|
|
280
|
+
dist_report[col] = {
|
|
281
|
+
"count": n_count,
|
|
282
|
+
"mean": round(mean_val, 4),
|
|
283
|
+
"std": round(std_val, 4),
|
|
284
|
+
"median": round(median_val, 4),
|
|
285
|
+
"min": round(min_val, 4),
|
|
286
|
+
"max": round(max_val, 4),
|
|
287
|
+
"skewness": round(skew_val, 3),
|
|
288
|
+
"kurtosis": round(kurt_val, 3),
|
|
289
|
+
"is_non_negative": bool(min_val >= 0),
|
|
290
|
+
"q1": round(q1, 4),
|
|
291
|
+
"q3": round(q3, 4),
|
|
292
|
+
"iqr": round(iqr, 4),
|
|
293
|
+
"lower_fence": round(lower_fence, 4),
|
|
294
|
+
"upper_fence": round(upper_fence, 4),
|
|
295
|
+
"iqr_outliers": iqr_outliers,
|
|
296
|
+
"iqr_outlier_pct": iqr_outlier_pct,
|
|
297
|
+
"extreme_outliers": extreme_outliers,
|
|
298
|
+
"z_outliers": z_outliers,
|
|
299
|
+
"z_outlier_pct": z_outlier_pct,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return dist_report
|
|
303
|
+
|
|
304
|
+
# -------------------------------------------------------------------------
|
|
305
|
+
# 5. Categorical Processing & Cardinality
|
|
306
|
+
# -------------------------------------------------------------------------
|
|
307
|
+
def scan_categoricals(self) -> Dict[str, Dict[str, Any]]:
|
|
308
|
+
"""
|
|
309
|
+
Analyze cardinality and distribution for categorical, boolean, and
|
|
310
|
+
object features.
|
|
311
|
+
"""
|
|
312
|
+
cat_cols = self.df.select_dtypes(include=["object", "string", "category", "bool"]).columns.tolist()
|
|
313
|
+
cat_report: Dict[str, Dict[str, Any]] = {}
|
|
314
|
+
n_rows = len(self.df)
|
|
315
|
+
|
|
316
|
+
for col in cat_cols:
|
|
317
|
+
series = self.df[col]
|
|
318
|
+
non_null = series.dropna()
|
|
319
|
+
n_non_null = len(non_null)
|
|
320
|
+
nunique = int(non_null.nunique())
|
|
321
|
+
|
|
322
|
+
# Top value and frequency
|
|
323
|
+
vc = non_null.value_counts()
|
|
324
|
+
top_val = str(vc.index[0]) if not vc.empty else None
|
|
325
|
+
top_pct = round((float(vc.iloc[0]) / n_non_null * 100) if n_non_null > 0 else 0.0, 2)
|
|
326
|
+
|
|
327
|
+
# Sample values up to 8
|
|
328
|
+
sample_values = [str(x) for x in vc.index[:8]]
|
|
329
|
+
|
|
330
|
+
cat_report[col] = {
|
|
331
|
+
"cardinality": nunique,
|
|
332
|
+
"cardinality_pct": round((nunique / n_rows * 100) if n_rows > 0 else 0.0, 2),
|
|
333
|
+
"top_value": top_val,
|
|
334
|
+
"top_value_pct": top_pct,
|
|
335
|
+
"sample_values": sample_values,
|
|
336
|
+
"is_binary": nunique == 2,
|
|
337
|
+
"is_low_cardinality": 2 <= nunique <= 10,
|
|
338
|
+
"is_high_cardinality": nunique > 10,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return cat_report
|
|
342
|
+
|
|
343
|
+
# -------------------------------------------------------------------------
|
|
344
|
+
# 6. Target Variable Analysis
|
|
345
|
+
# -------------------------------------------------------------------------
|
|
346
|
+
def scan_target(self) -> Optional[Dict[str, Any]]:
|
|
347
|
+
"""
|
|
348
|
+
Inspect the target variable to determine problem type (Regression vs
|
|
349
|
+
Classification) and class balance.
|
|
350
|
+
"""
|
|
351
|
+
target_col = self.target
|
|
352
|
+
if not target_col:
|
|
353
|
+
# Auto-detect target candidates
|
|
354
|
+
candidates = ["target", "label", "class", "churn", "price", "salary",
|
|
355
|
+
"default", "income", "outcome", "survived", "status", "y"]
|
|
356
|
+
for cand in candidates:
|
|
357
|
+
for col in self.df.columns:
|
|
358
|
+
if col.lower() == cand:
|
|
359
|
+
target_col = col
|
|
360
|
+
break
|
|
361
|
+
if target_col:
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
if not target_col or target_col not in self.df.columns:
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
series = self.df[target_col].dropna()
|
|
368
|
+
n_total = len(series)
|
|
369
|
+
nunique = int(series.nunique())
|
|
370
|
+
is_numeric = pd.api.types.is_numeric_dtype(series)
|
|
371
|
+
|
|
372
|
+
if is_numeric and nunique > 15:
|
|
373
|
+
# Regression problem
|
|
374
|
+
return {
|
|
375
|
+
"target_column": target_col,
|
|
376
|
+
"problem_type": "regression",
|
|
377
|
+
"is_continuous": True,
|
|
378
|
+
"n_samples": n_total,
|
|
379
|
+
"mean": round(float(series.mean()), 4),
|
|
380
|
+
"std": round(float(series.std()), 4),
|
|
381
|
+
"skewness": round(float(stats.skew(series)), 3),
|
|
382
|
+
"min": round(float(series.min()), 4),
|
|
383
|
+
"max": round(float(series.max()), 4),
|
|
384
|
+
}
|
|
385
|
+
else:
|
|
386
|
+
# Classification problem
|
|
387
|
+
vc = series.value_counts()
|
|
388
|
+
class_distribution: Dict[str, Dict[str, Any]] = {}
|
|
389
|
+
for cls_val, cnt in vc.items():
|
|
390
|
+
pct = round(float(cnt) / n_total * 100, 2)
|
|
391
|
+
class_distribution[str(cls_val)] = {"count": int(cnt), "percentage": pct}
|
|
392
|
+
|
|
393
|
+
min_cnt = vc.min()
|
|
394
|
+
max_cnt = vc.max()
|
|
395
|
+
minority_pct = round((min_cnt / n_total) * 100, 2)
|
|
396
|
+
imbalance_ratio = round(max_cnt / min_cnt, 2) if min_cnt > 0 else float("inf")
|
|
397
|
+
is_imbalanced = bool(minority_pct < 20.0 or imbalance_ratio >= 3.0)
|
|
398
|
+
classification_type = "binary" if nunique == 2 else "multiclass"
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
"target_column": target_col,
|
|
402
|
+
"problem_type": "classification",
|
|
403
|
+
"classification_type": classification_type,
|
|
404
|
+
"n_classes": nunique,
|
|
405
|
+
"class_distribution": class_distribution,
|
|
406
|
+
"minority_percentage": minority_pct,
|
|
407
|
+
"imbalance_ratio": imbalance_ratio,
|
|
408
|
+
"is_imbalanced": is_imbalanced,
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
# -------------------------------------------------------------------------
|
|
412
|
+
# 7. Hypothesis Testing Engine
|
|
413
|
+
# -------------------------------------------------------------------------
|
|
414
|
+
def run_hypothesis_tests(self) -> List[Dict[str, Any]]:
|
|
415
|
+
"""
|
|
416
|
+
Run relevant statistical hypothesis tests:
|
|
417
|
+
- 2 numeric: Pearson & Spearman correlation
|
|
418
|
+
- 2 categorical: Chi-Square Test of Independence
|
|
419
|
+
- 1 numeric + 1 binary categorical: Two-sample t-test / Mann-Whitney U test
|
|
420
|
+
- 1 numeric + 1 multi-class categorical: One-way ANOVA / Kruskal-Wallis test
|
|
421
|
+
|
|
422
|
+
Prioritizes Feature vs Target relationships if target is provided.
|
|
423
|
+
"""
|
|
424
|
+
tests: List[Dict[str, Any]] = []
|
|
425
|
+
target_info = self.scan_target()
|
|
426
|
+
target_col = target_info["target_column"] if target_info else None
|
|
427
|
+
# Filter out metadata/date/id/constant columns from hypothesis tests
|
|
428
|
+
dt_info = self.scan_data_types()
|
|
429
|
+
excluded_test_cols = set(
|
|
430
|
+
list(self.id_cols)
|
|
431
|
+
+ list(dt_info.get("potential_ids", []))
|
|
432
|
+
+ list(dt_info.get("potential_dates", []))
|
|
433
|
+
+ list(dt_info.get("constant_columns", []))
|
|
434
|
+
+ list(dt_info.get("high_card_objects", []))
|
|
435
|
+
)
|
|
436
|
+
num_cols = [c for c in self.df.select_dtypes(include=[np.number]).columns if c not in excluded_test_cols]
|
|
437
|
+
cat_cols = [
|
|
438
|
+
c for c in self.df.select_dtypes(include=["object", "string", "category", "bool"]).columns
|
|
439
|
+
if c not in excluded_test_cols and 2 <= self.df[c].nunique() <= 20
|
|
440
|
+
]
|
|
441
|
+
|
|
442
|
+
if target_col:
|
|
443
|
+
# Target-centric testing: test each feature against target
|
|
444
|
+
is_target_numeric = target_info.get("problem_type") == "regression"
|
|
445
|
+
t_series = self.df[target_col]
|
|
446
|
+
|
|
447
|
+
# 1. Target vs Numeric features
|
|
448
|
+
for col in num_cols:
|
|
449
|
+
if col == target_col:
|
|
450
|
+
continue
|
|
451
|
+
pair_df = self.df[[col, target_col]].dropna()
|
|
452
|
+
if len(pair_df) < 5 or pair_df[col].nunique() <= 1:
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
if is_target_numeric:
|
|
456
|
+
# 2 numeric columns: Pearson & Spearman
|
|
457
|
+
r_val, p_val = stats.pearsonr(pair_df[col], pair_df[target_col])
|
|
458
|
+
rho_val, rho_pval = stats.spearmanr(pair_df[col], pair_df[target_col])
|
|
459
|
+
tests.append({
|
|
460
|
+
"feature_1": col,
|
|
461
|
+
"feature_2": target_col,
|
|
462
|
+
"test_type": "Pearson & Spearman Correlation",
|
|
463
|
+
"h0": f"There is no linear/monotonic correlation between {col} and {target_col}.",
|
|
464
|
+
"h1": f"There is a statistically significant correlation between {col} and {target_col}.",
|
|
465
|
+
"statistic": round(float(r_val), 4),
|
|
466
|
+
"p_value": float(p_val),
|
|
467
|
+
"secondary_statistic": round(float(rho_val), 4),
|
|
468
|
+
"secondary_p_value": float(rho_pval),
|
|
469
|
+
"is_significant": bool(p_val < 0.05 or rho_pval < 0.05),
|
|
470
|
+
"conclusion": (
|
|
471
|
+
f"Significant association (r={r_val:.3f}, p={p_val:.2e})"
|
|
472
|
+
if p_val < 0.05 else f"No significant linear association (p={p_val:.3f})"
|
|
473
|
+
),
|
|
474
|
+
})
|
|
475
|
+
else:
|
|
476
|
+
# Numeric feature vs Categorical target
|
|
477
|
+
classes = pair_df[target_col].unique()
|
|
478
|
+
if len(classes) == 2:
|
|
479
|
+
g1 = pair_df[pair_df[target_col] == classes[0]][col]
|
|
480
|
+
g2 = pair_df[pair_df[target_col] == classes[1]][col]
|
|
481
|
+
if len(g1) >= 2 and len(g2) >= 2:
|
|
482
|
+
t_stat, t_pval = stats.ttest_ind(g1, g2, equal_var=False)
|
|
483
|
+
u_stat, u_pval = stats.mannwhitneyu(g1, g2, alternative="two-sided")
|
|
484
|
+
tests.append({
|
|
485
|
+
"feature_1": col,
|
|
486
|
+
"feature_2": target_col,
|
|
487
|
+
"test_type": "Two-sample Welch's t-test / Mann-Whitney U",
|
|
488
|
+
"h0": f"Mean/distribution of {col} is equal across {target_col} classes ({classes[0]} vs {classes[1]}).",
|
|
489
|
+
"h1": f"Mean/distribution of {col} differs significantly across {target_col} classes.",
|
|
490
|
+
"statistic": round(float(t_stat), 4),
|
|
491
|
+
"p_value": float(t_pval),
|
|
492
|
+
"secondary_statistic": round(float(u_stat), 4),
|
|
493
|
+
"secondary_p_value": float(u_pval),
|
|
494
|
+
"is_significant": bool(t_pval < 0.05 or u_pval < 0.05),
|
|
495
|
+
"conclusion": (
|
|
496
|
+
f"Distributions differ significantly across target classes (t={t_stat:.3f}, p={t_pval:.2e})"
|
|
497
|
+
if t_pval < 0.05 else f"No significant difference across target classes (p={t_pval:.3f})"
|
|
498
|
+
),
|
|
499
|
+
})
|
|
500
|
+
elif len(classes) > 2:
|
|
501
|
+
groups = [pair_df[pair_df[target_col] == c][col] for c in classes if len(pair_df[pair_df[target_col] == c]) >= 2]
|
|
502
|
+
if len(groups) >= 2:
|
|
503
|
+
f_stat, f_pval = stats.f_oneway(*groups)
|
|
504
|
+
kw_stat, kw_pval = stats.kruskal(*groups)
|
|
505
|
+
tests.append({
|
|
506
|
+
"feature_1": col,
|
|
507
|
+
"feature_2": target_col,
|
|
508
|
+
"test_type": "One-Way ANOVA / Kruskal-Wallis",
|
|
509
|
+
"h0": f"Mean of {col} is identical across all levels of {target_col}.",
|
|
510
|
+
"h1": f"At least one {target_col} group mean of {col} is different.",
|
|
511
|
+
"statistic": round(float(f_stat), 4),
|
|
512
|
+
"p_value": float(f_pval),
|
|
513
|
+
"secondary_statistic": round(float(kw_stat), 4),
|
|
514
|
+
"secondary_p_value": float(kw_pval),
|
|
515
|
+
"is_significant": bool(f_pval < 0.05 or kw_pval < 0.05),
|
|
516
|
+
"conclusion": (
|
|
517
|
+
f"Significant difference between group means (F={f_stat:.3f}, p={f_pval:.2e})"
|
|
518
|
+
if f_pval < 0.05 else f"No significant difference across groups (p={f_pval:.3f})"
|
|
519
|
+
),
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
# 2. Target vs Categorical features
|
|
523
|
+
for col in cat_cols:
|
|
524
|
+
if col == target_col:
|
|
525
|
+
continue
|
|
526
|
+
pair_df = self.df[[col, target_col]].dropna()
|
|
527
|
+
if len(pair_df) < 10 or pair_df[col].nunique() <= 1:
|
|
528
|
+
continue
|
|
529
|
+
|
|
530
|
+
if not is_target_numeric:
|
|
531
|
+
# 2 categorical columns: Chi-square test of independence
|
|
532
|
+
cont_tab = pd.crosstab(pair_df[col], pair_df[target_col])
|
|
533
|
+
if cont_tab.shape[0] > 1 and cont_tab.shape[1] > 1:
|
|
534
|
+
chi2, p_val, dof, _ = stats.chi2_contingency(cont_tab)
|
|
535
|
+
# Cramer's V
|
|
536
|
+
n_obs = cont_tab.sum().sum()
|
|
537
|
+
min_dim = min(cont_tab.shape) - 1
|
|
538
|
+
cramer_v = float(np.sqrt(chi2 / (n_obs * min_dim))) if (n_obs * min_dim) > 0 else 0.0
|
|
539
|
+
|
|
540
|
+
tests.append({
|
|
541
|
+
"feature_1": col,
|
|
542
|
+
"feature_2": target_col,
|
|
543
|
+
"test_type": "Chi-Square Test of Independence",
|
|
544
|
+
"h0": f"{col} and {target_col} are independent.",
|
|
545
|
+
"h1": f"{col} and {target_col} are significantly dependent/associated.",
|
|
546
|
+
"statistic": round(float(chi2), 4),
|
|
547
|
+
"p_value": float(p_val),
|
|
548
|
+
"secondary_statistic": round(cramer_v, 4),
|
|
549
|
+
"secondary_name": "Cramer's V",
|
|
550
|
+
"is_significant": bool(p_val < 0.05),
|
|
551
|
+
"conclusion": (
|
|
552
|
+
f"Significant association (Chi2={chi2:.2f}, p={p_val:.2e}, Cramer's V={cramer_v:.3f})"
|
|
553
|
+
if p_val < 0.05 else f"Independent/no significant association (p={p_val:.3f})"
|
|
554
|
+
),
|
|
555
|
+
})
|
|
556
|
+
else:
|
|
557
|
+
# Categorical feature vs Numeric target (ANOVA / t-test)
|
|
558
|
+
classes = pair_df[col].unique()
|
|
559
|
+
if len(classes) == 2:
|
|
560
|
+
g1 = pair_df[pair_df[col] == classes[0]][target_col]
|
|
561
|
+
g2 = pair_df[pair_df[col] == classes[1]][target_col]
|
|
562
|
+
if len(g1) >= 2 and len(g2) >= 2:
|
|
563
|
+
t_stat, t_pval = stats.ttest_ind(g1, g2, equal_var=False)
|
|
564
|
+
tests.append({
|
|
565
|
+
"feature_1": col,
|
|
566
|
+
"feature_2": target_col,
|
|
567
|
+
"test_type": "Two-sample Welch's t-test",
|
|
568
|
+
"h0": f"Mean target {target_col} is equal across {col} categories.",
|
|
569
|
+
"h1": f"Mean target {target_col} differs between {col} categories.",
|
|
570
|
+
"statistic": round(float(t_stat), 4),
|
|
571
|
+
"p_value": float(t_pval),
|
|
572
|
+
"is_significant": bool(t_pval < 0.05),
|
|
573
|
+
"conclusion": (
|
|
574
|
+
f"Significant difference in {target_col} (t={t_stat:.3f}, p={t_pval:.2e})"
|
|
575
|
+
if t_pval < 0.05 else f"No significant difference in {target_col} (p={t_pval:.3f})"
|
|
576
|
+
),
|
|
577
|
+
})
|
|
578
|
+
elif 2 < len(classes) <= 10:
|
|
579
|
+
groups = [pair_df[pair_df[col] == c][target_col] for c in classes if len(pair_df[pair_df[col] == c]) >= 2]
|
|
580
|
+
if len(groups) >= 2:
|
|
581
|
+
f_stat, f_pval = stats.f_oneway(*groups)
|
|
582
|
+
tests.append({
|
|
583
|
+
"feature_1": col,
|
|
584
|
+
"feature_2": target_col,
|
|
585
|
+
"test_type": "One-Way ANOVA",
|
|
586
|
+
"h0": f"Mean {target_col} is equal across all {col} groups.",
|
|
587
|
+
"h1": f"Mean {target_col} differs across {col} groups.",
|
|
588
|
+
"statistic": round(float(f_stat), 4),
|
|
589
|
+
"p_value": float(f_pval),
|
|
590
|
+
"is_significant": bool(f_pval < 0.05),
|
|
591
|
+
"conclusion": (
|
|
592
|
+
f"Significant difference across {col} groups (F={f_stat:.3f}, p={f_pval:.2e})"
|
|
593
|
+
if f_pval < 0.05 else f"No significant difference (p={f_pval:.3f})"
|
|
594
|
+
),
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
else:
|
|
598
|
+
# No target specified: test top correlated numeric pairs
|
|
599
|
+
if len(num_cols) >= 2:
|
|
600
|
+
corr_matrix = self.df[num_cols].corr()
|
|
601
|
+
checked_pairs = set()
|
|
602
|
+
# Find top correlated pairs
|
|
603
|
+
pairs: List[Tuple[str, str, float]] = []
|
|
604
|
+
for i in range(len(num_cols)):
|
|
605
|
+
for j in range(i + 1, len(num_cols)):
|
|
606
|
+
c1, c2 = num_cols[i], num_cols[j]
|
|
607
|
+
val = corr_matrix.loc[c1, c2]
|
|
608
|
+
if not np.isnan(val):
|
|
609
|
+
pairs.append((c1, c2, abs(val)))
|
|
610
|
+
|
|
611
|
+
pairs.sort(key=lambda x: x[2], reverse=True)
|
|
612
|
+
# Take top 5 pairs
|
|
613
|
+
for c1, c2, _ in pairs[:5]:
|
|
614
|
+
pair_df = self.df[[c1, c2]].dropna()
|
|
615
|
+
if len(pair_df) >= 5:
|
|
616
|
+
r_val, p_val = stats.pearsonr(pair_df[c1], pair_df[c2])
|
|
617
|
+
tests.append({
|
|
618
|
+
"feature_1": c1,
|
|
619
|
+
"feature_2": c2,
|
|
620
|
+
"test_type": "Pearson Correlation",
|
|
621
|
+
"h0": f"No linear correlation between {c1} and {c2}.",
|
|
622
|
+
"h1": f"Significant linear correlation between {c1} and {c2}.",
|
|
623
|
+
"statistic": round(float(r_val), 4),
|
|
624
|
+
"p_value": float(p_val),
|
|
625
|
+
"is_significant": bool(p_val < 0.05),
|
|
626
|
+
"conclusion": (
|
|
627
|
+
f"Significant correlation (r={r_val:.3f}, p={p_val:.2e})"
|
|
628
|
+
if p_val < 0.05 else f"No significant correlation (p={p_val:.3f})"
|
|
629
|
+
),
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
return tests
|
|
633
|
+
|
|
634
|
+
# -------------------------------------------------------------------------
|
|
635
|
+
# 8. Complete Scan Aggregation
|
|
636
|
+
# -------------------------------------------------------------------------
|
|
637
|
+
def scan(self) -> Dict[str, Any]:
|
|
638
|
+
"""Execute all scanners and return a unified diagnostic dictionary."""
|
|
639
|
+
overview = self.scan_overview()
|
|
640
|
+
missing = self.scan_missing_values()
|
|
641
|
+
dtypes = self.scan_data_types()
|
|
642
|
+
distributions = self.scan_distributions()
|
|
643
|
+
categoricals = self.scan_categoricals()
|
|
644
|
+
target_info = self.scan_target()
|
|
645
|
+
hypothesis_tests = self.run_hypothesis_tests()
|
|
646
|
+
|
|
647
|
+
return {
|
|
648
|
+
"overview": overview,
|
|
649
|
+
"missing_values": missing,
|
|
650
|
+
"data_types": dtypes,
|
|
651
|
+
"distributions": distributions,
|
|
652
|
+
"categoricals": categoricals,
|
|
653
|
+
"target": target_info,
|
|
654
|
+
"hypothesis_tests": hypothesis_tests,
|
|
655
|
+
}
|