Files
fn_registry/python/functions/pipelines/profile_table.py
T
Egutierrez e142ef026d fix(eda): hallazgos de comportamiento del benchmark (H2,H3,H6,H7,H8,H10,H11)
Ronda 4 (verificada con re-corrida sobre los datasets afectados):
- H2: stl_decompose deriva periodo de la frecuencia del indice (seattle period=365
  seasonal_strength=0.84; fin del period=2 espurio)
- H3+H10: infer_fk por senal de nombre (<X>Id->X.<X>Id) + excluir no-clave -> chinook
  111->9 FK, todas reales, cero absurdas, 16-27x mas rapido; base intacta (flag off->111)
- H6: association no computa eta2 si cardinalidad~=n (Ticket-Fare espurio fuera)
- H7: id secuencial monotono excluido de correlacion y PCA/KMeans (PassengerId fuera)
- H8: correlacion de series no estacionarias marcada espuria / sobre retornos
- H11: distribution_type usa modos/cardinalidad/normalidad (quality->discrete)
- 66 tests verdes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:37:47 +02:00

739 lines
32 KiB
Python

"""profile_table — orquestador one-shot del grupo de capacidad `eda`.
Pipeline impuro: perfila UNA tabla DuckDB end-to-end componiendo las funciones
puras e impuras del grupo `eda` y, opcionalmente, escribe un report markdown +
JSON sidecar a disco. Es la composicion canonica para "hazme un EDA de esta
tabla": una sola llamada en vez de orquestar 7 funciones a mano.
Funciones del registry compuestas (NO se reimplementa su logica):
- summarize_table_duckdb : perfil base por columna (push-down SQL, sin RAM).
- duckdb_query_readonly : muestra read-only de valores no nulos por columna.
- infer_semantic_type : clasifica VARCHAR (email, integer, currency, ...).
- describe_numeric : estadistica fina sobre la muestra numerica.
- summarize_categorical : top-k, moda, entropia sobre la muestra categorica.
- column_quality_score : score 0-100 de calidad por columna.
- render_eda_markdown : report legible del TableProfile.
Aporta una capa propia de PROMOCION DE TIPO: muchas tablas guardan numeros y
fechas como VARCHAR. Tras el perfil base, se muestrea cada columna textual, se
infiere su semantic_type y, si encaja, se promociona inferred_type a "numeric"
o "datetime" antes de enriquecer. Asi una columna '10','20' (VARCHAR) recibe su
bloque numeric en vez de quedarse como categorica.
Estilo dict-no-throw del grupo: nunca lanza; captura cualquier error y devuelve
{status:'error', error:str}.
"""
import json
import os
from datetime import date, datetime, timezone
from datascience import (
acf_pacf,
adf_kpss_stationarity,
association_matrix,
column_quality_score,
describe_numeric,
eda_llm_insights,
exploratory_caveats,
infer_semantic_type,
render_eda_markdown,
render_eda_pdf,
run_eda_models,
stl_decompose,
suggest_reexpression,
summarize_categorical,
summarize_table_duckdb,
summarize_table_pg,
to_returns,
)
from infra import duckdb_query_readonly, pg_query
# semantic_types que justifican promocionar inferred_type -> "numeric".
_NUMERIC_SEMANTIC = ("integer", "decimal", "currency")
# semantic_types que justifican promocionar inferred_type -> "datetime".
_DATETIME_SEMANTIC = ("datetime_iso", "date_eu")
# Fraccion minima de la muestra que debe parsear a float para confirmar la
# promocion a numeric (evita promocionar columnas mayormente no parseables).
_PROMOTE_MIN_PARSE = 0.8
# Cardinalidad maxima (distinct_count) por debajo de la cual una columna numerica
# se trata como NO continua (binaria / ordinal de pocos niveles) y, por tanto, no
# es candidata a re-expresion de Tukey (la escalera de potencias no aplica a una
# variable con pocos niveles discretos).
_REEXPR_MIN_DISTINCT = 12
# Tokens en el nombre (o semantic_type currency) que sugieren que una serie de
# niveles es FINANCIERA (precios/volumen): en ese caso la transformacion adecuada
# son los retornos. Para magnitudes fisicas (temperatura, caudal) la transformacion
# correcta son las diferencias, no los retornos.
_FINANCIAL_TOKENS = (
"price", "close", "open", "high", "low", "volume", "adj", "vwap",
"bid", "ask", "return", "precio", "cierre", "apertura", "cotiz", "retorno",
)
def _is_continuous_for_reexpr(col: dict, vals_float: list) -> bool:
"""True si la columna numerica es continua y justifica sugerir re-expresion.
Se saltan (devuelve False):
- binarias / ordinales de baja cardinalidad (``distinct_count`` <= umbral):
la escalera de potencias de Tukey no tiene sentido sobre pocos niveles
discretos (p.ej. ``Survived`` 0/1, ``Pclass`` 1/2/3).
- identificadores enteros (flag ``possible_id`` y todos los valores enteros):
re-expresar un id (p.ej. ``PassengerId`` 1..n) no aporta nada.
Los floats continuos de alta cardinalidad (precios, medidas) NO se saltan
aunque lleven ``possible_id``, porque tienen parte decimal (no son enteros).
"""
dc = col.get("distinct_count")
if isinstance(dc, int) and not isinstance(dc, bool) and dc <= _REEXPR_MIN_DISTINCT:
return False
flags = col.get("flags") or []
if "possible_id" in flags and vals_float and all(
float(f).is_integer() for f in vals_float
):
return False
return True
def _looks_financial(col: dict) -> bool:
"""True si la columna parece una serie financiera (precio/volumen/divisa).
Heuristica por nombre (tokens OHLCV típicos) o ``semantic_type == currency``.
Decide si una serie de niveles se debe transformar a retornos (financiera) o a
diferencias (no financiera, p.ej. temperatura).
"""
name = (col.get("name") or "").lower()
if any(tok in name for tok in _FINANCIAL_TOKENS):
return True
return (col.get("semantic_type") or "").lower() == "currency"
def _to_ordinal_days(value) -> float | None:
"""Convierte un valor fecha/datetime/ISO-string a dias ordinales (float), o None.
Soporta los tipos que devuelve DuckDB para columnas DATE/TIMESTAMP
(``datetime.date`` / ``datetime.datetime``) y strings ISO. Devuelve None para
cualquier cosa que no parsee a una fecha (numeros sueltos, basura).
"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, datetime):
return value.toordinal() + value.hour / 24.0 + value.minute / 1440.0
if isinstance(value, date):
return float(value.toordinal())
if isinstance(value, (int, float)):
return None # entero/float suelto: no es una fecha fiable
s = str(value).strip()
if not s:
return None
try:
return float(datetime.fromisoformat(s).toordinal())
except (TypeError, ValueError):
pass
try:
return float(date.fromisoformat(s[:10]).toordinal())
except (TypeError, ValueError):
return None
def _infer_period_from_dates(dates: list, n_series: int) -> int | None:
"""Deriva el periodo estacional de la FRECUENCIA del indice datetime.
En vez de adivinar el periodo por autocorrelacion cruda (que en series con
tendencia degenera a 2 y produce un falso negativo de estacionalidad), se mide
el delta mediano en dias entre observaciones consecutivas ordenadas y se mapea
a su periodo estacional dominante:
- diario (delta ~= 1 dia) -> 365 si hay >= 2 anios de datos; si no, 7.
- semanal (delta ~= 7 dias) -> 52.
- mensual (delta ~= 30 dias)-> 12.
- trimestral (delta ~= 91) -> 4.
Devuelve None si no hay fechas suficientes, si la frecuencia no encaja en un
patron conocido, o si la serie es demasiado corta para dos ciclos del periodo.
"""
ords = [d for d in (_to_ordinal_days(v) for v in dates) if d is not None]
if len(ords) < 3:
return None
ords.sort()
deltas = sorted(b - a for a, b in zip(ords[:-1], ords[1:]) if b - a > 0)
if not deltas:
return None
med = deltas[len(deltas) // 2] # delta mediano en dias
if med <= 2.0: # diario
if n_series >= 730: # >= 2 anios: estacionalidad anual
return 365
return 7 if n_series >= 14 else None
if 5.0 <= med <= 10.0: # semanal
return 52 if n_series >= 104 else None
if 25.0 <= med <= 35.0: # mensual
return 12 if n_series >= 24 else None
if 85.0 <= med <= 100.0: # trimestral
return 4 if n_series >= 8 else None
return None
def _is_sequential_id(col: dict) -> bool:
"""True si la columna numerica es un id ENTERO secuencial (indice de fila).
Distingue ``PassengerId`` (1..n, enteros densos, monotono) de un float continuo
de alta cardinalidad (precios): el id no debe entrar en correlacion ni en
PCA/KMeans (es ruido que infla pares espurios y distorsiona componentes); el
precio si. Criterio: flag ``possible_id`` + min/max enteros + rango denso (casi
todos los enteros del intervalo presentes). Un precio tiene parte decimal en
min/max, asi que NUNCA lo marca.
"""
if col.get("inferred_type") != "numeric":
return False
if "possible_id" not in (col.get("flags") or []):
return False
nb = col.get("numeric") or {}
mn, mx = nb.get("min"), nb.get("max")
if not (
isinstance(mn, (int, float))
and not isinstance(mn, bool)
and isinstance(mx, (int, float))
and not isinstance(mx, bool)
):
return False
if not (float(mn).is_integer() and float(mx).is_integer()):
return False # float continuo (precios): mantener
dc = col.get("distinct_count")
if isinstance(dc, int) and not isinstance(dc, bool) and dc > 1:
span = float(mx) - float(mn) + 1.0
if span > 0 and (dc / span) >= 0.95:
return True
return False
def _to_float(value):
"""Parsea un valor a float limpiando simbolos de moneda y separadores.
Quita simbolos de divisa (EUR/USD/GBP/€/$/£), espacios y separadores de
miles, y normaliza la coma decimal. Devuelve None si no parsea.
"""
if value is None:
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
s = str(value).strip()
if not s:
return None
# Limpia simbolos de moneda y unidades textuales.
for tok in ("", "$", "£", "EUR", "USD", "GBP", "eur", "usd", "gbp"):
s = s.replace(tok, "")
s = s.strip()
# Normaliza separadores: si hay coma y punto, asume punto=miles, coma=decimal.
if "," in s and "." in s:
s = s.replace(".", "").replace(",", ".")
elif "," in s:
# Solo coma: tratar como separador decimal.
s = s.replace(",", ".")
s = s.replace(" ", "")
try:
return float(s)
except (TypeError, ValueError):
return None
def _sample_values(query_fn, table: str, name: str, sample: int) -> list:
"""Trae hasta `sample` valores no nulos de una columna (read-only).
query_fn(sql) -> dict es el lector read-only del backend activo
(duckdb_query_readonly o pg_query), inyectado por profile_table.
"""
q = query_fn(
f'SELECT "{name}" AS v FROM "{table}" WHERE "{name}" IS NOT NULL '
f"LIMIT {int(sample)}",
)
if q.get("status") != "ok":
return []
return [row.get("v") for row in q.get("rows", [])]
def _sample_rows(query_fn, table: str, names: list, sample: int) -> list:
"""Trae hasta `sample` filas completas con las columnas alineadas por fila.
A diferencia de _sample_values (una columna, solo no nulos), esto preserva la
alineacion por fila entre columnas, requisito de la matriz de asociacion
(los pares (a_i, b_i) deben venir de la misma fila). query_fn es el lector
read-only del backend activo, inyectado por profile_table.
"""
if not names:
return []
cols_sql = ", ".join(f'"{n}"' for n in names)
q = query_fn(f'SELECT {cols_sql} FROM "{table}" LIMIT {int(sample)}')
if q.get("status") != "ok":
return []
return q.get("rows", [])
def _sample_series(query_fn, table: str, value_col: str, order_col, sample: int) -> list:
"""Trae hasta `sample` valores no nulos de una columna en orden de serie temporal.
A diferencia de _sample_values, cuando hay una columna de orden temporal
(`order_col`, normalmente la primera columna datetime de la tabla) se ordena
ascendentemente por ella para que la secuencia recuperada respete el orden
cronologico, requisito de los contrastes de serie temporal (ADF/KPSS, ACF/PACF,
STL). Si `order_col` es None se cae al orden fisico de inserciones (columna
numerica secuencial). query_fn es el lector read-only del backend activo.
"""
base = (
f'SELECT "{value_col}" AS v FROM "{table}" '
f'WHERE "{value_col}" IS NOT NULL'
)
if order_col:
base += f' ORDER BY "{order_col}"'
base += f" LIMIT {int(sample)}"
q = query_fn(base)
if q.get("status") != "ok":
return []
return [row.get("v") for row in q.get("rows", [])]
def _build_series_block(query_fn, table: str, col: dict, order_col, sample: int) -> dict:
"""Construye el bloque `series` de una columna numerica (estilo dict-no-throw).
Compone los contrastes de serie temporal del grupo `eda` sobre la secuencia
ordenada de la columna: estacionariedad (ADF+KPSS), autocorrelacion (ACF/PACF +
Ljung-Box) y descomposicion STL (tendencia/estacional/resto). Cuando la columna
parece de NIVELES (precios: estrictamente positiva y no claramente estacionaria)
anade ademas la conversion a retornos (`to_returns`) como sugerencia, ya que
correlacionar/modelar niveles no estacionarios produce relaciones espurias
(Granger-Newbold).
Devuelve None si no hay suficientes puntos validos (<8) para ningun contraste.
"""
name = col.get("name")
raw = _sample_series(query_fn, table, name, order_col, sample)
series_vals = [f for f in (_to_float(v) for v in raw) if f is not None]
if len(series_vals) < 8:
return None
# Periodo estacional derivado de la FRECUENCIA del indice datetime (mensual->12,
# diario->365/7), que es fiable, en vez de dejar que stl_decompose lo adivine por
# autocorrelacion (que en series con tendencia degenera a period=2 -> falso
# negativo de estacionalidad). Si no hay order_col datetime o la frecuencia no
# encaja, period queda None y stl_decompose lo infiere (ya con detrend) o avisa.
period = None
period_source = "autocorr"
if order_col:
raw_dates = _sample_series(query_fn, table, order_col, order_col, sample)
period = _infer_period_from_dates(raw_dates, len(series_vals))
if period is not None:
period_source = "datetime_freq"
block: dict = {
"order_col": order_col,
"ordered": bool(order_col),
"n": len(series_vals),
"stationarity": adf_kpss_stationarity(series_vals),
"acf_pacf": acf_pacf(series_vals),
# Periodo de la frecuencia del indice si se pudo derivar; si no, stl_decompose
# lo infiere por autocorrelacion del residuo detrended o devuelve una nota
# ("periodo no determinado") sin reportar seasonal_strength=0 como conclusion.
"period_source": period_source,
"stl": stl_decompose(series_vals, period=period),
}
# Sugerencia de transformacion solo si la columna parece de niveles:
# estrictamente positiva y con veredicto de estacionariedad NO confirmado.
# La transformacion adecuada depende de la SEMANTICA: retornos para series
# financieras (precios/volumen), diferencias para magnitudes fisicas
# (temperatura, caudal). Aplicar "retornos" a una temperatura no tiene sentido
# fisico; la primera diferencia si la estaciona.
nb = col.get("numeric") or {}
minimum = nb.get("min")
verdict = (block["stationarity"] or {}).get("verdict")
if (
isinstance(minimum, (int, float))
and not isinstance(minimum, bool)
and minimum > 0
and verdict in ("non_stationary", "inconclusive")
):
block["levels_suggested"] = True
if _looks_financial(col):
block["levels_kind"] = "returns"
block["to_returns"] = to_returns(series_vals, method="log")
block["levels_reason"] = (
"columna financiera estrictamente positiva y no claramente "
"estacionaria (serie de niveles/precios): trabajar sobre retornos "
"evita correlacion espuria (Granger-Newbold)."
)
else:
block["levels_kind"] = "differences"
block["levels_reason"] = (
"serie de niveles no financiera y no claramente estacionaria: la "
"primera diferencia la estaciona; los retornos no tienen sentido en "
"magnitudes fisicas (p.ej. temperatura)."
)
else:
block["levels_suggested"] = False
return block
def profile_table(
db_path: str,
table: str,
backend: str = "duckdb",
sample: int = 5000,
run_models: bool = False,
run_llm: bool = False,
run_series: bool = False,
emit_pdf: bool = False,
report_dir: str = "reports",
write_report: bool = True,
) -> dict:
"""Perfila una tabla (DuckDB o PostgreSQL) end-to-end y emite el TableProfile.
Args:
db_path: ruta al archivo DuckDB, o DSN PostgreSQL si backend="postgres".
table: nombre de la tabla a perfilar.
backend: "duckdb" (default) o "postgres". Selecciona el motor de
perfilado base (summarize) y de muestreo read-only.
sample: maximo de valores no nulos muestreados por columna para el
enriquecimiento (describe_numeric / summarize_categorical /
infer_semantic_type). Default 5000.
run_models: si True (default False) corre los modelos baratos
(PCA/KMeans/IsolationForest/normalidad) sobre las numericas y guarda
el bloque en prof["models"].
run_llm: si True (default False) hace 1 llamada LLM sobre el perfil
agregado y guarda el resultado en prof["llm"].
run_series: si True (default False) calcula, para cada columna numerica,
un bloque de serie temporal (estacionariedad ADF+KPSS, ACF/PACF,
descomposicion STL y, si parece de niveles, conversion a retornos).
Si hay una columna datetime se usa como orden cronologico; si no, se
usa el orden fisico de filas (columna numerica secuencial). Los bloques
se guardan por columna en col["series"] y agregados en prof["series"].
emit_pdf: si True (default False) renderiza un PDF multipagina vertical
(legible en movil) del perfil junto al report markdown y devuelve su
ruta en pdf_path.
report_dir: directorio donde escribir los reports si write_report.
Default "reports". Se crea si no existe.
write_report: si True (default), escribe un report markdown + un JSON
sidecar timestamped en report_dir. Si False, no toca disco y los
paths del retorno son None.
Returns:
dict. En exito: {status:'ok', profile: <TableProfile>,
report_md_path: str|None, report_json_path: str|None, pdf_path: str|None}.
En error (sin lanzar): {status:'error', error:str}.
"""
try:
# 1) Perfil base por columna (push-down SQL) + lector read-only del
# backend activo, inyectado en el muestreo (_sample_values/_sample_rows).
if backend == "postgres":
r = summarize_table_pg(db_path, table)
def _q(sql):
return pg_query(db_path, sql)
elif backend == "duckdb":
r = summarize_table_duckdb(db_path, table)
def _q(sql):
return duckdb_query_readonly(db_path, sql)
else:
return {"status": "error", "error": f"backend desconocido: {backend}"}
if r.get("status") != "ok":
return {"status": "error", "error": r.get("error", "summarize failed")}
prof = r["profile"]
cols = prof.get("columns", [])
for col in cols:
name = col.get("name")
inferred = col.get("inferred_type")
# 2) Muestra de valores no nulos.
vals = _sample_values(_q, table, name, sample)
# 3) Promocion de tipo sobre columnas textuales.
if inferred in ("categorical", "text"):
sem = infer_semantic_type(vals)
semantic = sem.get("semantic_type", "")
col["semantic_type"] = semantic
if semantic in _NUMERIC_SEMANTIC:
parsed = [_to_float(v) for v in vals]
ok = [f for f in parsed if f is not None]
if vals and (len(ok) / len(vals)) >= _PROMOTE_MIN_PARSE:
col["inferred_type"] = "numeric"
inferred = "numeric"
elif semantic in _DATETIME_SEMANTIC:
col["inferred_type"] = "datetime"
inferred = "datetime"
# 4) Enriquecer segun el inferred_type final.
if inferred == "numeric":
vals_float = [f for f in (_to_float(v) for v in vals) if f is not None]
col["numeric"] = describe_numeric(vals_float)
# Re-expresion sugerida (escalera de Tukey): que transformacion
# simetriza mejor la columna a partir de su skew/dominio. Solo para
# columnas CONTINUAS: no aplica a binarias/ordinales de baja
# cardinalidad ni a identificadores enteros (la fila seria ruido).
if _is_continuous_for_reexpr(col, vals_float):
col["reexpression"] = suggest_reexpression(col["numeric"])
elif inferred in ("categorical", "text"):
col["categorical"] = summarize_categorical(vals)
# Para columnas no promovidas que ya eran categorical/text y no
# habian pasado por infer arriba, asegurar semantic_type seteado.
if not col.get("semantic_type"):
col["semantic_type"] = infer_semantic_type(vals).get(
"semantic_type", ""
)
elif inferred == "datetime":
# profile_datetime llega en otra fase; conserva semantic_type.
col["datetime"] = None
# 5) Score de calidad por columna.
col["quality_score"] = column_quality_score(col).get("score")
# 6) Score agregado de la tabla (media de columnas).
scores = [
c["quality_score"] for c in cols if c.get("quality_score") is not None
]
prof["quality_score"] = round(sum(scores) / len(scores), 1) if scores else None
# 7) Candidatos a clave.
key_candidates = []
for c in cols:
flags = c.get("flags") or []
unique_pct = c.get("unique_pct") or 0.0
null_pct = c.get("null_pct") or 0.0
if "possible_id" in flags or (unique_pct >= 0.99 and null_pct == 0):
key_candidates.append(c["name"])
prof["key_candidates"] = key_candidates
# 8) Recalcular type_breakdown tras la promocion.
type_breakdown = {
"numeric": 0,
"categorical": 0,
"datetime": 0,
"text": 0,
"boolean": 0,
}
for c in cols:
it = c.get("inferred_type")
if it in type_breakdown:
type_breakdown[it] += 1
prof["type_breakdown"] = type_breakdown
# 8.5) Matriz de correlacion/asociacion sobre una muestra de filas
# alineadas. Elige la metrica por par de tipos (Pearson/Spearman,
# Cramer's V/Theil's U, correlation ratio, MI) via association_matrix.
# Se salta el text de alta cardinalidad (ids/urls): solo mete ruido.
try:
corr_sample = min(int(sample), 5000)
# Excluye columnas id-like (possible_id / high_cardinality) de tipo
# categorical/text: su cardinalidad ~ n filas infla Cramer's V y MI
# con asociaciones espurias (cada valor unico empareja perfecto).
# Las numericas de alta cardinalidad SI se conservan (p.ej. precios).
def _skip_for_assoc(c):
it = c.get("inferred_type")
flags = c.get("flags") or []
# Categoricas/text id-like por cardinalidad ~ n.
if it in ("categorical", "text") and (
"possible_id" in flags or "high_cardinality" in flags
):
return True
# Id ENTERO secuencial numerico (PassengerId 1..n): indice de fila,
# genera pares espurios (correlation_ratio ~0.9 sig=si) y entra como
# feature ruidosa en PCA/KMeans. Los floats continuos (precios) NO
# se saltan aunque lleven possible_id.
return _is_sequential_id(c)
assoc_cols = [c for c in cols if not _skip_for_assoc(c)]
rows = _sample_rows(
_q, table, [c["name"] for c in assoc_cols], corr_sample
)
assoc_input = {}
for c in assoc_cols:
name = c["name"]
it = c.get("inferred_type") or "categorical"
raw = [row.get(name) for row in rows]
if it == "numeric":
assoc_input[name] = {
"values": [_to_float(v) for v in raw],
"type": "numeric",
}
else:
assoc_input[name] = {"values": raw, "type": it}
prof["correlations"] = (
association_matrix(assoc_input) if len(assoc_input) >= 2 else None
)
except Exception: # noqa: BLE001
prof["correlations"] = None
assoc_input = {}
# Modelos baratos opt-in en su PROPIO try: un fallo de los modelos NUNCA
# debe tumbar las correlaciones ya calculadas (bug detectado en EDAs PG
# reales: un try/except compartido ponia ambos campos a None).
if run_models:
try:
# Subconjunto de features para PCA/KMeans: solo numericas CONTINUAS.
# Se excluyen binarias/ordinales/target de baja cardinalidad (Survived
# 0/1, Pclass 1/2/3): como dimensiones del PCA/clustering anaden ruido
# y distorsionan componentes y centroides. Los id secuenciales ya
# quedaron fuera de assoc_input via _skip_for_assoc.
def _is_model_feature(cname):
c = next(
(x for x in assoc_cols if x.get("name") == cname), None
)
if c is None or c.get("inferred_type") != "numeric":
return False
dc = c.get("distinct_count")
if (
isinstance(dc, int)
and not isinstance(dc, bool)
and dc <= _REEXPR_MIN_DISTINCT
):
return False
return True
models_input = {
n: v for n, v in assoc_input.items() if _is_model_feature(n)
}
prof["models"] = run_eda_models(models_input)
except Exception: # noqa: BLE001
prof["models"] = None
# 8.6) Capa LLM opcional: interpreta el perfil ya calculado en UNA
# llamada (data dictionary, resumen, granularidad de fila, PII, limpieza,
# analisis sugeridos). Solo envia el perfil agregado, nunca filas crudas.
if run_llm:
try:
res = eda_llm_insights(prof)
prof["llm"] = res.get("llm") if res.get("status") == "ok" else None
except Exception: # noqa: BLE001
prof["llm"] = None
# 8.7) Analisis de serie temporal opt-in. Para cada columna numerica se
# calcula estacionariedad (ADF+KPSS), autocorrelacion (ACF/PACF) y
# descomposicion STL sobre la secuencia ordenada; si parece de niveles se
# anade la conversion a retornos. Si hay una columna datetime se usa como
# orden cronologico; si no, el orden fisico (columna numerica secuencial).
if run_series:
try:
order_col = next(
(
c.get("name")
for c in cols
if c.get("inferred_type") == "datetime"
),
None,
)
series_map: dict = {}
for col in cols:
if col.get("inferred_type") != "numeric":
continue
try:
sblock = _build_series_block(
_q, table, col, order_col, sample
)
except Exception: # noqa: BLE001
sblock = None
if sblock is not None:
col["series"] = sblock
series_map[col["name"]] = sblock
prof["series"] = series_map or None
except Exception: # noqa: BLE001
prof["series"] = None
# 8.75) Marcar las correlaciones num-num calculadas sobre NIVELES de series
# no estacionarias (autocorreladas) como posible espuria (Granger-Newbold):
# Close-Open=0.998 es un artefacto de la raiz unitaria, no una relacion util.
# Para uso financiero lo correcto es correlacionar retornos (col["series"]
# ["to_returns"]). Si corrio run_series se usa el veredicto ADF/KPSS por
# columna; si no, una heuristica financiera por nombre + dominio positivo.
try:
corr = prof.get("correlations")
if isinstance(corr, dict):
levels_cols: set = set()
series_map = prof.get("series") or {}
if series_map:
for cname, sb in series_map.items():
if not isinstance(sb, dict):
continue
verdict = (sb.get("stationarity") or {}).get("verdict")
if sb.get("levels_suggested") or verdict in (
"non_stationary",
"inconclusive",
):
levels_cols.add(cname)
else:
for c in cols:
if c.get("inferred_type") == "numeric" and _looks_financial(c):
mn = (c.get("numeric") or {}).get("min")
if (
isinstance(mn, (int, float))
and not isinstance(mn, bool)
and mn > 0
):
levels_cols.add(c.get("name"))
n_marked = 0
for pair in corr.get("pairs", []):
if (
pair.get("method") == "pearson/spearman"
and pair.get("a") in levels_cols
and pair.get("b") in levels_cols
):
pair["levels_possible_spurious"] = True
n_marked += 1
if n_marked:
corr["levels_caveat"] = (
f"{n_marked} par(es) de correlacion se calculan sobre NIVELES "
"de series no estacionarias (autocorreladas): la correlacion "
"puede ser espuria (Granger-Newbold). Para uso financiero, "
"correlacionar sobre retornos/diferencias (ver el bloque "
"series de cada columna)."
)
except Exception: # noqa: BLE001
pass
# 8.8) Avisos exploratorios: recuerdan que el EDA genera hipotesis, no
# conclusiones. Se calculan sobre el perfil ya completo (correlaciones,
# modelos, outliers, faltantes determinan que advertencias aplican).
try:
prof["caveats"] = exploratory_caveats(prof)
except Exception: # noqa: BLE001
prof["caveats"] = None
# 9) Reports opcionales (markdown + JSON sidecar + PDF movil).
report_md_path = None
report_json_path = None
pdf_path = None
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
if write_report:
os.makedirs(report_dir, exist_ok=True)
report_json_path = os.path.join(report_dir, f"eda_{table}_{ts}.json")
report_md_path = os.path.join(report_dir, f"eda_{table}_{ts}.md")
with open(report_json_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(prof, ensure_ascii=False, indent=1, default=str))
with open(report_md_path, "w", encoding="utf-8") as fh:
fh.write(render_eda_markdown(prof))
# PDF multipagina vertical (legible en movil), junto al report markdown.
if emit_pdf:
try:
os.makedirs(report_dir, exist_ok=True)
pdf_target = os.path.join(report_dir, f"eda_{table}_{ts}.pdf")
pres = render_eda_pdf(prof, pdf_target)
pdf_path = pres.get("pdf_path")
except Exception: # noqa: BLE001
pdf_path = None
return {
"status": "ok",
"profile": prof,
"report_md_path": report_md_path,
"report_json_path": report_json_path,
"pdf_path": pdf_path,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}