eda-express / eda /profiler.py
aitziberluis's picture
Upload 9 files
d7f258e verified
Raw
History Blame Contribute Delete
16.7 kB
"""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
# Por encima de este número de filas, los gráficos usan una muestra para
# mantener la interfaz fluida (las estadísticas se calculan siempre con todo).
SAMPLE_THRESHOLD = 50_000
# ---------------------------------------------------------------------------
# Carga
# ---------------------------------------------------------------------------
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))
# sep=None + engine="python" detecta ; , \t automáticamente — habitual
# en CSVs europeos exportados de Excel.
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])
]
# ---------------------------------------------------------------------------
# Visión general
# ---------------------------------------------------------------------------
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)
# ---------------------------------------------------------------------------
# Faltantes
# ---------------------------------------------------------------------------
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,
}
)
# ---------------------------------------------------------------------------
# Numéricas
# ---------------------------------------------------------------------------
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)
# ---------------------------------------------------------------------------
# Categóricas
# ---------------------------------------------------------------------------
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})
# ---------------------------------------------------------------------------
# Correlaciones
# ---------------------------------------------------------------------------
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])
# ---------------------------------------------------------------------------
# Análisis respecto a una columna objetivo
# ---------------------------------------------------------------------------
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):
# Objetivo no numérico: solo es codificable si es binario.
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})
# ---------------------------------------------------------------------------
# Resumen compacto para el LLM (solo agregados, nunca filas de datos)
# ---------------------------------------------------------------------------
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)
# ---------------------------------------------------------------------------
# Detección de tipos mal guardados y columnas redundantes
# ---------------------------------------------------------------------------
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
# Solo intentarlo si los valores "parecen" fechas (dígitos + separador):
# to_datetime es caro y de gatillo fácil con números sueltos.
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
# ---------------------------------------------------------------------------
# Asociación entre categóricas (V de Cramér) y con el objetivo (eta cuadrado)
# ---------------------------------------------------------------------------
MAX_CRAMER_CARDINALITY = 30 # con más categorías, la tabla de contingencia no dice nada
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 a mano con numpy (sin scikit-learn: la dependencia no se justifica
# para dos componentes)
# ---------------------------------------------------------------------------
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),
}