| """Cálculo del perfil exploratorio de un DataFrame. |
| |
| Este módulo no sabe nada de Streamlit: recibe DataFrames y devuelve |
| estructuras simples (dicts y DataFrames). Eso permite probarlo sin interfaz |
| y reutilizarlo desde un notebook o un script. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import io |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| |
| |
| SAMPLE_THRESHOLD = 50_000 |
|
|
|
|
| |
| |
| |
|
|
| def load_dataframe(data: bytes, filename: str) -> pd.DataFrame: |
| """Carga CSV (detectando el separador) o Excel a partir de bytes.""" |
| name = filename.lower() |
| if name.endswith((".xlsx", ".xls")): |
| return pd.read_excel(io.BytesIO(data)) |
| |
| |
| return pd.read_csv(io.BytesIO(data), sep=None, engine="python") |
|
|
|
|
| def numeric_columns(df: pd.DataFrame) -> list[str]: |
| return df.select_dtypes(include="number").columns.tolist() |
|
|
|
|
| def categorical_columns(df: pd.DataFrame) -> list[str]: |
| """Todo lo que no es numérico ni fecha se trata como categórico. |
| |
| Detectar por exclusión es deliberado: pandas 3 cambió el dtype de texto |
| de 'object' a 'str' (backend Arrow) y una lista blanca de dtypes se |
| quedaría corta según la versión instalada. |
| """ |
| numeric = set(numeric_columns(df)) |
| return [ |
| col |
| for col in df.columns |
| if col not in numeric and not pd.api.types.is_datetime64_any_dtype(df[col]) |
| ] |
|
|
|
|
| |
| |
| |
|
|
| def overview(df: pd.DataFrame) -> dict: |
| return { |
| "filas": int(len(df)), |
| "columnas": int(df.shape[1]), |
| "celdas": int(df.size), |
| "memoria_mb": round(df.memory_usage(deep=True).sum() / 1024**2, 2), |
| "numericas": len(numeric_columns(df)), |
| "categoricas": len(categorical_columns(df)), |
| "otras": df.shape[1] - len(numeric_columns(df)) - len(categorical_columns(df)), |
| "faltantes_total": int(df.isna().sum().sum()), |
| "faltantes_pct": round(100 * df.isna().sum().sum() / max(df.size, 1), 2), |
| "filas_duplicadas": int(df.duplicated().sum()), |
| "duplicadas_pct": round(100 * df.duplicated().sum() / max(len(df), 1), 2), |
| } |
|
|
|
|
| def column_table(df: pd.DataFrame) -> pd.DataFrame: |
| """Tabla resumen por columna: tipo, faltantes, únicos y valores de ejemplo.""" |
| rows = [] |
| for col in df.columns: |
| series = df[col] |
| sample = series.dropna().unique()[:3] |
| rows.append( |
| { |
| "columna": col, |
| "tipo": str(series.dtype), |
| "no_nulos": int(series.notna().sum()), |
| "faltantes_%": round(100 * series.isna().mean(), 1), |
| "unicos": int(series.nunique()), |
| "ejemplos": ", ".join(str(v)[:25] for v in sample), |
| } |
| ) |
| return pd.DataFrame(rows) |
|
|
|
|
| |
| |
| |
|
|
| def missing_table(df: pd.DataFrame) -> pd.DataFrame: |
| missing = df.isna().sum() |
| missing = missing[missing > 0].sort_values(ascending=False) |
| return pd.DataFrame( |
| { |
| "columna": missing.index, |
| "faltantes": missing.values, |
| "porcentaje": (100 * missing / len(df)).round(1).values, |
| } |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def iqr_outlier_bounds(series: pd.Series) -> tuple[float, float]: |
| q1, q3 = series.quantile(0.25), series.quantile(0.75) |
| iqr = q3 - q1 |
| return q1 - 1.5 * iqr, q3 + 1.5 * iqr |
|
|
|
|
| def numeric_table(df: pd.DataFrame) -> pd.DataFrame: |
| """describe() ampliado con asimetría, curtosis y outliers por IQR.""" |
| rows = [] |
| for col in numeric_columns(df): |
| series = df[col].dropna() |
| if series.empty: |
| continue |
| low, high = iqr_outlier_bounds(series) |
| n_outliers = int(((series < low) | (series > high)).sum()) |
| rows.append( |
| { |
| "columna": col, |
| "media": round(float(series.mean()), 3), |
| "mediana": round(float(series.median()), 3), |
| "desv_tipica": round(float(series.std()), 3) if len(series) > 1 else 0.0, |
| "minimo": round(float(series.min()), 3), |
| "maximo": round(float(series.max()), 3), |
| "asimetria": round(float(series.skew()), 2) if len(series) > 2 else 0.0, |
| "curtosis": round(float(series.kurtosis()), 2) if len(series) > 3 else 0.0, |
| "outliers_iqr": n_outliers, |
| "outliers_%": round(100 * n_outliers / len(series), 1), |
| } |
| ) |
| return pd.DataFrame(rows) |
|
|
|
|
| |
| |
| |
|
|
| def categorical_table(df: pd.DataFrame) -> pd.DataFrame: |
| rows = [] |
| for col in categorical_columns(df): |
| series = df[col].dropna().astype(str) |
| if series.empty: |
| continue |
| counts = series.value_counts() |
| rows.append( |
| { |
| "columna": col, |
| "unicos": int(series.nunique()), |
| "moda": str(counts.index[0])[:40], |
| "frecuencia_moda_%": round(100 * counts.iloc[0] / len(series), 1), |
| "ratio_cardinalidad": round(series.nunique() / len(series), 3), |
| } |
| ) |
| return pd.DataFrame(rows) |
|
|
|
|
| def value_counts_for(df: pd.DataFrame, col: str, top: int = 15) -> pd.DataFrame: |
| counts = df[col].astype(str).value_counts(dropna=False).head(top) |
| return pd.DataFrame({"valor": counts.index, "frecuencia": counts.values}) |
|
|
|
|
| |
| |
| |
|
|
| def correlation_matrix(df: pd.DataFrame, method: str = "pearson") -> pd.DataFrame: |
| numeric = df[numeric_columns(df)] |
| if numeric.shape[1] < 2: |
| return pd.DataFrame() |
| return numeric.corr(method=method).round(3) |
|
|
|
|
| def top_correlations(corr: pd.DataFrame, n: int = 10) -> pd.DataFrame: |
| """Pares de variables más correlacionados (en valor absoluto).""" |
| if corr.empty: |
| return pd.DataFrame() |
| pairs = [] |
| cols = corr.columns |
| for i in range(len(cols)): |
| for j in range(i + 1, len(cols)): |
| value = corr.iloc[i, j] |
| if pd.notna(value): |
| pairs.append({"variable_1": cols[i], "variable_2": cols[j], "correlacion": value}) |
| pairs.sort(key=lambda p: abs(p["correlacion"]), reverse=True) |
| return pd.DataFrame(pairs[:n]) |
|
|
|
|
| |
| |
| |
|
|
| def target_kind(df: pd.DataFrame, target: str) -> str: |
| """'categorico' si el objetivo tiene pocas clases; 'numerico' si es continuo.""" |
| if target in categorical_columns(df) or df[target].nunique() <= 10: |
| return "categorico" |
| return "numerico" |
|
|
|
|
| def class_balance(df: pd.DataFrame, target: str) -> pd.DataFrame: |
| counts = df[target].astype(str).value_counts(dropna=False) |
| return pd.DataFrame( |
| { |
| "clase": counts.index, |
| "n": counts.values, |
| "porcentaje": (100 * counts / counts.sum()).round(1).values, |
| } |
| ) |
|
|
|
|
| def correlations_with_target(df: pd.DataFrame, target: str) -> pd.DataFrame: |
| """Correlación de cada numérica con el objetivo (si el objetivo es numérico |
| o binario codificable).""" |
| numeric = df[numeric_columns(df)] |
| target_series = df[target] |
| if not pd.api.types.is_numeric_dtype(target_series): |
| |
| classes = target_series.dropna().unique() |
| if len(classes) != 2: |
| return pd.DataFrame() |
| target_series = (target_series == classes[0]).astype(float) |
| if target in numeric.columns: |
| numeric = numeric.drop(columns=[target]) |
| if numeric.empty: |
| return pd.DataFrame() |
| corr = numeric.corrwith(target_series).dropna().sort_values(key=abs, ascending=False) |
| return pd.DataFrame({"variable": corr.index, "correlacion_con_objetivo": corr.round(3).values}) |
|
|
|
|
| |
| |
| |
|
|
| def compact_summary(df: pd.DataFrame, alerts: list[dict] | None = None) -> dict: |
| summary = { |
| "vision_general": overview(df), |
| "columnas": column_table(df).to_dict(orient="records"), |
| "numericas": numeric_table(df).to_dict(orient="records"), |
| "categoricas": categorical_table(df).to_dict(orient="records"), |
| "top_correlaciones": top_correlations(correlation_matrix(df), 8).to_dict(orient="records"), |
| } |
| if alerts: |
| summary["alertas"] = [ |
| {"nivel": a["nivel"], "columna": a["columna"], "mensaje": a["mensaje"]} |
| for a in alerts |
| ] |
| return summary |
|
|
|
|
| def sample_for_plots(df: pd.DataFrame) -> pd.DataFrame: |
| """Muestra aleatoria reproducible para no ahogar al navegador con gráficos.""" |
| if len(df) <= SAMPLE_THRESHOLD: |
| return df |
| return df.sample(SAMPLE_THRESHOLD, random_state=42) |
|
|
|
|
| |
| |
| |
|
|
| def numeric_stored_as_text(df: pd.DataFrame) -> list[str]: |
| """Columnas de texto donde casi todo se puede convertir a número. |
| |
| Típico de CSVs con decimales con coma ("3,14") o números con unidades. |
| """ |
| suspects = [] |
| for col in categorical_columns(df): |
| series = df[col].dropna().astype(str) |
| if series.empty or df[col].dtype == bool: |
| continue |
| cleaned = series.str.replace(",", ".", regex=False).str.strip() |
| converted = pd.to_numeric(cleaned, errors="coerce") |
| if converted.notna().mean() >= 0.8 and series.nunique() > 2: |
| suspects.append(col) |
| return suspects |
|
|
|
|
| def dates_stored_as_text(df: pd.DataFrame) -> list[str]: |
| """Columnas de texto donde casi todo parsea como fecha.""" |
| suspects = [] |
| for col in categorical_columns(df): |
| series = df[col].dropna().astype(str) |
| if series.empty or df[col].dtype == bool: |
| continue |
| |
| |
| looks_like = series.str.contains(r"\d{1,4}[-/.]\d{1,2}", regex=True, na=False) |
| if looks_like.mean() < 0.8: |
| continue |
| parsed = pd.to_datetime(series, errors="coerce", format="mixed", dayfirst=True) |
| if parsed.notna().mean() >= 0.8: |
| suspects.append(col) |
| return suspects |
|
|
|
|
| def duplicate_columns(df: pd.DataFrame) -> list[tuple[str, str]]: |
| """Pares de columnas con contenido idéntico (misma información dos veces).""" |
| pairs = [] |
| cols = df.columns.tolist() |
| hashes = {col: pd.util.hash_pandas_object(df[col], index=False).sum() for col in cols} |
| for i in range(len(cols)): |
| for j in range(i + 1, len(cols)): |
| if hashes[cols[i]] == hashes[cols[j]] and df[cols[i]].equals(df[cols[j]]): |
| pairs.append((cols[i], cols[j])) |
| return pairs |
|
|
|
|
| |
| |
| |
|
|
| MAX_CRAMER_CARDINALITY = 30 |
|
|
|
|
| def cramers_v(x: pd.Series, y: pd.Series) -> float: |
| """V de Cramér entre dos series categóricas (0 = independientes, 1 = asociación total). |
| |
| Chi-cuadrado calculado a mano con numpy para no depender de scipy. |
| """ |
| table = pd.crosstab(x, y).to_numpy().astype(float) |
| n = table.sum() |
| if n == 0 or min(table.shape) < 2: |
| return float("nan") |
| row_sums = table.sum(axis=1, keepdims=True) |
| col_sums = table.sum(axis=0, keepdims=True) |
| expected = row_sums @ col_sums / n |
| with np.errstate(divide="ignore", invalid="ignore"): |
| chi2 = np.nansum(np.where(expected > 0, (table - expected) ** 2 / expected, 0.0)) |
| return float(np.sqrt(chi2 / (n * (min(table.shape) - 1)))) |
|
|
|
|
| def cramers_v_matrix(df: pd.DataFrame) -> pd.DataFrame: |
| cols = [ |
| c for c in categorical_columns(df) |
| if 2 <= df[c].nunique(dropna=True) <= MAX_CRAMER_CARDINALITY |
| ] |
| if len(cols) < 2: |
| return pd.DataFrame() |
| matrix = pd.DataFrame(np.eye(len(cols)), index=cols, columns=cols) |
| for i in range(len(cols)): |
| for j in range(i + 1, len(cols)): |
| value = cramers_v(df[cols[i]].astype(str), df[cols[j]].astype(str)) |
| matrix.iloc[i, j] = matrix.iloc[j, i] = round(value, 3) |
| return matrix |
|
|
|
|
| def eta_squared_table(df: pd.DataFrame, target: str) -> pd.DataFrame: |
| """Eta² de cada variable numérica respecto a un objetivo categórico. |
| |
| Mide qué proporción de la varianza de la numérica explican las clases |
| del objetivo (0 = nada, 1 = toda). Complementa a la correlación, que |
| solo funciona con objetivos numéricos o binarios. |
| """ |
| if target not in df.columns: |
| return pd.DataFrame() |
| rows = [] |
| groups_all = df[target].astype(str) |
| for col in numeric_columns(df): |
| if col == target: |
| continue |
| data = pd.DataFrame({"valor": df[col], "grupo": groups_all}).dropna() |
| if len(data) < 10 or data["grupo"].nunique() < 2: |
| continue |
| grand_mean = data["valor"].mean() |
| ss_total = ((data["valor"] - grand_mean) ** 2).sum() |
| if ss_total == 0: |
| continue |
| ss_between = sum( |
| len(g) * (g["valor"].mean() - grand_mean) ** 2 |
| for _, g in data.groupby("grupo") |
| ) |
| rows.append({"variable": col, "eta_cuadrado": round(float(ss_between / ss_total), 3)}) |
| result = pd.DataFrame(rows) |
| if not result.empty: |
| result = result.sort_values("eta_cuadrado", ascending=False).reset_index(drop=True) |
| return result |
|
|
|
|
| |
| |
| |
| |
|
|
| PCA_SAMPLE = 5_000 |
|
|
|
|
| def pca_summary(df: pd.DataFrame, color_col: str | None = None) -> dict | None: |
| """PCA sobre las numéricas estandarizadas. |
| |
| Devuelve la varianza explicada por componente y la proyección 2D |
| (para el gráfico), o None si no hay suficientes datos. |
| """ |
| cols = numeric_columns(df) |
| if len(cols) < 3: |
| return None |
| data = df[cols].dropna() |
| if len(data) < 10: |
| return None |
| if len(data) > PCA_SAMPLE: |
| data = data.sample(PCA_SAMPLE, random_state=42) |
|
|
| X = data.to_numpy(dtype=float) |
| X = X - X.mean(axis=0) |
| std = X.std(axis=0) |
| keep = std > 0 |
| X = X[:, keep] / std[keep] |
| if X.shape[1] < 2: |
| return None |
|
|
| _, s, vt = np.linalg.svd(X, full_matrices=False) |
| variance = s**2 |
| ratio = variance / variance.sum() |
|
|
| projection = pd.DataFrame(X @ vt[:2].T, columns=["PC1", "PC2"], index=data.index) |
| if color_col and color_col in df.columns: |
| projection[color_col] = df.loc[data.index, color_col].astype(str) |
|
|
| loadings = pd.DataFrame( |
| vt[:2].T, |
| index=[c for c, k in zip(cols, keep) if k], |
| columns=["PC1", "PC2"], |
| ).round(3) |
|
|
| return { |
| "varianza_explicada": [round(float(r), 4) for r in ratio], |
| "proyeccion": projection, |
| "cargas": loadings, |
| "n_muestra": len(data), |
| } |
|
|