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.
@@ -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
+ }