""" DataMind AI — EDA Pipeline Module Comprehensive automated Exploratory Data Analysis. """ import pandas as pd import numpy as np from typing import Dict, Any, List, Tuple def _detect_date_columns(df: pd.DataFrame) -> List[str]: """Detect columns that can be parsed as dates.""" date_cols = [] for col in df.columns: if pd.api.types.is_datetime64_any_dtype(df[col]): date_cols.append(col) continue if df[col].dtype == object: sample = df[col].dropna().head(20) if len(sample) == 0: continue try: parsed = pd.to_datetime(sample, errors='coerce') if parsed.notna().sum() >= len(sample) * 0.8: date_cols.append(col) except (ValueError, TypeError): continue return date_cols def _detect_numeric_string_columns(df: pd.DataFrame) -> List[str]: """Detect object columns that should be numeric.""" numeric_str_cols = [] for col in df.columns: if df[col].dtype == object: sample = df[col].dropna().head(30) if len(sample) == 0: continue try: converted = pd.to_numeric(sample, errors='coerce') if converted.notna().sum() >= len(sample) * 0.8: numeric_str_cols.append(col) except (ValueError, TypeError): continue return numeric_str_cols def run_full_eda(df: pd.DataFrame) -> Dict[str, Any]: """ Run a thorough EDA pipeline on the given DataFrame. Returns a comprehensive results dictionary. """ results = {} df = df.copy() # ========================================================================= # 1. SHAPE & OVERVIEW # ========================================================================= dtypes_dict = df.dtypes.astype(str).to_dict() dtype_counts = {} for dtype_str in dtypes_dict.values(): key = str(dtype_str) dtype_counts[key] = dtype_counts.get(key, 0) + 1 results["shape"] = { "rows": int(df.shape[0]), "columns": int(df.shape[1]), "column_names": list(df.columns), "dtypes": dtypes_dict, "dtype_counts": dtype_counts, "memory_usage_mb": round(df.memory_usage(deep=True).sum() / (1024 * 1024), 2) } # ========================================================================= # 2. DATA TYPE FIXING (before missing value analysis) # ========================================================================= type_fixes = [] # Detect and convert date columns date_cols = _detect_date_columns(df) for col in date_cols: if not pd.api.types.is_datetime64_any_dtype(df[col]): try: df[col] = pd.to_datetime(df[col], errors='coerce') type_fixes.append({"column": col, "from": "object", "to": "datetime64"}) except Exception: pass # Detect and convert numeric string columns numeric_str_cols = _detect_numeric_string_columns(df) for col in numeric_str_cols: if col not in date_cols: try: df[col] = pd.to_numeric(df[col], errors='coerce') type_fixes.append({"column": col, "from": "object", "to": "float64"}) except Exception: pass results["type_fixes"] = type_fixes # ========================================================================= # 3. MISSING VALUES — BEFORE # ========================================================================= missing_before = df.isnull().sum().to_dict() missing_before = {k: int(v) for k, v in missing_before.items() if v > 0} total_missing_before = int(df.isnull().sum().sum()) # Fill strategies fill_strategies = {} for col in df.columns: if df[col].isnull().sum() == 0: continue if pd.api.types.is_datetime64_any_dtype(df[col]): df[col] = df[col].ffill() if df[col].isnull().sum() > 0: df[col] = df[col].bfill() fill_strategies[col] = "forward-fill (then back-fill)" elif pd.api.types.is_numeric_dtype(df[col]): median_val = df[col].median() df[col] = df[col].fillna(median_val) fill_strategies[col] = f"median ({median_val:.2f})" else: mode_vals = df[col].mode() if len(mode_vals) > 0: df[col] = df[col].fillna(mode_vals[0]) fill_strategies[col] = f"mode ('{mode_vals[0]}')" else: df[col] = df[col].fillna("Unknown") fill_strategies[col] = "fallback ('Unknown')" missing_after = df.isnull().sum().to_dict() missing_after = {k: int(v) for k, v in missing_after.items() if v > 0} total_missing_after = int(df.isnull().sum().sum()) results["missing_values"] = { "before": missing_before, "after": missing_after, "total_before": total_missing_before, "total_after": total_missing_after, "strategies": fill_strategies } # ========================================================================= # 4. DUPLICATES # ========================================================================= dup_count = int(df.duplicated().sum()) if dup_count > 0: df = df.drop_duplicates().reset_index(drop=True) results["duplicates"] = { "found": dup_count, "removed": dup_count, "rows_after": int(len(df)) } # ========================================================================= # 5. CAPITALISATION NORMALISATION # ========================================================================= normalised_cols = [] for col in df.columns: if df[col].dtype == object: try: # Skip free text or high-cardinality columns for performance if df[col].nunique() > 1000: continue before_sample = df[col].dropna().head(5).tolist() # Optimised transformation using categories (instant for millions of rows) cat_col = df[col].astype('category') new_categories = [str(x).strip().title() if pd.notna(x) else x for x in cat_col.cat.categories] cat_col = cat_col.cat.rename_categories(new_categories) df[col] = cat_col.astype(object) after_sample = df[col].dropna().head(5).tolist() if before_sample != after_sample: normalised_cols.append(col) except Exception: pass results["capitalisation"] = { "normalised_columns": normalised_cols, "method": "Title Case" } # ========================================================================= # 6. OUTLIER DETECTION (IQR method) # ========================================================================= numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist() outlier_info = {} for col in numeric_cols: Q1 = df[col].quantile(0.25) Q3 = df[col].quantile(0.75) IQR = Q3 - Q1 lower_bound = Q1 - 1.5 * IQR upper_bound = Q3 + 1.5 * IQR outlier_mask = (df[col] < lower_bound) | (df[col] > upper_bound) outlier_count = int(outlier_mask.sum()) if outlier_count > 0: outlier_info[col] = { "count": outlier_count, "percentage": round(outlier_count / len(df) * 100, 1), "lower_bound": round(float(lower_bound), 2), "upper_bound": round(float(upper_bound), 2), "Q1": round(float(Q1), 2), "Q3": round(float(Q3), 2), "IQR": round(float(IQR), 2) } # Flag but do NOT remove df[f"_{col}_outlier"] = outlier_mask results["outliers"] = outlier_info # ========================================================================= # 7. SUMMARY STATISTICS # ========================================================================= summary_stats = {} for col in numeric_cols: stats = df[col].describe() summary_stats[col] = { "count": int(stats.get("count", 0)), "mean": round(float(stats.get("mean", 0)), 2), "std": round(float(stats.get("std", 0)), 2), "min": round(float(stats.get("min", 0)), 2), "25%": round(float(stats.get("25%", 0)), 2), "median": round(float(df[col].median()), 2), "75%": round(float(stats.get("75%", 0)), 2), "max": round(float(stats.get("max", 0)), 2) } results["summary_stats"] = summary_stats # ========================================================================= # 8. CORRELATION MATRIX # ========================================================================= if len(numeric_cols) >= 2: corr = df[numeric_cols].corr() corr_dict = {} for col in corr.columns: corr_dict[col] = {k: round(float(v), 3) for k, v in corr[col].items()} results["correlation"] = corr_dict else: results["correlation"] = {} # ========================================================================= # Store categorical column info # ========================================================================= cat_cols = df.select_dtypes(include=["object"]).columns.tolist() cat_info = {} for col in cat_cols: value_counts = df[col].value_counts().head(10).to_dict() cat_info[col] = { "unique_count": int(df[col].nunique()), "top_values": {str(k): int(v) for k, v in value_counts.items()} } results["categorical_info"] = cat_info # Store date column info date_cols_final = [c for c in df.columns if pd.api.types.is_datetime64_any_dtype(df[c])] date_info = {} for col in date_cols_final: date_info[col] = { "min": str(df[col].min()), "max": str(df[col].max()), "range_days": int((df[col].max() - df[col].min()).days) if pd.notna(df[col].min()) and pd.notna(df[col].max()) else 0 } results["date_info"] = date_info # Drop outlier flag columns before returning cleaned df outlier_flag_cols = [c for c in df.columns if c.startswith("_") and c.endswith("_outlier")] df_clean = df.drop(columns=outlier_flag_cols, errors='ignore') return results, df_clean