"""Data loading, profiling, and preprocessing utilities.""" from __future__ import annotations import os import re import tempfile import numpy as np import pandas as pd # Temp workspace that persists for the app session TEMP_DIR = os.path.join(tempfile.gettempdir(), "ml_analysis_space") os.makedirs(TEMP_DIR, exist_ok=True) CLEANED_PATH = os.path.join(TEMP_DIR, "cleaned_data.csv") SUPPORTED_EXTENSIONS = [".csv", ".tsv", ".xlsx", ".xls", ".json", ".parquet", ".txt"] # ------------------------------------------------------------------ load ---- ENCODINGS = ("utf-8", "utf-8-sig", "cp1252", "latin-1") def _read_text_table(file_path: str, sep: str | None = None) -> pd.DataFrame: """Read a delimited text file, trying several encodings. With sep=None pandas sniffs the delimiter (handles ; | tab exports). Malformed rows are skipped instead of failing the whole file. """ last_err: Exception | None = None for encoding in ENCODINGS: try: return pd.read_csv( file_path, sep=sep, engine="python", encoding=encoding, on_bad_lines="skip", ) except UnicodeDecodeError as e: last_err = e raise ValueError(f"Could not decode file with any of {ENCODINGS}: {last_err}") def _tidy_columns(df: pd.DataFrame) -> pd.DataFrame: """Fix messy headers: strip whitespace, name blank/auto columns, dedupe.""" df = df.copy() names = [] for i, col in enumerate(df.columns): name = re.sub(r"\s+", " ", str(col)).strip() if not name or name.lower().startswith("unnamed:"): name = f"column_{i}" names.append(name) seen: dict[str, int] = {} unique = [] for name in names: if name in seen: seen[name] += 1 name = f"{name}_{seen[name]}" else: seen[name] = 0 unique.append(name) df.columns = unique return df def load_data(file_path: str) -> pd.DataFrame: """Load a dataset from CSV, TSV, Excel, JSON, or Parquet.""" ext = os.path.splitext(file_path)[1].lower() if ext in (".csv", ".txt"): df = _read_text_table(file_path) elif ext == ".tsv": df = _read_text_table(file_path, sep="\t") elif ext in (".xlsx", ".xls"): df = pd.read_excel(file_path) elif ext == ".json": df = pd.read_json(file_path) elif ext == ".parquet": df = pd.read_parquet(file_path) else: raise ValueError( f"Unsupported file type '{ext}'. Supported: {', '.join(SUPPORTED_EXTENSIONS)}" ) if df.shape[0] == 0 or df.shape[1] == 0: raise ValueError("The file was read but contains no data.") return _tidy_columns(df) # --------------------------------------------------------------- profile ---- def profile_data(df: pd.DataFrame) -> dict: """Return a summary profile of the dataset.""" numeric_cols = df.select_dtypes(include=np.number).columns.tolist() categorical_cols = df.select_dtypes(exclude=np.number).columns.tolist() profile = { "n_rows": int(df.shape[0]), "n_cols": int(df.shape[1]), "columns": df.columns.tolist(), "dtypes": {c: str(t) for c, t in df.dtypes.items()}, "numeric_columns": numeric_cols, "categorical_columns": categorical_cols, "missing_counts": df.isna().sum().to_dict(), "missing_total": int(df.isna().sum().sum()), "duplicate_rows": int(df.duplicated().sum()), "memory_kb": round(df.memory_usage(deep=True).sum() / 1024, 1), } return profile def profile_text(profile: dict) -> str: """Human-readable summary of a profile dict.""" lines = [ f"**Rows:** {profile['n_rows']:,} | **Columns:** {profile['n_cols']} " f"| **Memory:** {profile['memory_kb']} KB", f"**Numeric columns ({len(profile['numeric_columns'])}):** " + (", ".join(profile["numeric_columns"]) or "none"), f"**Categorical columns ({len(profile['categorical_columns'])}):** " + (", ".join(profile["categorical_columns"]) or "none"), f"**Missing values:** {profile['missing_total']:,} | " f"**Duplicate rows:** {profile['duplicate_rows']:,}", ] missing = {k: v for k, v in profile["missing_counts"].items() if v > 0} if missing: lines.append( "**Columns with missing values:** " + ", ".join(f"{k} ({v})" for k, v in missing.items()) ) return "\n\n".join(lines) # ------------------------------------------------------------ preprocess ---- _NUMERIC_JUNK = re.compile(r"[\s$€£,%]") _MISSING_TOKENS = {"", "nan", "none", "null", "na", "n/a", "-", "?", "missing"} def _normalize_missing_tokens(df: pd.DataFrame) -> tuple[pd.DataFrame, int]: """Turn placeholder strings like 'N/A', '?', '-' into real NaN.""" before = int(df.isna().sum().sum()) for col in df.select_dtypes(exclude=np.number).columns: mask = df[col].astype(str).str.strip().str.lower().isin(_MISSING_TOKENS) if mask.any(): df.loc[mask, col] = np.nan return df, int(df.isna().sum().sum()) - before def _coerce_numeric_strings(df: pd.DataFrame) -> tuple[pd.DataFrame, list[str]]: """Convert text columns that are really numbers ('$1,234', '45%') to numeric.""" converted = [] for col in df.select_dtypes(include="object").columns: stripped = df[col].astype(str).str.replace(_NUMERIC_JUNK, "", regex=True) num = pd.to_numeric(stripped, errors="coerce") notna = df[col].notna() if notna.any() and num[notna].notna().mean() >= 0.8: df[col] = num converted.append(col) return df, converted def _clip_outliers(df: pd.DataFrame, target_column: str | None) -> tuple[pd.DataFrame, list[str]]: """Clip numeric values outside 1.5*IQR to the whisker bounds.""" clipped = [] for col in df.select_dtypes(include=np.number).columns: if col == target_column: continue q1, q3 = df[col].quantile([0.25, 0.75]) iqr = q3 - q1 if not iqr: continue lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr n = int(((df[col] < lo) | (df[col] > hi)).sum()) if n: df[col] = df[col].clip(lo, hi) clipped.append(f"{col} ({n})") return df, clipped def preprocess_data( df: pd.DataFrame, missing_strategy: str = "Impute (mean/mode)", drop_duplicates: bool = True, encode_categoricals: bool = True, scaling: str = "None", clip_outliers: bool = False, target_column: str | None = None, ) -> tuple[pd.DataFrame, list[str]]: """Clean the dataset and return (cleaned_df, list of steps applied).""" steps = [] df = df.copy() # Text tidy-up: strip whitespace in string cells, normalize missing tokens for col in df.select_dtypes(include="object").columns: df[col] = df[col].str.strip() df, n_tokens = _normalize_missing_tokens(df) if n_tokens: steps.append( f"Converted {n_tokens} placeholder values ('N/A', '?', '-', 'null'...) to missing" ) # Text columns that are actually numeric ('$1,234', '45%', '1 000') df, converted = _coerce_numeric_strings(df) if converted: steps.append(f"Converted numeric-looking text columns to numbers: {', '.join(converted)}") # Drop columns that are entirely empty empty_cols = [c for c in df.columns if df[c].isna().all()] if empty_cols: df = df.drop(columns=empty_cols) steps.append(f"Dropped fully-empty columns: {', '.join(empty_cols)}") # Drop constant columns — they carry no signal const_cols = [ c for c in df.columns if c != target_column and df[c].nunique(dropna=False) <= 1 ] if const_cols: df = df.drop(columns=const_cols) steps.append(f"Dropped constant columns: {', '.join(const_cols)}") # Duplicates if drop_duplicates: n = int(df.duplicated().sum()) if n: df = df.drop_duplicates().reset_index(drop=True) steps.append(f"Removed {n} duplicate rows") # Missing values n_missing = int(df.isna().sum().sum()) if n_missing: if missing_strategy.startswith("Drop"): before = len(df) df = df.dropna().reset_index(drop=True) steps.append(f"Dropped {before - len(df)} rows with missing values") else: use_median = "median" in missing_strategy.lower() for col in df.columns: if df[col].isna().any(): if pd.api.types.is_numeric_dtype(df[col]): fill = df[col].median() if use_median else df[col].mean() df[col] = df[col].fillna(fill) else: mode = df[col].mode() df[col] = df[col].fillna(mode.iloc[0] if len(mode) else "unknown") centre = "median" if use_median else "mean" steps.append( f"Imputed {n_missing} missing values ({centre} for numeric, mode for categorical)" ) # Outliers if clip_outliers: df, clipped = _clip_outliers(df, target_column) if clipped: steps.append(f"Clipped outliers beyond 1.5*IQR: {', '.join(clipped)}") # Encode categoricals (except the target, which models handle separately) if encode_categoricals: cat_cols = [ c for c in df.select_dtypes(exclude=np.number).columns if c != target_column ] low_card = [c for c in cat_cols if df[c].nunique() <= 20] high_card = [c for c in cat_cols if df[c].nunique() > 20] if high_card: df = df.drop(columns=high_card) steps.append( f"Dropped high-cardinality text columns (>20 unique): {', '.join(high_card)}" ) if low_card: df = pd.get_dummies(df, columns=low_card, drop_first=True, dtype=int) steps.append(f"One-hot encoded: {', '.join(low_card)}") # Scale numeric features if scaling and scaling != "None": from sklearn.preprocessing import MinMaxScaler, StandardScaler num_cols = [ c for c in df.select_dtypes(include=np.number).columns if c != target_column ] if num_cols: if "Min-Max" in scaling: df[num_cols] = MinMaxScaler().fit_transform(df[num_cols]) steps.append(f"Min-Max scaled {len(num_cols)} numeric columns to [0, 1]") else: df[num_cols] = StandardScaler().fit_transform(df[num_cols]) steps.append(f"Standard-scaled {len(num_cols)} numeric columns (mean 0, std 1)") if not steps: steps.append("Data was already clean — no changes needed") df.to_csv(CLEANED_PATH, index=False) steps.append(f"Saved cleaned data ({len(df):,} rows x {df.shape[1]} cols) for modeling") return df, steps def generate_preprocessing_code( missing_strategy: str, drop_duplicates: bool, encode_categoricals: bool, scaling: str, clip_outliers: bool, target_column: str | None, ) -> str: """Return equivalent standalone pandas code for the preprocessing performed.""" code = [ "import re", "import numpy as np", "import pandas as pd", "", "df = pd.read_csv('your_data.csv')", "", "# Tidy text cells and turn placeholder strings into real NaN", "MISSING = {'', 'nan', 'none', 'null', 'na', 'n/a', '-', '?', 'missing'}", "for col in df.select_dtypes(include='object').columns:", " df[col] = df[col].str.strip()", " mask = df[col].astype(str).str.strip().str.lower().isin(MISSING)", " df.loc[mask, col] = np.nan", "", "# Convert numeric-looking text columns ('$1,234', '45%') to numbers", "for col in df.select_dtypes(include='object').columns:", " num = pd.to_numeric(df[col].astype(str).str.replace(r'[\\s$€£,%]', '', regex=True),", " errors='coerce')", " if df[col].notna().any() and num[df[col].notna()].notna().mean() >= 0.8:", " df[col] = num", "", "# Drop fully-empty and constant columns", "df = df.dropna(axis=1, how='all')", "df = df.drop(columns=[c for c in df.columns if df[c].nunique(dropna=False) <= 1])", ] if drop_duplicates: code += ["", "# Remove duplicate rows", "df = df.drop_duplicates().reset_index(drop=True)"] if missing_strategy.startswith("Drop"): code += ["", "# Drop rows with missing values", "df = df.dropna().reset_index(drop=True)"] else: centre = "median" if "median" in missing_strategy.lower() else "mean" code += [ "", f"# Impute missing values: {centre} for numeric, mode for categorical", "for col in df.columns:", " if df[col].isna().any():", " if pd.api.types.is_numeric_dtype(df[col]):", f" df[col] = df[col].fillna(df[col].{centre}())", " else:", " df[col] = df[col].fillna(df[col].mode().iloc[0])", ] if clip_outliers: code += [ "", "# Clip numeric outliers beyond 1.5*IQR", f"target = {target_column!r}", "for col in df.select_dtypes(include=np.number).columns:", " if col == target:", " continue", " q1, q3 = df[col].quantile([0.25, 0.75])", " iqr = q3 - q1", " if iqr:", " df[col] = df[col].clip(q1 - 1.5 * iqr, q3 + 1.5 * iqr)", ] if encode_categoricals: code += [ "", "# One-hot encode low-cardinality categoricals (drop high-cardinality text)", f"target = {target_column!r}", "cat_cols = [c for c in df.select_dtypes(exclude=np.number).columns if c != target]", "df = df.drop(columns=[c for c in cat_cols if df[c].nunique() > 20])", "cat_cols = [c for c in df.select_dtypes(exclude=np.number).columns if c != target]", "df = pd.get_dummies(df, columns=cat_cols, drop_first=True, dtype=int)", ] if scaling and scaling != "None": scaler = "MinMaxScaler" if "Min-Max" in scaling else "StandardScaler" code += [ "", f"# Scale numeric features with {scaler}", f"from sklearn.preprocessing import {scaler}", f"num_cols = [c for c in df.select_dtypes(include=np.number).columns if c != {target_column!r}]", f"df[num_cols] = {scaler}().fit_transform(df[num_cols])", ] code += ["", "df.to_csv('cleaned_data.csv', index=False)"] return "\n".join(code)