feat(browser): auto-commit con 178 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:22:23 +02:00
parent 7d100e7f3e
commit 763e06c127
178 changed files with 19917 additions and 317 deletions
+56
View File
@@ -15,13 +15,69 @@ from .scrape_google_trends import scrape_google_trends
from .scrape_competitor_prices import scrape_competitor_prices
from .scrape_tiktok_creative import scrape_tiktok_creative
from .scrape_aliexpress_trending import scrape_aliexpress_trending
from .fetch_reddit_search import fetch_reddit_search
from .fetch_hackernews_search import fetch_hackernews_search
from .score_demand_signal import score_demand_signal
from .pull_gsc_search_analytics import pull_gsc_search_analytics
from .summarize_table_duckdb import summarize_table_duckdb
from .describe_numeric import describe_numeric
from .summarize_categorical import summarize_categorical
from .infer_semantic_type import infer_semantic_type
from .column_quality_score import column_quality_score
from .render_eda_markdown import render_eda_markdown
from .detect_distribution_type import detect_distribution_type
from .spearman_corr import spearman_corr
from .cramers_v import cramers_v
from .theils_u import theils_u
from .correlation_ratio import correlation_ratio
from .mutual_info_columns import mutual_info_columns
from .infer_fk_containment_duckdb import infer_fk_containment_duckdb
from .build_join_graph import build_join_graph
from .association_matrix import association_matrix
from .correlation_matrix_duckdb import correlation_matrix_duckdb
from .pca_explained import pca_explained
from .kmeans_segments import kmeans_segments
from .isolation_forest_outliers import isolation_forest_outliers
from .normality_tests import normality_tests
from .trend_slope import trend_slope
from .run_eda_models import run_eda_models
from .eda_llm_insights import eda_llm_insights
from .build_eda_notebook import build_eda_notebook
__all__ = [
"summarize_table_duckdb",
"spearman_corr",
"cramers_v",
"theils_u",
"correlation_ratio",
"mutual_info_columns",
"infer_fk_containment_duckdb",
"build_join_graph",
"association_matrix",
"correlation_matrix_duckdb",
"pca_explained",
"kmeans_segments",
"isolation_forest_outliers",
"normality_tests",
"trend_slope",
"run_eda_models",
"eda_llm_insights",
"build_eda_notebook",
"describe_numeric",
"summarize_categorical",
"infer_semantic_type",
"column_quality_score",
"render_eda_markdown",
"detect_distribution_type",
"pull_gsc_search_analytics",
"scrape_amazon_bestsellers",
"scrape_google_trends",
"scrape_competitor_prices",
"scrape_tiktok_creative",
"scrape_aliexpress_trending",
"fetch_reddit_search",
"fetch_hackernews_search",
"score_demand_signal",
"pearson",
"standardize",
"min_max_scale",
@@ -0,0 +1,86 @@
---
name: association_matrix
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def association_matrix(columns: dict, strong_threshold: float = 0.5, top_n: int = 20) -> dict"
description: "Matriz de asociacion unificada de una tabla con tipos mezclados: elige la metrica correcta por par de tipos (Pearson/Spearman num-num, Cramer's V cat-cat, correlation ratio num-cat) y calcula informacion mutua normalizada comun para todos los pares. Devuelve pares evaluados, pares fuertes y leyenda de metodos."
tags: [eda, correlation, association, statistics, mixed-types, mutual-information]
params:
- name: columns
desc: "dict {nombre_columna: {\"values\": list, \"type\": \"numeric\"|\"categorical\"|\"datetime\"|\"boolean\"|\"text\"}}. datetime/boolean/text se tratan como categoricas; text de cardinalidad ~ n se salta como ruido."
- name: strong_threshold
desc: "Umbral en [0, 1]. Un par es fuerte si abs(value) >= umbral o extra.mi >= umbral. Default 0.5."
- name: top_n
desc: "Maximo de pares fuertes a devolver, ordenados por relevancia (max(abs(value), mi)) desc. Default 20."
output: "dict {pairs: lista de todos los pares {a, b, a_type, b_type, method, value, extra}; strong: subconjunto fuerte ordenado por relevancia desc truncado a top_n; methods_legend: dict metodo->descripcion}. Pura: con dict vacio o 1 columna devuelve pairs=[] y strong=[]."
uses_functions:
- pearson_py_datascience
- spearman_corr_py_datascience
- cramers_v_py_datascience
- theils_u_py_datascience
- correlation_ratio_py_datascience
- mutual_info_columns_py_datascience
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_two_correlated_numerics_strong_pearson", "test_numeric_explained_by_category_strong_correlation_ratio", "test_independent_pair_not_strong", "test_empty_dict_does_not_crash", "test_single_column_returns_empty"]
test_file_path: "python/functions/datascience/association_matrix_test.py"
file_path: "python/functions/datascience/association_matrix.py"
---
## Ejemplo
```python
from datascience import association_matrix
columns = {
# Numerica correlada linealmente con "size" (y ~ 2x + ruido pequeno).
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
"price": {"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1], "type": "numeric"},
# Categorica que explica la varianza de "score" (cada region -> nivel distinto).
"region": {"values": ["N", "N", "S", "S", "E", "E", "W", "W"], "type": "categorical"},
"score": {"values": [10.0, 11.0, 50.0, 49.0, 90.0, 91.0, 30.0, 31.0], "type": "numeric"},
}
result = association_matrix(columns, strong_threshold=0.5, top_n=10)
# Pares fuertes detectados (orden por relevancia):
for p in result["strong"]:
print(p["a"], p["b"], p["method"], round(p["value"], 2))
# size price pearson/spearman 1.0 -> num-num lineal casi perfecta
# region score correlation_ratio 0.99 -> la categoria explica la numerica
print(result["methods_legend"]["correlation_ratio"])
```
## Cuando usarla
Cuando necesites una **matriz de relaciones de una tabla entera mezclando tipos**
(numericas, categoricas, fechas, booleanos) en una sola pasada, sin tener que
elegir a mano que metrica aplicar a cada par. Ideal en la fase EDA para detectar
de un vistazo que columnas estan asociadas (y por que metodo), priorizando los
pares fuertes. Reusa las funciones atomicas del registry (`pearson`,
`spearman_corr`, `cramers_v`, `theils_u`, `correlation_ratio`,
`mutual_info_columns`) y anade informacion mutua normalizada como medida comun
no-lineal a todos los pares.
## Notas
- Pura: las atomicas que compone son puras y deterministas; no hace I/O.
- `pearson` no limpia None/NaN internamente, asi que los pares num-num se
limpian aqui antes de llamarla (se emparejan por indice y se descartan pares
con algun lado no numerico).
- En num-num el `value` principal es el de mayor valor absoluto entre Pearson y
Spearman; ambos quedan en `extra` (`pearson`, `spearman`).
- En cat-cat el `value` es Cramer's V (simetrico) y `extra` lleva Theil's U
direccional en ambos sentidos (`u_ab` = U(a|b), `u_ba` = U(b|a)).
- En num-cat el `value` es el correlation ratio (eta) llamando siempre con la
categorica como primer argumento y la numerica como segundo.
- Se saltan columnas con menos de 3 valores validos, y columnas `text` cuya
cardinalidad sea >= 90% del numero de filas (identificadores / free-text).
@@ -0,0 +1,210 @@
"""Matriz de asociacion unificada para una tabla con columnas de tipos mezclados.
Funcion pura del grupo eda. Para cada par de columnas elige la metrica de
asociacion adecuada al par de tipos (Pearson/Spearman para num-num, Cramer's V
para cat-cat, correlation ratio para num-cat) y, ademas, calcula informacion
mutua normalizada como medida comun no-lineal para todos los pares. Devuelve la
lista de pares evaluados, el subconjunto de pares fuertes y una leyenda de los
metodos. Compone las funciones atomicas del registry; no reimplementa metricas.
"""
import math
from datascience import (
correlation_ratio,
cramers_v,
mutual_info_columns,
pearson,
spearman_corr,
theils_u,
)
# Tipos que, para efectos de asociacion, se tratan como categoricos.
_CATEGORICAL_LIKE = {"categorical", "datetime", "boolean", "text"}
def _is_num(v) -> bool:
"""True si v es un numero real (int/float) que no es bool ni NaN."""
return (
isinstance(v, (int, float))
and not isinstance(v, bool)
and not (isinstance(v, float) and math.isnan(v))
)
def _is_numeric_type(t: str) -> bool:
return t == "numeric"
def _valid_count(values: list, numeric: bool) -> int:
"""Numero de valores validos: numericos finitos si numeric, no-None si cat."""
if numeric:
return sum(1 for v in values if _is_num(v))
return sum(1 for v in values if v is not None)
def _cardinality(values: list) -> int:
"""Numero de valores distintos no-None."""
return len({v for v in values if v is not None})
def _clean_numeric_pairs(xs: list, ys: list) -> tuple[list, list]:
"""Empareja por indice y conserva solo pares con ambos lados numericos."""
cx: list[float] = []
cy: list[float] = []
for x, y in zip(xs, ys):
if _is_num(x) and _is_num(y):
cx.append(float(x))
cy.append(float(y))
return cx, cy
def association_matrix(
columns: dict,
strong_threshold: float = 0.5,
top_n: int = 20,
) -> dict:
"""Construye la matriz de asociacion de una tabla con tipos mezclados.
Para cada par de columnas (i < j) selecciona la metrica adecuada al par de
tipos y calcula tambien informacion mutua normalizada como medida comun:
- num-num: `pearson` (lineal) y `spearman_corr` (monotonica). El `value`
principal es el de mayor valor absoluto; ambos se guardan en `extra`.
- cat-cat: `cramers_v` (simetrica) como `value`; `theils_u` en ambas
direcciones en `extra` (u_ab = U(a|b), u_ba = U(b|a)).
- num-cat: `correlation_ratio(categorias, valores)` como `value`.
- Todos los pares: `mutual_info_columns` normalizada en `extra["mi"]`.
Se saltan los pares donde alguna columna tenga menos de 3 valores validos o
sea de tipo `text` con cardinalidad cercana al numero de filas (ruido sin
asociacion util). Es una funcion pura: no falla con dict vacio o una sola
columna (devuelve `pairs=[]`, `strong=[]`).
Args:
columns: dict {nombre_columna: {"values": list, "type": str}} donde type
es uno de "numeric", "categorical", "datetime", "boolean", "text".
Los tipos datetime/boolean/text se tratan como categoricos.
strong_threshold: umbral en [0, 1]. Un par es "fuerte" si
abs(value) >= umbral o extra["mi"] >= umbral.
top_n: numero maximo de pares fuertes a devolver, ordenados por
relevancia (max(abs(value), mi)) descendente.
Returns:
dict con claves:
pairs: lista de todos los pares evaluados, cada uno
{a, b, a_type, b_type, method, value, extra}.
strong: subconjunto de pairs por encima del umbral, ordenado por
relevancia descendente y truncado a top_n.
methods_legend: dict {metodo: descripcion}.
"""
legend = {
"pearson": "num-num lineal (Pearson r), signo indica direccion, [-1, 1]",
"spearman": "num-num monotonica (Spearman rho), robusta a outliers, [-1, 1]",
"cramers_v": "cat-cat simetrica (Cramer's V, sesgo-corregido), [0, 1]",
"theils_u": "cat-cat direccional (Theil's U), incertidumbre explicada, [0, 1]",
"correlation_ratio": "num-cat (eta), varianza numerica explicada por la categoria, [0, 1]",
"mutual_info": "general no-lineal (NMI normalizada) para cualquier par de tipos, [0, 1]",
}
names = list(columns.keys())
if len(names) < 2:
return {"pairs": [], "strong": [], "methods_legend": legend}
n_rows = max(
(len(columns[name].get("values", [])) for name in names),
default=0,
)
def _skip(name: str) -> bool:
"""True si la columna no aporta asociacion util (pocos validos o text ruidoso)."""
col = columns[name]
vals = col.get("values", [])
ctype = col.get("type", "categorical")
numeric = _is_numeric_type(ctype)
if _valid_count(vals, numeric) < 3:
return True
# Texto de cardinalidad ~ n: identificadores/free-text, sin asociacion util.
if ctype == "text" and n_rows > 0 and _cardinality(vals) >= 0.9 * n_rows:
return True
return False
pairs: list[dict] = []
for i in range(len(names)):
a_name = names[i]
if _skip(a_name):
continue
a_col = columns[a_name]
a_vals = a_col.get("values", [])
a_type = a_col.get("type", "categorical")
a_numeric = _is_numeric_type(a_type)
for j in range(i + 1, len(names)):
b_name = names[j]
if _skip(b_name):
continue
b_col = columns[b_name]
b_vals = b_col.get("values", [])
b_type = b_col.get("type", "categorical")
b_numeric = _is_numeric_type(b_type)
extra: dict = {}
# Medida comun no-lineal para todos los pares.
mi = mutual_info_columns(
a_vals,
b_vals,
a_numeric=a_numeric,
b_numeric=b_numeric,
normalized=True,
)
extra["mi"] = mi
if a_numeric and b_numeric:
method = "pearson/spearman"
cx, cy = _clean_numeric_pairs(a_vals, b_vals)
p = pearson(cx, cy)
s = spearman_corr(a_vals, b_vals)
extra["pearson"] = p
extra["spearman"] = s
value = p if abs(p) >= abs(s) else s
elif (not a_numeric) and (not b_numeric):
method = "cramers_v"
value = cramers_v(a_vals, b_vals)
extra["u_ab"] = theils_u(a_vals, b_vals)
extra["u_ba"] = theils_u(b_vals, a_vals)
else:
method = "correlation_ratio"
if a_numeric:
# a numerica, b categorica.
value = correlation_ratio(b_vals, a_vals)
else:
# a categorica, b numerica.
value = correlation_ratio(a_vals, b_vals)
pairs.append(
{
"a": a_name,
"b": b_name,
"a_type": a_type,
"b_type": b_type,
"method": method,
"value": value,
"extra": extra,
}
)
def _relevance(pair: dict) -> float:
return max(abs(pair["value"]), pair["extra"].get("mi", 0.0))
strong = [
pair
for pair in pairs
if abs(pair["value"]) >= strong_threshold
or pair["extra"].get("mi", 0.0) >= strong_threshold
]
strong.sort(key=_relevance, reverse=True)
strong = strong[:top_n]
return {"pairs": pairs, "strong": strong, "methods_legend": legend}
@@ -0,0 +1,82 @@
"""Tests para association_matrix."""
from datascience import association_matrix
def _find_pair(pairs, a, b):
"""Devuelve el par (a, b) sin importar el orden en que aparezca, o None."""
for p in pairs:
if {p["a"], p["b"]} == {a, b}:
return p
return None
def test_two_correlated_numerics_strong_pearson():
columns = {
"size": {"values": [1, 2, 3, 4, 5, 6, 7, 8], "type": "numeric"},
"price": {
"values": [2.1, 4.0, 5.9, 8.1, 10.0, 12.2, 13.8, 16.1],
"type": "numeric",
},
}
result = association_matrix(columns, strong_threshold=0.5)
pair = _find_pair(result["pairs"], "size", "price")
assert pair is not None
assert pair["method"] == "pearson/spearman"
assert abs(pair["value"]) > 0.95
assert "pearson" in pair["extra"] and "spearman" in pair["extra"]
# El par fuertemente correlado aparece en strong.
assert _find_pair(result["strong"], "size", "price") is not None
def test_numeric_explained_by_category_strong_correlation_ratio():
columns = {
"region": {
"values": ["N", "N", "S", "S", "E", "E", "W", "W"],
"type": "categorical",
},
"score": {
"values": [10.0, 11.0, 50.0, 49.0, 90.0, 91.0, 30.0, 31.0],
"type": "numeric",
},
}
result = association_matrix(columns, strong_threshold=0.5)
pair = _find_pair(result["pairs"], "region", "score")
assert pair is not None
assert pair["method"] == "correlation_ratio"
# La categoria explica casi toda la varianza de la numerica.
assert pair["value"] > 0.9
assert _find_pair(result["strong"], "region", "score") is not None
def test_independent_pair_not_strong():
# x e y construidos para ser practicamente independientes (sin relacion).
columns = {
"x": {"values": [1, 2, 1, 2, 1, 2, 1, 2], "type": "numeric"},
"y": {"values": [5, 5, 5, 5, 5, 5, 5, 6], "type": "numeric"},
}
result = association_matrix(columns, strong_threshold=0.5)
pair = _find_pair(result["pairs"], "x", "y")
assert pair is not None
# Ni la metrica principal ni la MI superan el umbral fuerte.
assert abs(pair["value"]) < 0.5
assert pair["extra"]["mi"] < 0.5
assert _find_pair(result["strong"], "x", "y") is None
def test_empty_dict_does_not_crash():
result = association_matrix({})
assert result["pairs"] == []
assert result["strong"] == []
assert "methods_legend" in result
assert "pearson" in result["methods_legend"]
def test_single_column_returns_empty():
columns = {"only": {"values": [1, 2, 3, 4], "type": "numeric"}}
result = association_matrix(columns)
assert result["pairs"] == []
assert result["strong"] == []
@@ -0,0 +1,74 @@
---
name: build_eda_notebook
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def build_eda_notebook(db_path: str, table: str, notebook_path: str, run_models: bool = False, run_llm: bool = False) -> dict"
description: "Genera un notebook Jupyter de EDA (nbformat v4) para una tabla DuckDB usando el grupo eda. Escribe el .ipynb a disco listo para abrir/ejecutar; no ejecuta el notebook. dict-no-throw."
tags: [eda, notebook, jupyter, datascience, duckdb, profiling]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, os]
params:
- name: db_path
desc: "Ruta al archivo DuckDB que contiene la tabla a perfilar. Se referencia dentro del notebook, no se abre en esta funcion."
- name: table
desc: "Nombre de la tabla DuckDB a perfilar."
- name: notebook_path
desc: "Ruta de salida del .ipynb. El directorio padre se crea si no existe."
- name: run_models
desc: "Si True, añade celda con prof['models'] (PCA explained_variance_ratio, kmeans best_k, outliers n_outliers) y pasa run_models=True a profile_table dentro del notebook. Default False."
- name: run_llm
desc: "Si True, añade celda que llama eda_llm_insights(prof) para insights generados por LLM. Default False."
output: "dict. En exito {status:'ok', notebook_path:str, n_cells:int}. En error {status:'error', error:str}."
tested: true
tests: ["genera notebook ok", "notebook es json nbformat valido", "run_models añade celda de modelos", "run_llm añade celda de insights", "sin flags no añade celdas opcionales", "crea directorio padre"]
test_file_path: "python/functions/datascience/build_eda_notebook_test.py"
file_path: "python/functions/datascience/build_eda_notebook.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.build_eda_notebook import build_eda_notebook
r = build_eda_notebook(
db_path="/home/enmanuel/data/ventas.duckdb",
table="cubo_ventas",
notebook_path="/tmp/eda_demo.ipynb",
run_models=True,
run_llm=False,
)
# {'status': 'ok', 'notebook_path': '/tmp/eda_demo.ipynb', 'n_cells': 10}
# Luego se abre/ejecuta en Jupyter; este paso solo escribe el .ipynb.
```
## Cuando usarla
Cuando quieras entregar un EDA como **notebook ejecutable** (no un report estatico):
perfilar una tabla DuckDB con el grupo `eda` y dejar un `.ipynb` listo. El notebook
se lanza despues en Jupyter colaborativo con las funciones del grupo `notebook`
(`jupyter_discover` / `jupyter_exec` / `jupyter_write`) y el usuario lo ve ejecutarse
en vivo. Es la base de la entrega "analysis EDA".
## Gotchas
- **Impura**: escribe un archivo `.ipynb` a `notebook_path` (crea el directorio padre).
- **NO ejecuta el notebook**: solo emite las celdas. La ejecucion la hace Jupyter despues.
- Las celdas asumen que `python/functions` del registry esta accesible desde el kernel:
el startup `00_fn_registry.py` del analysis lo expone, o como fallback la primera celda
inserta `~/fn_registry/python/functions` en `sys.path`. Si el repo no esta ahi y el
kernel no lo expone, las celdas de import fallaran al ejecutarse (no al generar).
- `profile_table` se invoca con `write_report=False` dentro del notebook: no toca disco
para reports, el perfil vive en la variable `prof`.
- `run_llm=True` emite una celda que llama `eda_llm_insights`, que requiere token OAuth
de Claude disponible para el kernel; sin el, esa celda fallara al ejecutarse.
- dict-no-throw: cualquier fallo de escritura se devuelve como `{status:'error', error}`,
no se propaga excepcion.
@@ -0,0 +1,194 @@
"""Genera un notebook Jupyter de EDA (nbformat v4) para una tabla DuckDB.
Construye un .ipynb listo para abrir/ejecutar que perfila una tabla con el
grupo `eda` del registry (profile_table + render_eda_markdown + run_eda_models +
eda_llm_insights). La funcion NO ejecuta el notebook: solo escribe el archivo
con las celdas. Es la base de la entrega "analysis EDA" que luego se lanza en el
navegador colaborativo con las funciones del grupo `notebook`.
"""
import json
import os
def _code_cell(source: str) -> dict:
"""Construye una celda de codigo nbformat v4."""
return {
"cell_type": "code",
"source": source,
"metadata": {},
"outputs": [],
"execution_count": None,
}
def _markdown_cell(source: str) -> dict:
"""Construye una celda markdown nbformat v4."""
return {"cell_type": "markdown", "source": source, "metadata": {}}
def build_eda_notebook(
db_path: str,
table: str,
notebook_path: str,
run_models: bool = False,
run_llm: bool = False,
) -> dict:
"""Genera un notebook Jupyter de EDA para una tabla DuckDB.
Construye un dict nbformat v4 (a mano, sin depender de la libreria nbformat)
con celdas que perfilan la tabla usando el grupo `eda` del registry, lo
serializa como JSON a disco y devuelve un resumen. NO ejecuta el notebook.
Args:
db_path: ruta al archivo DuckDB que contiene la tabla a perfilar.
table: nombre de la tabla a perfilar dentro de la DuckDB.
notebook_path: ruta de salida del .ipynb. El directorio padre se crea
si no existe.
run_models: si True, añade una celda que muestra prof["models"]
(PCA explained_variance_ratio, kmeans best_k, outliers n_outliers).
Tambien pasa run_models=True a profile_table dentro del notebook.
run_llm: si True, añade una celda que llama eda_llm_insights(prof) para
obtener insights generados por LLM.
Returns:
dict. En exito: {status:'ok', notebook_path: str, n_cells: int}.
En error (sin lanzar): {status:'error', error: str}.
"""
try:
cells = []
# 1) Titulo.
cells.append(
_markdown_cell(
f"# EDA — {table}\nGenerado por el grupo `eda` del registry."
)
)
# 2) Setup: sys.path + import de profile_table.
cells.append(
_code_cell(
"import sys, os\n"
"# El kernel startup del analysis (00_fn_registry.py) ya suele\n"
"# exponer python/functions en sys.path. Como fallback asumimos\n"
"# el repo en ~/fn_registry.\n"
'_fns = os.path.join(os.path.expanduser("~"), "fn_registry", "python", "functions")\n'
"if _fns not in sys.path:\n"
" sys.path.insert(0, _fns)\n"
"from pipelines.profile_table import profile_table"
)
)
# 3) Perfilar la tabla.
cells.append(
_code_cell(
f"r = profile_table({db_path!r}, {table!r}, run_models={run_models}, write_report=False)\n"
'prof = r["profile"]\n'
'prof["n_rows"], prof["n_cols"], prof["quality_score"]'
)
)
# 4) Report markdown renderizado.
cells.append(
_code_cell(
"from datascience import render_eda_markdown\n"
"from IPython.display import Markdown, display\n"
"display(Markdown(render_eda_markdown(prof)))"
)
)
# 5) Tabla de columnas con pandas.
cells.append(
_code_cell(
"import pandas as pd\n"
"pd.DataFrame([\n"
" {k: c.get(k) for k in (\n"
' "name", "inferred_type", "semantic_type", "null_pct",\n'
' "distinct_count", "unique_pct", "quality_score",\n'
" )}\n"
' for c in prof["columns"]\n'
"])"
)
)
# 6) Correlaciones fuertes.
cells.append(
_code_cell(
'corr = prof.get("correlations")\n'
'pd.DataFrame(corr["strong"]) if corr and corr.get("strong") else "sin correlaciones fuertes"'
)
)
# 7) Modelos (solo si run_models).
if run_models:
cells.append(
_markdown_cell("## Modelos no supervisados")
)
cells.append(
_code_cell(
'models = prof.get("models") or {}\n'
'pca = models.get("pca") or {}\n'
'kmeans = models.get("kmeans") or {}\n'
'outliers = models.get("outliers") or {}\n'
"{\n"
' "pca_explained_variance_ratio": pca.get("explained_variance_ratio"),\n'
' "kmeans_best_k": kmeans.get("best_k"),\n'
' "outliers_n_outliers": outliers.get("n_outliers"),\n'
"}"
)
)
# 8) Insights LLM (solo si run_llm).
if run_llm:
cells.append(_markdown_cell("## Insights (LLM)"))
cells.append(
_code_cell(
"from datascience import eda_llm_insights\n"
"ins = eda_llm_insights(prof)\n"
"ins"
)
)
# 9) Notas finales.
cells.append(
_markdown_cell(
"## Notas\n\n"
"- Este notebook fue generado por `build_eda_notebook` del grupo `eda`.\n"
"- Ejecuta las celdas en orden. La primera celda de codigo asume que\n"
" python/functions del registry esta en `sys.path` (kernel startup\n"
" del analysis o `~/fn_registry`).\n"
"- `profile_table` se llama con `write_report=False`: no escribe reports\n"
" a disco, todo el perfil vive en la variable `prof`.\n"
"- Para regenerar con modelos o insights LLM, vuelve a llamar a\n"
" `build_eda_notebook(..., run_models=True, run_llm=True)`."
)
)
notebook = {
"cells": cells,
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3",
},
"language_info": {"name": "python"},
},
"nbformat": 4,
"nbformat_minor": 5,
}
parent = os.path.dirname(os.path.abspath(notebook_path))
if parent:
os.makedirs(parent, exist_ok=True)
with open(notebook_path, "w", encoding="utf-8") as f:
json.dump(notebook, f, indent=1)
return {
"status": "ok",
"notebook_path": notebook_path,
"n_cells": len(cells),
}
except Exception as exc: # noqa: BLE001 - dict-no-throw
return {"status": "error", "error": str(exc)}
@@ -0,0 +1,93 @@
"""Tests para build_eda_notebook.
No ejecuta el notebook generado: solo valida que el .ipynb se escribe como JSON
nbformat v4 valido y que las celdas opcionales (modelos / LLM) aparecen segun
los flags. La validacion del contenido se hace sobre el dict deserializado.
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from functions.datascience.build_eda_notebook import build_eda_notebook
def _load(path: str) -> dict:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def test_genera_notebook_ok(tmp_path):
out = str(tmp_path / "eda.ipynb")
r = build_eda_notebook("/tmp/x.duckdb", "ventas", out)
assert r["status"] == "ok"
assert r["notebook_path"] == out
assert os.path.exists(out)
assert r["n_cells"] >= 1
def test_notebook_es_json_nbformat_valido(tmp_path):
out = str(tmp_path / "eda.ipynb")
r = build_eda_notebook("/tmp/x.duckdb", "ventas", out)
assert r["status"] == "ok"
nb = _load(out)
assert nb["nbformat"] == 4
assert isinstance(nb.get("cells"), list)
assert len(nb["cells"]) > 0
# Cada celda tiene cell_type valido.
for cell in nb["cells"]:
assert cell["cell_type"] in ("code", "markdown")
# n_cells coincide con las celdas del archivo.
assert r["n_cells"] == len(nb["cells"])
# El titulo referencia la tabla.
assert any(
c["cell_type"] == "markdown" and "ventas" in "".join(c["source"])
for c in nb["cells"]
)
def test_run_models_anade_celda_de_modelos(tmp_path):
out = str(tmp_path / "eda.ipynb")
base = build_eda_notebook("/tmp/x.duckdb", "ventas", out, run_models=False)
out2 = str(tmp_path / "eda_models.ipynb")
r = build_eda_notebook("/tmp/x.duckdb", "ventas", out2, run_models=True)
assert r["status"] == "ok"
nb = _load(out2)
sources = "".join("".join(c["source"]) for c in nb["cells"])
assert "models" in sources
assert "explained_variance_ratio" in sources
assert "best_k" in sources
assert "n_outliers" in sources
# run_models=True añade celdas respecto al base.
assert r["n_cells"] > base["n_cells"]
# profile_table dentro del notebook usa run_models=True.
assert "run_models=True" in sources
def test_run_llm_anade_celda_de_insights(tmp_path):
out = str(tmp_path / "eda_llm.ipynb")
r = build_eda_notebook("/tmp/x.duckdb", "ventas", out, run_llm=True)
assert r["status"] == "ok"
nb = _load(out)
sources = "".join("".join(c["source"]) for c in nb["cells"])
assert "eda_llm_insights" in sources
def test_sin_flags_no_anade_celdas_opcionales(tmp_path):
out = str(tmp_path / "eda_plain.ipynb")
r = build_eda_notebook("/tmp/x.duckdb", "ventas", out)
assert r["status"] == "ok"
nb = _load(out)
sources = "".join("".join(c["source"]) for c in nb["cells"])
assert "eda_llm_insights" not in sources
assert "explained_variance_ratio" not in sources
def test_crea_directorio_padre(tmp_path):
out = str(tmp_path / "nested" / "deep" / "eda.ipynb")
r = build_eda_notebook("/tmp/x.duckdb", "ventas", out)
assert r["status"] == "ok"
assert os.path.exists(out)
@@ -0,0 +1,130 @@
---
id: build_join_graph_py_datascience
name: build_join_graph
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def build_join_graph(fk_candidates: list, tables: list = None) -> dict"
description: "Construye un grafo de relaciones inter-tabla a partir de FK candidatas (salida fk_candidates de infer_fk_containment_duckdb): nodos con grados y rol (fact/dimension/bridge/standalone), aristas por FK, hubs (candidatas a tabla de hechos) y un diagrama Mermaid graph LR pegable. Funcion pura, sin deps externas, no muta el input."
tags: [eda, relations, join, schema, graph, mermaid, star-schema, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from datascience import build_join_graph
fks = [
{"from_table": "orders", "from_col": "customer_id",
"to_table": "customers", "to_col": "id",
"inclusion": 1.0, "cardinality": "many-to-one"},
{"from_table": "orders", "from_col": "product_id",
"to_table": "products", "to_col": "id",
"inclusion": 0.98, "cardinality": "many-to-one"},
]
g = build_join_graph(fks)
# g["hubs"] == ["orders"]; orders -> role "fact", customers/products -> "dimension"
print(g["mermaid"])
tested: true
tests:
- "test_star_schema_roles_and_hub"
- "test_two_edges_built"
- "test_mermaid_contains_tables_and_arrows"
- "test_bridge_role"
- "test_standalone_node_from_tables_list"
- "test_empty_list_does_not_crash"
- "test_none_input_does_not_crash"
- "test_malformed_entries_skipped"
- "test_does_not_mutate_input"
test_file_path: "python/functions/datascience/build_join_graph_test.py"
file_path: "python/functions/datascience/build_join_graph.py"
params:
- name: fk_candidates
desc: >
lista de dicts, cada uno una FK candidata con al menos las claves
from_table, from_col, to_table, to_col, inclusion, cardinality. Suele ser
la salida `fk_candidates` de infer_fk_containment_duckdb. Las claves se
leen de forma defensiva con .get(...); entradas que no son dict o que no
tienen from_table/to_table se ignoran sin fallar. None se trata como [].
- name: tables
desc: >
lista opcional de nombres de TODAS las tablas. Sirve para incluir como
nodos aislados (role "standalone") las tablas que no aparecen en ninguna
FK. Si es None, los nodos se derivan solo de las aristas.
output: >
dict con nodes (list[dict] con table, out_degree, in_degree, role donde role
es "fact"|"dimension"|"bridge"|"standalone"), edges (list[dict] con
from_table, from_col, to_table, to_col, inclusion, cardinality, una por FK
valida), mermaid (str con un diagrama `graph LR` pegable en un bloque
```mermaid, una arista por FK etiquetada `from_col->to_col`) y hubs (list[str]
de tablas con out_degree>0 ordenadas por out_degree descendente, candidatas a
tabla de hechos / star schema).
---
## Ejemplo
```python
from datascience import build_join_graph
# fk_candidates concreto: orders apunta a customers y a products (estrella).
fks = [
{"from_table": "orders", "from_col": "customer_id",
"to_table": "customers", "to_col": "id",
"inclusion": 1.0, "cardinality": "many-to-one"},
{"from_table": "orders", "from_col": "product_id",
"to_table": "products", "to_col": "id",
"inclusion": 0.98, "cardinality": "many-to-one"},
]
g = build_join_graph(fks)
g["hubs"] # ["orders"]
# nodes: orders -> role "fact" (out_degree 2, in_degree 0),
# customers/products -> role "dimension" (in_degree 1, out_degree 0)
print(g["mermaid"])
```
El campo `mermaid` se pega tal cual en un bloque ```mermaid:
```mermaid
graph LR
orders["orders"] -->|customer_id->id| customers["customers"]
orders["orders"] -->|product_id->id| products["products"]
```
## Cuando usarla
Cuando hayas inferido las foreign keys de una base de datos con
`infer_fk_containment_duckdb` (grupo `eda`) y necesites **visualizar el esquema
relacional**: ver de un vistazo que tabla es la de hechos (hub/star schema),
cuales son dimensiones y cuales quedan sueltas. Devuelve un diagrama Mermaid
pegable en docs, un report o un dashboard, mas el grafo en dict para razonar
sobre los grados (priorizar joins, detectar tablas puente, planear el modelo
dimensional). Es la capa de grafo sobre las FK crudas: lee las candidatas, no
toca la base de datos.
## Notas
Funcion pura, sin I/O ni dependencias externas (solo stdlib), no muta
`fk_candidates`. Tolera lista vacia o `None` (devuelve grafo vacio con un
mermaid minimo `graph LR` con nota `empty`) y entradas malformadas (no-dict o
sin from_table/to_table se ignoran).
Heuristica de `role` por nodo, basada solo en grados:
- **fact** — `out_degree > 0` y `in_degree == 0`: apunta a otras tablas y nadie
le apunta. Es la candidata a tabla de hechos.
- **dimension** — `in_degree > 0` y `out_degree == 0`: solo recibe referencias
(tabla maestra / catalogo).
- **bridge** — `out_degree > 0` e `in_degree > 0`: apunta y recibe (tabla puente
o asociativa de una relacion many-to-many).
- **standalone** — sin aristas (solo aparece si se paso en `tables`).
`hubs` ordena por `out_degree` descendente las tablas con `out_degree > 0`. Para
un star schema limpio, `hubs[0]` es la tabla de hechos. Los IDs de nodo en el
Mermaid se sanean (no-alfanumerico -> `_`) pero la etiqueta visible conserva el
nombre original de la tabla.
```
@@ -0,0 +1,171 @@
"""Construye un grafo de relaciones inter-tabla a partir de FK candidatas.
Toma la lista `fk_candidates` (salida de infer_fk_containment_duckdb) y produce un
grafo de relaciones: nodos (tablas) con grados y rol inferido (fact/dimension/
bridge/standalone), aristas (una por FK), un diagrama Mermaid pegable y la lista
de hubs (tablas con mayor out_degree, candidatas a tabla de hechos / star schema).
Funcion pura: lista de dicts -> dict de grafo. Sin I/O ni dependencias externas.
"""
def _mermaid_id(name: str) -> str:
"""Sanea un nombre de tabla para usarlo como identificador Mermaid.
Mermaid no admite espacios, guiones ni puntos en los IDs de nodo. Se sustituyen
por guion bajo. El nombre original se conserva como etiqueta visible del nodo.
"""
safe = []
for ch in str(name):
safe.append(ch if (ch.isalnum() or ch == "_") else "_")
out = "".join(safe)
if not out:
out = "node"
if out[0].isdigit():
out = "t_" + out
return out
def build_join_graph(fk_candidates: list, tables: list = None) -> dict:
"""Construye un grafo de relaciones inter-tabla desde FK candidatas.
Args:
fk_candidates: lista de dicts, cada uno una FK candidata con al menos
las claves from_table, from_col, to_table, to_col, inclusion,
cardinality. Claves ausentes se toleran con .get(...). Suele ser la
salida `fk_candidates` de infer_fk_containment_duckdb.
tables: lista opcional de nombres de TODAS las tablas. Sirve para incluir
como nodos aislados (role "standalone") las tablas que no aparecen en
ninguna FK. Si es None, los nodos se derivan solo de las aristas.
Returns:
dict con:
- nodes: list[dict] con table, out_degree, in_degree, role
(role: "fact" | "dimension" | "bridge" | "standalone").
- edges: list[dict] con from_table, from_col, to_table, to_col,
inclusion, cardinality (una por FK valida).
- mermaid: str con un diagrama `graph LR` pegable en un bloque
```mermaid, una arista por FK etiquetada con las columnas.
- hubs: list[str] de tablas con mayor out_degree (>0), ordenadas por
out_degree descendente. Candidatas a tabla de hechos.
"""
fk_candidates = fk_candidates or []
out_degree: dict = {}
in_degree: dict = {}
node_order: list = []
def _ensure(name) -> None:
if name is None:
return
if name not in out_degree:
out_degree[name] = 0
in_degree[name] = 0
node_order.append(name)
# Sembrar nodos aislados si se pasaron todas las tablas.
for t in tables or []:
_ensure(t)
edges: list = []
for fk in fk_candidates:
if not isinstance(fk, dict):
continue
ft = fk.get("from_table")
tt = fk.get("to_table")
if ft is None or tt is None:
continue
_ensure(ft)
_ensure(tt)
out_degree[ft] += 1
in_degree[tt] += 1
edges.append(
{
"from_table": ft,
"from_col": fk.get("from_col"),
"to_table": tt,
"to_col": fk.get("to_col"),
"inclusion": fk.get("inclusion"),
"cardinality": fk.get("cardinality"),
}
)
nodes: list = []
for name in node_order:
od = out_degree[name]
ind = in_degree[name]
if od == 0 and ind == 0:
role = "standalone"
elif od > 0 and ind == 0:
# Apunta a otras tablas pero nadie le apunta: tabla de hechos.
role = "fact"
elif od == 0 and ind > 0:
# Solo recibe referencias: tabla de dimension / maestra.
role = "dimension"
else:
# Apunta y recibe: tabla puente / asociativa.
role = "bridge"
nodes.append(
{
"table": name,
"out_degree": od,
"in_degree": ind,
"role": role,
}
)
max_out = max((n["out_degree"] for n in nodes), default=0)
hubs: list = []
if max_out > 0:
hubs = [
n["table"]
for n in sorted(
(n for n in nodes if n["out_degree"] > 0),
key=lambda n: n["out_degree"],
reverse=True,
)
]
mermaid = _build_mermaid(nodes, edges)
return {"nodes": nodes, "edges": edges, "mermaid": mermaid, "hubs": hubs}
def _build_mermaid(nodes: list, edges: list) -> str:
"""Renderiza el grafo como un diagrama Mermaid `graph LR` pegable.
Una arista por FK, etiquetada con `from_col->to_col`. Los nodos aislados se
declaran sueltos para que aparezcan en el diagrama. Si no hay nodos ni
aristas, devuelve un diagrama minimo valido con una nota.
"""
lines = ["graph LR"]
if not nodes and not edges:
lines.append(" empty[No relations]")
return "\n".join(lines)
# Declarar nodos aislados (sin ninguna arista) para que se rendericen.
connected = set()
for e in edges:
connected.add(e["from_table"])
connected.add(e["to_table"])
for n in nodes:
name = n["table"]
if name not in connected:
nid = _mermaid_id(name)
lines.append(f' {nid}["{name}"]')
for e in edges:
ft = e["from_table"]
tt = e["to_table"]
fc = e.get("from_col")
tc = e.get("to_col")
label = f"{fc}->{tc}" if (fc is not None and tc is not None) else ""
fid = _mermaid_id(ft)
tid = _mermaid_id(tt)
if label:
lines.append(f' {fid}["{ft}"] -->|{label}| {tid}["{tt}"]')
else:
lines.append(f' {fid}["{ft}"] --> {tid}["{tt}"]')
return "\n".join(lines)
@@ -0,0 +1,123 @@
"""Tests para build_join_graph."""
from build_join_graph import build_join_graph
def _star_fks():
"""Esquema en estrella: orders apunta a customers y a products."""
return [
{
"from_table": "orders",
"from_col": "customer_id",
"to_table": "customers",
"to_col": "id",
"inclusion": 1.0,
"cardinality": "many-to-one",
},
{
"from_table": "orders",
"from_col": "product_id",
"to_table": "products",
"to_col": "id",
"inclusion": 0.98,
"cardinality": "many-to-one",
},
]
def test_star_schema_roles_and_hub():
g = build_join_graph(_star_fks())
nodes = {n["table"]: n for n in g["nodes"]}
assert nodes["orders"]["role"] == "fact"
assert nodes["orders"]["out_degree"] == 2
assert nodes["orders"]["in_degree"] == 0
assert nodes["customers"]["role"] == "dimension"
assert nodes["customers"]["in_degree"] == 1
assert nodes["customers"]["out_degree"] == 0
assert nodes["products"]["role"] == "dimension"
# orders es el hub (mayor out_degree).
assert g["hubs"][0] == "orders"
def test_two_edges_built():
g = build_join_graph(_star_fks())
assert len(g["edges"]) == 2
pairs = {(e["from_table"], e["to_table"]) for e in g["edges"]}
assert pairs == {("orders", "customers"), ("orders", "products")}
def test_mermaid_contains_tables_and_arrows():
g = build_join_graph(_star_fks())
m = g["mermaid"]
assert "orders" in m
assert "customers" in m
assert "products" in m
assert "-->" in m
# Etiqueta de columnas en la arista.
assert "customer_id->id" in m
def test_bridge_role():
# order_items apunta a orders y products, y nadie le apunta -> fact en este
# subgrafo. Para forzar bridge, hacemos que reciba tambien una FK.
fks = [
{"from_table": "shipments", "from_col": "order_item_id",
"to_table": "order_items", "to_col": "id",
"inclusion": 1.0, "cardinality": "many-to-one"},
{"from_table": "order_items", "from_col": "product_id",
"to_table": "products", "to_col": "id",
"inclusion": 1.0, "cardinality": "many-to-one"},
]
g = build_join_graph(fks)
nodes = {n["table"]: n for n in g["nodes"]}
assert nodes["order_items"]["role"] == "bridge"
assert nodes["order_items"]["in_degree"] == 1
assert nodes["order_items"]["out_degree"] == 1
def test_standalone_node_from_tables_list():
g = build_join_graph(_star_fks(), tables=["orders", "customers", "products", "audit_log"])
nodes = {n["table"]: n for n in g["nodes"]}
assert "audit_log" in nodes
assert nodes["audit_log"]["role"] == "standalone"
assert nodes["audit_log"]["out_degree"] == 0
assert nodes["audit_log"]["in_degree"] == 0
# El nodo aislado aparece declarado en el mermaid.
assert "audit_log" in g["mermaid"]
def test_empty_list_does_not_crash():
g = build_join_graph([])
assert g["nodes"] == []
assert g["edges"] == []
assert g["hubs"] == []
assert g["mermaid"].startswith("graph LR")
def test_none_input_does_not_crash():
g = build_join_graph(None)
assert g["edges"] == []
assert "graph LR" in g["mermaid"]
def test_malformed_entries_skipped():
fks = [
{"from_table": "a", "from_col": "x", "to_table": "b", "to_col": "y"},
{"from_table": "a"}, # falta to_table -> se ignora
"not a dict", # no es dict -> se ignora
{"to_table": "b"}, # falta from_table -> se ignora
]
g = build_join_graph(fks)
assert len(g["edges"]) == 1
assert g["edges"][0]["from_table"] == "a"
def test_does_not_mutate_input():
fks = _star_fks()
snapshot = [dict(fk) for fk in fks]
build_join_graph(fks)
assert fks == snapshot
@@ -0,0 +1,109 @@
---
id: column_quality_score_py_datascience
name: column_quality_score
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def column_quality_score(col: dict) -> dict"
description: "Calcula un score de calidad de datos 0-100 para un ColumnProfile del grupo eda, con desglose completeness/validity/consistency y lista de issues legibles. Funcion pura, no muta el input."
tags: [eda, data-quality, profiling, scoring, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from datascience import column_quality_score
col = {"name": "precio", "inferred_type": "float", "null_pct": 0.2,
"unique_pct": 0.4, "flags": [], "numeric": {"outlier_pct": 0.08}}
column_quality_score(col)
# {"score": 86.8, "completeness": 0.8, "validity": 0.92,
# "consistency": 1.0, "issues": ["20% nulos", "8% outliers"]}
tested: true
tests:
- "test_clean_column_high_score"
- "test_half_null_lowers_completeness_and_score"
- "test_constant_column_flags_issue"
- "test_empty_dict_does_not_crash"
- "test_outliers_penalize_validity"
- "test_mostly_null_flag_halves_validity"
- "test_high_cardinality_text_flagged_as_id"
- "test_none_values_treated_defensively"
- "test_does_not_mutate_input"
test_file_path: "python/functions/datascience/column_quality_score_test.py"
file_path: "python/functions/datascience/column_quality_score.py"
params:
- name: col
desc: >
ColumnProfile dict del grupo eda (p.ej. salida de summarize_table_duckdb).
Se leen sus claves de forma defensiva con .get(...) y se toleran valores
None. Claves usadas: null_pct (0-1), inferred_type, semantic_type,
unique_pct (0-1), flags (list[str], reconoce "constant"/"mostly_null"),
numeric ({outlier_pct: 0-1, ...}|None) y match_rate (opcional, 0-1).
output: >
dict con score (float 0-100, redondeado a 1 decimal), completeness (0-1),
validity (0-1), consistency (0-1) e issues (list[str] de descripciones
legibles de los problemas detectados). score = round(100 * (0.5*completeness
+ 0.3*validity + 0.2*consistency), 1).
---
## Ejemplo
```python
from datascience import column_quality_score
# ColumnProfile de una columna numerica con 20% nulls y 8% outliers.
col = {
"name": "precio",
"physical_type": "DOUBLE",
"inferred_type": "float",
"semantic_type": "",
"count": 800,
"n_rows": 1000,
"null_count": 200,
"null_pct": 0.20,
"distinct_count": 400,
"unique_pct": 0.40,
"flags": [],
"numeric": {"outlier_pct": 0.08},
"categorical": None,
"datetime": None,
}
column_quality_score(col)
# {
# "score": 86.8,
# "completeness": 0.8, # 1 - 0.20
# "validity": 0.92, # 1 - min(0.08, 0.3)
# "consistency": 1.0,
# "issues": ["20% nulos", "8% outliers"],
# }
```
## Cuando usarla
Cuando hayas perfilado una tabla con el grupo `eda` (p.ej.
`summarize_table_duckdb`) y necesites un numero 0-100 por columna para
ordenar/priorizar limpieza de datos, pintar semaforos de calidad en un
dashboard, o decidir que columnas descartar antes de modelar. Es la capa de
scoring sobre el ColumnProfile crudo: lee el perfil, no toca los datos.
## Notas
Funcion pura, sin I/O ni dependencias externas, no muta `col`. Lee todas las
claves con `.get(...)` y tolera que vengan en `None` (un ColumnProfile recien
salido de `summarize_table_duckdb` trae muchas claves a `None`), por lo que
nunca falla por claves ausentes — un `{}` produce un resultado bien definido.
Pesos del score: completeness 0.5, validity 0.3, consistency 0.2.
- **completeness** = `1 - null_pct` (None -> 0 nulls -> 1.0).
- **validity**: parte de 1.0 y penaliza `min(outlier_pct, 0.3)` en columnas
numericas, `0.5 * (1 - match_rate)` si hay `semantic_type` declarado con
`match_rate` bajo disponible, y multiplica por 0.5 si el flag `mostly_null`
esta presente.
- **consistency**: 1.0 salvo flag `constant` (-> 0.3, columna poco informativa)
o texto con `unique_pct > 0.9` (-> 0.6, posible id de alta cardinalidad).
@@ -0,0 +1,145 @@
"""Score de calidad de datos (0-100) para un ColumnProfile del grupo eda.
Funcion pura: dado el perfil de una columna producido por el grupo de
capacidad `eda` (p.ej. summarize_table_duckdb), calcula un score agregado
de calidad junto a su desglose en completeness / validity / consistency y
una lista de issues legibles. No realiza I/O ni muta el input.
"""
def column_quality_score(col: dict) -> dict:
"""Calcula un score de calidad de datos 0-100 para un ColumnProfile.
El score pondera tres dimensiones:
- completeness (0.5): proporcion de valores no nulos.
- validity (0.3): ausencia de outliers / heuristicas de validez.
- consistency (0.2): la columna aporta informacion (no constante, no ruido).
Args:
col: ColumnProfile dict del grupo eda. Se leen las claves de forma
defensiva con .get(...) y se tolera que muchas vengan en None.
Claves relevantes: null_pct, inferred_type, semantic_type,
unique_pct, flags (list[str]), numeric ({outlier_pct, ...}|None),
match_rate (opcional).
Returns:
dict con:
score (float, 0-100, redondeado a 1 decimal),
completeness (float, 0-1),
validity (float, 0-1),
consistency (float, 0-1),
issues (list[str]) descripciones legibles de los problemas.
"""
if not isinstance(col, dict):
col = {}
flags = col.get("flags") or []
if not isinstance(flags, (list, tuple)):
flags = []
flags = set(flags)
issues: list[str] = []
# --- completeness -------------------------------------------------
null_pct = col.get("null_pct")
if null_pct is None:
null_pct = 0.0
try:
null_pct = float(null_pct)
except (TypeError, ValueError):
null_pct = 0.0
null_pct = _clamp(null_pct, 0.0, 1.0)
completeness = 1.0 - null_pct
if null_pct > 0:
issues.append(f"{round(null_pct * 100)}% nulos")
# --- validity -----------------------------------------------------
validity = 1.0
inferred_type = col.get("inferred_type") or ""
numeric = col.get("numeric")
is_numeric = inferred_type in ("integer", "float", "numeric") or isinstance(numeric, dict)
if isinstance(numeric, dict):
outlier_pct = numeric.get("outlier_pct")
if outlier_pct is not None:
try:
outlier_pct = float(outlier_pct)
except (TypeError, ValueError):
outlier_pct = 0.0
outlier_pct = _clamp(outlier_pct, 0.0, 1.0)
if outlier_pct > 0:
penalty = min(outlier_pct, 0.3)
validity -= penalty
issues.append(f"{round(outlier_pct * 100)}% outliers")
# semantic_type declarado pero con baja tasa de match (si la conocemos).
semantic_type = col.get("semantic_type") or ""
match_rate = col.get("match_rate")
if semantic_type and match_rate is not None:
try:
match_rate = float(match_rate)
except (TypeError, ValueError):
match_rate = None
if match_rate is not None:
match_rate = _clamp(match_rate, 0.0, 1.0)
if match_rate < 1.0:
shortfall = 1.0 - match_rate
validity -= 0.5 * shortfall
issues.append(
f"semantic_type '{semantic_type}' con baja coincidencia "
f"({round(match_rate * 100)}%)"
)
if "mostly_null" in flags:
validity *= 0.5
issues.append("mayoritariamente nula")
validity = _clamp(validity, 0.0, 1.0)
# --- consistency --------------------------------------------------
consistency = 1.0
if "constant" in flags:
consistency = 0.3
issues.append("columna constante")
else:
unique_pct = col.get("unique_pct")
if unique_pct is not None:
try:
unique_pct = float(unique_pct)
except (TypeError, ValueError):
unique_pct = None
if (
inferred_type == "text"
and unique_pct is not None
and _clamp(unique_pct, 0.0, 1.0) > 0.9
):
consistency = 0.6
issues.append("posible id de alta cardinalidad")
consistency = _clamp(consistency, 0.0, 1.0)
# --- score agregado ----------------------------------------------
score = round(
100.0 * (0.5 * completeness + 0.3 * validity + 0.2 * consistency),
1,
)
# Silencia warnings sobre la variable de tipo no usada.
_ = is_numeric
return {
"score": score,
"completeness": completeness,
"validity": validity,
"consistency": consistency,
"issues": issues,
}
def _clamp(x: float, lo: float, hi: float) -> float:
"""Recorta x al rango [lo, hi]."""
if x < lo:
return lo
if x > hi:
return hi
return x
@@ -0,0 +1,122 @@
"""Tests para column_quality_score."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from column_quality_score import column_quality_score
def _clean_numeric_col() -> dict:
"""ColumnProfile de una columna numerica sana, sin problemas."""
return {
"name": "edad",
"physical_type": "INTEGER",
"inferred_type": "integer",
"semantic_type": "",
"count": 1000,
"n_rows": 1000,
"null_count": 0,
"null_pct": 0.0,
"distinct_count": 80,
"unique_pct": 0.08,
"flags": [],
"numeric": {"outlier_pct": 0.0},
"categorical": None,
"datetime": None,
}
def test_clean_column_high_score():
out = column_quality_score(_clean_numeric_col())
assert out["score"] > 90
assert out["completeness"] == 1.0
assert out["validity"] == 1.0
assert out["consistency"] == 1.0
assert out["issues"] == []
def test_half_null_lowers_completeness_and_score():
col = _clean_numeric_col()
col["null_count"] = 500
col["null_pct"] = 0.5
clean_score = column_quality_score(_clean_numeric_col())["score"]
out = column_quality_score(col)
assert out["completeness"] == 0.5
assert out["score"] < clean_score
assert any("nulos" in issue for issue in out["issues"])
def test_constant_column_flags_issue():
col = _clean_numeric_col()
col["flags"] = ["constant"]
col["distinct_count"] = 1
col["unique_pct"] = 0.001
out = column_quality_score(col)
assert out["consistency"] == 0.3
assert any("constante" in issue for issue in out["issues"])
def test_empty_dict_does_not_crash():
out = column_quality_score({})
assert isinstance(out["score"], float)
assert out["completeness"] == 1.0
assert 0.0 <= out["score"] <= 100.0
assert isinstance(out["issues"], list)
def test_outliers_penalize_validity():
col = _clean_numeric_col()
col["numeric"] = {"outlier_pct": 0.2}
out = column_quality_score(col)
assert out["validity"] < 1.0
assert any("outliers" in issue for issue in out["issues"])
def test_mostly_null_flag_halves_validity():
col = _clean_numeric_col()
col["null_pct"] = 0.85
col["flags"] = ["mostly_null"]
out = column_quality_score(col)
assert out["validity"] == 0.5
assert any("mayoritariamente nula" in issue for issue in out["issues"])
def test_high_cardinality_text_flagged_as_id():
col = {
"name": "uuid",
"inferred_type": "text",
"semantic_type": "",
"null_pct": 0.0,
"unique_pct": 0.99,
"flags": [],
"numeric": None,
}
out = column_quality_score(col)
assert out["consistency"] < 1.0
assert any("alta cardinalidad" in issue for issue in out["issues"])
def test_none_values_treated_defensively():
col = {
"name": "x",
"inferred_type": None,
"semantic_type": None,
"null_pct": None,
"unique_pct": None,
"flags": None,
"numeric": None,
}
out = column_quality_score(col)
assert out["completeness"] == 1.0
assert isinstance(out["score"], float)
def test_does_not_mutate_input():
col = _clean_numeric_col()
col["flags"] = ["constant"]
before = {k: (list(v) if isinstance(v, list) else v) for k, v in col.items()}
column_quality_score(col)
assert col["flags"] == before["flags"]
assert col == before
@@ -0,0 +1,74 @@
---
name: correlation_matrix_duckdb
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def correlation_matrix_duckdb(db_path: str, table: str, columns: list = None, strong_threshold: float = 0.7) -> dict"
description: "Matriz de correlacion de Pearson entre columnas numericas de una tabla DuckDB calculada con push-down SQL (funcion nativa corr()), sin traer filas a RAM. Apta para tablas grandes donde no quieres muestrear en Python."
tags: [eda, correlation, duckdb, pearson, datascience, push-down]
uses_functions: [duckdb_table_schema_py_infra, duckdb_query_readonly_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: db_path
desc: "Ruta al archivo DuckDB. Debe existir; el modo read_only NO crea la base."
- name: table
desc: "Nombre de la tabla. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ y se interpola citado (DuckDB no admite parametros para identificadores)."
- name: columns
desc: "Lista de columnas numericas a correlacionar. None (default) = autodescubre las columnas de tipo numerico DuckDB leyendo el schema."
- name: strong_threshold
desc: "Umbral en valor absoluto para marcar una pareja como fuerte (default 0.7). Pares con abs(corr) >= threshold se devuelven en `strong`."
output: "dict. En exito {status:'ok', columns:[...], matrix:{a:{b:corr}}, pairs:[{a,b,corr}], strong:[{a,b,corr}]} con corr float o None (columna constante / <2 valores -> corr() = NULL); strong omite los None y va ordenado por abs(corr) desc. En error {status:'error', error:str} (no lanza)."
tested: true
tests: ["correla dos columnas linealmente dependientes y aparece en strong", "columna constante no rompe y queda fuera de strong", "tabla con menos de dos columnas numericas devuelve error", "columns explicitas respetan el orden y la matriz es simetrica"]
test_file_path: "python/functions/datascience/correlation_matrix_duckdb_test.py"
file_path: "python/functions/datascience/correlation_matrix_duckdb.py"
---
## Ejemplo
```python
import duckdb
from datascience import correlation_matrix_duckdb
# Crear una tabla DuckDB de prueba con 3 columnas numericas (col_a y col_b correladas).
db = "/tmp/corr_demo.duckdb"
con = duckdb.connect(db)
con.execute("CREATE TABLE m AS SELECT i AS col_a, 2*i AS col_b, (i*7) % 5 AS col_c FROM range(100) t(i)")
con.close()
res = correlation_matrix_duckdb(db, "m")
print(res["status"]) # ok
print(round(res["matrix"]["col_a"]["col_b"], 3)) # ~1.0
print([(p["a"], p["b"]) for p in res["strong"]]) # [('col_a', 'col_b')]
```
## Cuando usarla
Cuando necesitas la correlacion de Pearson entre muchas columnas numericas de una
tabla con MUCHAS filas y NO quieres muestrear ni traerla a RAM con pandas/numpy. Todo
el calculo se hace push-down en el motor de DuckDB con la funcion nativa `corr()`.
Util en el flujo `eda` para detectar pares fuertemente correlados (multicolinealidad)
antes de modelar, o para resumir relaciones lineales en datasets que no caben en memoria.
## Gotchas
- Funcion impura: lee un archivo DuckDB del disco (read_only, nunca lo modifica).
- Solo correlacion de PEARSON (lineal). Para monotona usa `spearman_corr_py_datascience`;
para asociacion categorica `cramers_v_py_datascience`.
- `corr()` de DuckDB ignora las filas con NULL POR PAREJA (pairwise complete): cada
coeficiente usa solo las filas donde ambas columnas son no-NULL, asi que distintos
pares pueden basarse en distinto numero de filas.
- Una columna constante o con menos de 2 valores distintos da varianza cero: DuckDB
devuelve `NaN` (y `NULL` si la tabla esta vacia). Ambos casos se normalizan a
`corr: None`, de modo que ese par se omite de `strong` y la matriz nunca contiene
`NaN` (no rompe ni el orden de `strong`).
- Tabla vacia -> matriz de None (salvo la diagonal 1.0). Menos de 2 columnas numericas
-> `{status:'error'}`.
- La query se ejecuta con `sandbox=False` en `duckdb_query_readonly` (uso interno
confiable: el SQL lo construye esta funcion, no un cliente externo).
@@ -0,0 +1,182 @@
"""correlation_matrix_duckdb — matriz de correlacion de Pearson con push-down SQL.
Funcion impura: lee de disco a traves de DuckDB (via las primitivas read-only del
grupo `duckdb`: `duckdb_table_schema` para descubrir las columnas numericas y
`duckdb_query_readonly` para ejecutar la query de correlacion). Pertenece al grupo
de capacidad `eda` (exploratory data analysis).
Calcula la matriz de correlacion de Pearson entre columnas NUMERICAS de una tabla
DuckDB usando la funcion agregada nativa `corr()` del motor. TODO el calculo ocurre
en el motor de DuckDB (push-down): se construye UN solo SELECT con un `corr()` por
cada pareja (i < j) y se traen unicamente los coeficientes, nunca las filas. Esto la
hace apta para tablas grandes donde muestrear en Python (pandas/numpy) seria caro o
imposible.
Estilo dict-no-throw del grupo duckdb: nunca lanza; captura cualquier error y
devuelve {status:'error', error:str}.
"""
import math
import re
from infra import duckdb_query_readonly, duckdb_table_schema
# Identificador SQL valido. DuckDB no admite parametros posicionales para nombres
# de tabla/columna, asi que hay que validar e interpolar citado con dobles comillas.
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Tipos fisicos DuckDB que mapean a "numeric" y por tanto admiten corr().
_NUMERIC_TYPES = {
"TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT",
"UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "UHUGEINT",
"FLOAT", "REAL", "DOUBLE", "DECIMAL", "NUMERIC",
}
def _base_type(duckdb_type: str) -> str:
"""Normaliza un tipo DuckDB a su nombre base en mayusculas.
DuckDB reporta tipos como 'DECIMAL(18,3)' o 'BIGINT'. Nos quedamos con el
prefijo antes de '(' para mapearlo contra _NUMERIC_TYPES.
"""
return duckdb_type.split("(", 1)[0].strip().upper()
def _quote(ident: str) -> str:
"""Cita un identificador SQL con dobles comillas (ya validado por el regex)."""
return '"' + ident.replace('"', '""') + '"'
def correlation_matrix_duckdb(
db_path: str,
table: str,
columns: list = None,
strong_threshold: float = 0.7,
) -> dict:
"""Matriz de correlacion de Pearson entre columnas numericas, push-down en DuckDB.
Args:
db_path: ruta al archivo DuckDB. Debe existir (read_only NO crea la base).
table: nombre de la tabla. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ antes
de interpolarlo (DuckDB no admite parametros para identificadores).
columns: lista de columnas numericas a correlacionar. Si es None (default),
se descubren automaticamente leyendo el schema de la tabla y quedandose
con las de tipo numerico DuckDB. Cada nombre se valida con el mismo regex.
strong_threshold: umbral en valor absoluto para marcar una pareja como
"fuerte" (default 0.7). Las parejas con abs(corr) >= threshold se devuelven
ademas en `strong`, ordenadas por abs(corr) descendente.
Returns:
dict. En exito:
{status:'ok',
columns:[...], # columnas usadas, en orden
matrix:{a:{b:corr, ...}, ...}, # matriz simetrica; diagonal=1.0
pairs:[{a, b, corr}, ...], # cada pareja i<j una vez
strong:[{a, b, corr}, ...]} # pares con abs(corr)>=threshold
donde corr es float o None (columna constante / <2 valores -> corr() = NULL).
Los pares con corr None se omiten de `strong`. En error (sin lanzar):
{status:'error', error:str}.
"""
# 1. Validar tabla.
if not isinstance(table, str) or not _IDENT_RE.match(table):
return {"status": "error", "error": f"invalid table identifier: {table!r}"}
try:
# 2. Resolver columnas numericas si no se especificaron.
if columns is None:
schema = duckdb_table_schema(db_path, table)
if schema.get("status") != "ok":
return {
"status": "error",
"error": "could not read schema: "
+ str(schema.get("error", "unknown")),
}
columns = [
col["name"]
for col in schema.get("columns", [])
if _base_type(col.get("type", "")) in _NUMERIC_TYPES
]
# Validar cada nombre de columna.
if not isinstance(columns, list):
return {"status": "error", "error": "columns must be a list or None"}
for col in columns:
if not isinstance(col, str) or not _IDENT_RE.match(col):
return {
"status": "error",
"error": f"invalid column identifier: {col!r}",
}
if len(columns) < 2:
return {
"status": "error",
"error": "need at least 2 numeric columns to correlate, got "
+ str(len(columns)),
}
# 3. Construir UNA query con un corr() por pareja (i < j). El alias usa el
# indice de cada columna (c0__c1) para evitar colisiones y nombres invalidos
# cuando los nombres de columna son largos o repiten substrings.
select_terms = []
pair_index = [] # (i, j) en el mismo orden que los terminos del SELECT
for i in range(len(columns)):
for j in range(i + 1, len(columns)):
alias = f"c{i}__c{j}"
select_terms.append(
f"corr({_quote(columns[i])}, {_quote(columns[j])}) AS {alias}"
)
pair_index.append((i, j))
sql = f"SELECT {', '.join(select_terms)} FROM {_quote(table)}"
result = duckdb_query_readonly(db_path, sql, max_rows=1, sandbox=False)
if result.get("status") != "ok":
return {
"status": "error",
"error": "correlation query failed: "
+ str(result.get("error", "unknown")),
}
rows = result.get("rows", [])
if not rows:
# Tabla vacia: corr() de cero filas es NULL; devolvemos matriz de None.
row = {}
else:
row = rows[0]
# 4. Parsear a matriz simetrica.
matrix = {a: {b: None for b in columns} for a in columns}
for a in columns:
matrix[a][a] = 1.0
pairs = []
for term_pos, (i, j) in enumerate(pair_index):
alias = f"c{i}__c{j}"
value = row.get(alias)
# corr() devuelve NULL (cero filas) o NaN (varianza cero: columna
# constante / <2 valores). Ambos casos significan "sin correlacion
# definida": los normalizamos a None para que `strong` y la matriz
# nunca contengan NaN.
if value is None or (isinstance(value, float) and math.isnan(value)):
corr = None
else:
corr = float(value)
a, b = columns[i], columns[j]
matrix[a][b] = corr
matrix[b][a] = corr
pairs.append({"a": a, "b": b, "corr": corr})
strong = sorted(
(p for p in pairs if p["corr"] is not None and abs(p["corr"]) >= strong_threshold),
key=lambda p: abs(p["corr"]),
reverse=True,
)
return {
"status": "ok",
"columns": list(columns),
"matrix": matrix,
"pairs": pairs,
"strong": strong,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
@@ -0,0 +1,108 @@
"""Tests para correlation_matrix_duckdb."""
import os
import sys
import duckdb
# Permitir importar funciones del registry (from infra import ..., from datascience import ...).
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "functions"))
from datascience.correlation_matrix_duckdb import correlation_matrix_duckdb
def _make_db(tmp_name: str) -> str:
"""Crea una DuckDB en /tmp con col_a, col_b=2*col_a (corr ~1) y col_c aleatoria."""
db = os.path.join("/tmp", tmp_name)
if os.path.exists(db):
os.remove(db)
con = duckdb.connect(db)
# col_b = 2*col_a => correlacion de Pearson exactamente 1.0.
# col_c usa un patron pseudo-aleatorio acotado, no perfectamente correlado.
con.execute(
"CREATE TABLE m AS "
"SELECT i AS col_a, 2*i AS col_b, (i*7 + 3) % 11 AS col_c "
"FROM range(200) t(i)"
)
con.close()
return db
def test_correla_dos_columnas_linealmente_dependientes_y_aparece_en_strong():
db = _make_db("corr_test_strong.duckdb")
res = correlation_matrix_duckdb(db, "m")
assert res["status"] == "ok", res
# col_a y col_b son linealmente dependientes -> corr ~1.0.
assert abs(res["matrix"]["col_a"]["col_b"] - 1.0) < 1e-9
assert abs(res["matrix"]["col_b"]["col_a"] - 1.0) < 1e-9
# El par (a, b) debe aparecer en strong (abs(corr) >= 0.7).
strong_pairs = {frozenset((p["a"], p["b"])) for p in res["strong"]}
assert frozenset(("col_a", "col_b")) in strong_pairs
# strong ordenado por abs(corr) descendente.
abs_vals = [abs(p["corr"]) for p in res["strong"]]
assert abs_vals == sorted(abs_vals, reverse=True)
os.remove(db)
def test_columna_constante_no_rompe_y_queda_fuera_de_strong():
db = os.path.join("/tmp", "corr_test_const.duckdb")
if os.path.exists(db):
os.remove(db)
con = duckdb.connect(db)
# col_k es constante => corr() = NULL para cualquier par que la incluya.
con.execute(
"CREATE TABLE m AS "
"SELECT i AS col_a, 2*i AS col_b, 42 AS col_k "
"FROM range(50) t(i)"
)
con.close()
res = correlation_matrix_duckdb(db, "m")
assert res["status"] == "ok", res
# La columna constante produce corr None, no rompe.
assert res["matrix"]["col_a"]["col_k"] is None
assert res["matrix"]["col_k"]["col_b"] is None
# Diagonal sigue siendo 1.0.
assert res["matrix"]["col_k"]["col_k"] == 1.0
# Ningun par con corr None entra en strong.
for p in res["strong"]:
assert p["corr"] is not None
# El par correlado a-b sigue presente en strong.
strong_pairs = {frozenset((p["a"], p["b"])) for p in res["strong"]}
assert frozenset(("col_a", "col_b")) in strong_pairs
os.remove(db)
def test_menos_de_dos_columnas_numericas_devuelve_error():
db = os.path.join("/tmp", "corr_test_few.duckdb")
if os.path.exists(db):
os.remove(db)
con = duckdb.connect(db)
con.execute("CREATE TABLE m AS SELECT i AS col_a, 'x' AS label FROM range(10) t(i)")
con.close()
res = correlation_matrix_duckdb(db, "m")
assert res["status"] == "error", res
assert "at least 2 numeric columns" in res["error"]
os.remove(db)
def test_columns_explicitas_respetan_orden_y_matriz_simetrica():
db = _make_db("corr_test_explicit.duckdb")
res = correlation_matrix_duckdb(db, "m", columns=["col_c", "col_a", "col_b"])
assert res["status"] == "ok", res
assert res["columns"] == ["col_c", "col_a", "col_b"]
# Matriz simetrica.
for a in res["columns"]:
for b in res["columns"]:
assert res["matrix"][a][b] == res["matrix"][b][a]
# pairs contiene cada pareja i<j una sola vez: C(3,2) = 3.
assert len(res["pairs"]) == 3
os.remove(db)
def test_tabla_invalida_devuelve_error():
res = correlation_matrix_duckdb("/tmp/nope.duckdb", "drop table; --")
assert res["status"] == "error"
assert "invalid table identifier" in res["error"]
@@ -0,0 +1,60 @@
---
name: correlation_ratio
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def correlation_ratio(categories: list, values: list) -> float"
description: "Correlation ratio eta (η): mide cuanto explica una variable categorica de la varianza de una variable numerica, en [0,1]. η²=varianza entre grupos/varianza total; devuelve η=sqrt(η²). Es la metrica num↔cat de una matriz de asociacion mixta (analoga a Cramer's V para cat↔cat o Pearson para num↔num). Descarta pares con categoria None o valor None/NaN/no-numerico. Si <2 grupos distintos o varianza total 0 devuelve 0.0 (float, nunca None ni excepcion)."
tags: [eda, correlation, association, categorical, numeric, statistics, datascience]
params:
- name: categories
desc: "Lista de etiquetas categoricas (cualquier hashable: str, int, etc.). Define los grupos. None en una posicion descarta ese par."
- name: values
desc: "Lista de valores numericos pareada con categories (mismo orden e indice). None, NaN o no-numerico descarta ese par."
output: "eta (η) en rango [0,1] como float. 1.0 = la categorica explica toda la varianza de la numerica (medias de grupo muy separadas); 0.0 = no la explica (medias de grupo iguales o datos degenerados). Nunca None ni excepcion."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_high_eta_when_groups_separated", "test_low_eta_when_random", "test_single_group_returns_zero", "test_zero_total_variance_returns_zero", "test_skips_none_and_nan_pairs", "test_result_in_unit_range"]
test_file_path: "python/functions/datascience/correlation_ratio_test.py"
file_path: "python/functions/datascience/correlation_ratio.py"
---
## Ejemplo
```python
from datascience import correlation_ratio
# La categoria separa claramente los valores -> eta alto (cercano a 1)
categories = ["A", "A", "A", "B", "B", "B", "C", "C", "C"]
values = [ 1, 2, 1, 10, 11, 10, 20, 21, 20 ]
print(round(correlation_ratio(categories, values), 3)) # ~0.997
# Categoria sin relacion con los valores -> eta bajo (cercano a 0)
import random
random.seed(0)
cats = [random.choice(["x", "y", "z"]) for _ in range(300)]
vals = [random.gauss(0, 1) for _ in range(300)]
print(round(correlation_ratio(cats, vals), 3)) # ~0.0 - 0.1
```
## Cuando usarla
Cuando quieras saber **si una variable categorica explica una numerica**: pais → salario,
ciudad → precio de vivienda, segmento de cliente → ticket medio. Es la celda num↔cat de una
matriz de asociacion mixta para EDA — combinala con Pearson/Spearman (num↔num) y Cramer's V
(cat↔cat). Un η alto indica que conocer el grupo reduce mucho la incertidumbre sobre el valor.
## Gotchas
Funcion pura, sin gotchas de efectos. Notas de comportamiento:
- η NO es simetrica: mide cat→num, no num→cat. No la uses al reves.
- η no distingue direccion ni linealidad: solo cuanta varianza separan los grupos.
- Pocos datos por grupo inflan η al alza (sobreajuste a medias ruidosas); con grupos de
tamaño 1 cada grupo "explica" su punto. Interpretar con cautela en muestras pequeñas.
@@ -0,0 +1,73 @@
"""Correlation ratio eta (η): asociacion entre una variable categorica y una numerica."""
import math
import numpy as np
def correlation_ratio(categories: list, values: list) -> float:
"""Correlation ratio eta (η) entre una variable categorica y una numerica.
Mide cuanto de la varianza de la variable numerica (`values`) queda
explicada por la pertenencia a cada grupo de la variable categorica
(`categories`). Es la metrica num↔cat de una matriz de asociacion mixta
(analoga a Cramer's V para cat↔cat o Pearson para num↔num).
Definicion: η² = varianza entre grupos / varianza total, donde
ss_between = Σ_g n_g · (mean_g mean_global)²
ss_total = Σ_i (value_i mean_global)²
η² = ss_between / ss_total
η = sqrt(max(0, η²))
Descarta los pares en los que la categoria sea None o el valor sea None,
NaN o no numerico. Si tras la limpieza quedan menos de 2 grupos distintos,
o la varianza total es cero, devuelve 0.0. El resultado se clampa a [0, 1].
Args:
categories: lista de etiquetas categoricas (cualquier hashable). None
descarta el par.
values: lista de valores numericos pareada con categories. None, NaN o
no numerico descarta el par.
Returns:
eta (η) en rango [0, 1] como float. Nunca None ni excepcion: ante datos
insuficientes o degenerados devuelve 0.0.
"""
def _is_num(v) -> bool:
return (
isinstance(v, (int, float))
and not isinstance(v, bool)
and not (isinstance(v, float) and math.isnan(v))
)
groups: dict = {}
all_values: list[float] = []
for cat, val in zip(categories, values):
if cat is None or not _is_num(val):
continue
fv = float(val)
groups.setdefault(cat, []).append(fv)
all_values.append(fv)
if len(groups) < 2:
return 0.0
arr = np.asarray(all_values, dtype=float)
mean_global = float(arr.mean())
ss_total = float(np.sum((arr - mean_global) ** 2))
if ss_total == 0.0:
return 0.0
ss_between = 0.0
for vals in groups.values():
g = np.asarray(vals, dtype=float)
n_g = g.size
mean_g = float(g.mean())
ss_between += n_g * (mean_g - mean_global) ** 2
eta2 = ss_between / ss_total
eta = math.sqrt(max(0.0, eta2))
return float(min(1.0, max(0.0, eta)))
@@ -0,0 +1,56 @@
"""Tests para correlation_ratio."""
import math
import random
from correlation_ratio import correlation_ratio
def test_high_eta_when_groups_separated():
# Tres grupos con medias muy distintas y poca varianza intra-grupo -> eta alto.
categories = ["A", "A", "A", "B", "B", "B", "C", "C", "C"]
values = [1, 2, 1, 10, 11, 10, 20, 21, 20]
eta = correlation_ratio(categories, values)
assert eta > 0.8
def test_low_eta_when_random():
# Categoria asignada al azar, valores gaussianos independientes -> eta bajo.
random.seed(0)
cats = [random.choice(["x", "y", "z"]) for _ in range(500)]
vals = [random.gauss(0.0, 1.0) for _ in range(500)]
eta = correlation_ratio(cats, vals)
assert eta < 0.2
def test_single_group_returns_zero():
# Menos de 2 grupos distintos -> 0.0
assert correlation_ratio(["A", "A", "A"], [1.0, 2.0, 3.0]) == 0.0
def test_zero_total_variance_returns_zero():
# Todos los valores iguales -> varianza total 0 -> 0.0
assert correlation_ratio(["A", "B", "C"], [5.0, 5.0, 5.0]) == 0.0
def test_skips_none_and_nan_pairs():
# Los pares con categoria None o valor None/NaN/no-numerico se descartan
# sin afectar el resultado de los pares validos.
base_cats = ["A", "A", "B", "B"]
base_vals = [1.0, 1.0, 9.0, 9.0]
clean = correlation_ratio(base_cats, base_vals)
noisy_cats = ["A", "A", "B", "B", None, "C", "D"]
noisy_vals = [1.0, 1.0, 9.0, 9.0, 7.0, float("nan"), "no-num"]
noisy = correlation_ratio(noisy_cats, noisy_vals)
assert math.isclose(clean, noisy, rel_tol=1e-9, abs_tol=1e-9)
assert clean == 1.0 # grupos perfectamente separados y constantes -> eta = 1
def test_result_in_unit_range():
random.seed(7)
cats = [random.choice(["p", "q"]) for _ in range(200)]
vals = [random.gauss(2.0, 3.0) for _ in range(200)]
eta = correlation_ratio(cats, vals)
assert 0.0 <= eta <= 1.0
+98
View File
@@ -0,0 +1,98 @@
---
id: cramers_v_py_datascience
name: cramers_v
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def cramers_v(a: list, b: list) -> float"
description: "Cramer's V del grupo eda: asociacion simetrica entre dos columnas categoricas pareadas (0=independientes, 1=asociacion perfecta), con correccion de sesgo Bergsma-Wicher. Descarta pares con None y devuelve 0.0 si hay <2 categorias o <2 pares. Funcion pura, sin pandas."
tags: [eda, correlation, association, categorical, statistics, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from datascience import cramers_v
a = ["red", "green", "blue", "red", "green", "blue"]
b = ["hot", "cool", "cool", "hot", "cool", "cool"] # derivada de a
cramers_v(a, b)
# -> ~1.0 (asociacion perfecta)
tested: true
tests:
- "test_perfect_association_is_near_one"
- "test_independent_columns_low_value"
- "test_single_category_returns_zero"
- "test_fewer_than_two_pairs_returns_zero"
- "test_none_pairs_are_discarded"
- "test_always_returns_float_never_none"
- "test_derived_column_high_association"
test_file_path: "python/functions/datascience/cramers_v_test.py"
file_path: "python/functions/datascience/cramers_v.py"
params:
- name: a
desc: >
Lista de valores categoricos hashables. Se empareja posicion a posicion
con `b`. Los pares donde `a[i]` sea None se descartan.
- name: b
desc: >
Lista de valores categoricos hashables pareada con `a` (idealmente misma
longitud). Los pares donde `b[i]` sea None se descartan. zip recorta a la
longitud minima de ambas listas.
output: >
float en [0, 1]. 0.0 = variables independientes, 1.0 = asociacion perfecta.
Devuelve 0.0 cuando hay menos de 2 pares validos o menos de 2 categorias
distintas en alguna de las dos variables. Nunca devuelve None ni lanza
excepcion.
---
## Ejemplo
```python
from datascience import cramers_v
# Dos categoricas asociadas: b se deriva de a con un mapeo fijo.
a = ["red", "green", "blue", "red", "green", "blue", "red", "green", "blue"]
mapping = {"red": "hot", "green": "cool", "blue": "cool"}
b = [mapping[x] for x in a]
cramers_v(a, b)
# -> ~1.0 (saber el color predice perfectamente la temperatura)
# Dos categoricas independientes (aleatorias) -> V cercana a 0.
import random
rng = random.Random(42)
cats = ["a", "b", "c", "d"]
x = [rng.choice(cats) for _ in range(2000)]
y = [rng.choice(cats) for _ in range(2000)]
cramers_v(x, y)
# -> < 0.5 (no hay asociacion)
```
## Cuando usarla
Cuando perfiles o exploras un dataset y necesites medir la **asociacion entre
dos columnas categoricas** (no numericas): construir un heatmap de correlacion
categorica, detectar columnas redundantes/derivadas una de otra, o decidir que
features categoricas aportan informacion antes de modelar. Es el equivalente
categorico de un coeficiente de correlacion: simetrica (`cramers_v(a, b) ==
cramers_v(b, a)`) y normalizada a [0, 1].
## Notas
Funcion pura, sin I/O, sin pandas y sin mutar los inputs. Construye la tabla de
contingencia con `collections.Counter` sobre los pares `(a_i, b_i)` y calcula
chi-cuadrado a mano (`sum((obs-exp)^2/exp)`), por lo que solo depende de la
stdlib.
Aplica la **correccion de sesgo de Bergsma-Wicher**, que reduce el inflado de V
en tablas pequenas: `phi2corr = max(0, phi2 - (r-1)(k-1)/(n-1))`, con `r`/`k`
filas/columnas corregidas y `n` el numero de pares validos. El resultado se
clampa a [0, 1] por seguridad numerica.
Casos borde resueltos sin excepcion: listas vacias, un solo par, columna con una
sola categoria, o None en cualquiera de los dos lados (el par se descarta) ->
todos devuelven `0.0` o una V bien definida sobre los pares que queden.
+74
View File
@@ -0,0 +1,74 @@
"""Cramer's V: asociacion simetrica entre dos columnas categoricas pareadas.
Funcion pura del grupo eda. Mide la fuerza de asociacion entre dos variables
categoricas (0 = independientes, 1 = asociacion perfecta) usando la estadistica
chi-cuadrado de la tabla de contingencia, con la correccion de sesgo de
Bergsma-Wicher para tablas pequenas.
"""
from collections import Counter
def cramers_v(a: list, b: list) -> float:
"""Calcula Cramer's V (con correccion de sesgo) entre dos categoricas.
Empareja `a` y `b` posicion a posicion, descarta los pares donde cualquiera
de los dos sea None, construye la tabla de contingencia y devuelve la V de
Cramer corregida (Bergsma-Wicher), clampada a [0, 1].
Args:
a: lista de valores categoricos (hashables; None se descarta).
b: lista de valores categoricos pareada con `a` (mismo criterio).
Returns:
float en [0, 1]: 0.0 si hay menos de 2 pares validos o menos de 2
categorias distintas en alguna de las dos variables; en otro caso la V
de Cramer corregida. Nunca devuelve None ni lanza excepcion.
"""
# Empareja y descarta pares con None en cualquiera de los dos lados.
pairs = [
(x, y)
for x, y in zip(a, b)
if x is not None and y is not None
]
n = len(pairs)
if n < 2:
return 0.0
rows = sorted({x for x, _ in pairs}, key=repr)
cols = sorted({y for _, y in pairs}, key=repr)
r = len(rows)
k = len(cols)
if r < 2 or k < 2:
return 0.0
row_idx = {v: i for i, v in enumerate(rows)}
col_idx = {v: j for j, v in enumerate(cols)}
cell = Counter((row_idx[x], col_idx[y]) for x, y in pairs)
row_tot = [0.0] * r
col_tot = [0.0] * k
for (i, j), c in cell.items():
row_tot[i] += c
col_tot[j] += c
# chi2 = sum((obs - exp)^2 / exp) sobre toda la tabla.
chi2 = 0.0
for i in range(r):
for j in range(k):
obs = cell.get((i, j), 0)
exp = row_tot[i] * col_tot[j] / n
if exp > 0.0:
diff = obs - exp
chi2 += diff * diff / exp
phi2 = chi2 / n
# Correccion de sesgo Bergsma-Wicher.
phi2corr = max(0.0, phi2 - (r - 1) * (k - 1) / (n - 1))
rcorr = r - (r - 1) ** 2 / (n - 1)
kcorr = k - (k - 1) ** 2 / (n - 1)
denom = max(1e-12, min(kcorr - 1.0, rcorr - 1.0))
v = (phi2corr / denom) ** 0.5
# Clampa a [0, 1] por seguridad numerica.
return max(0.0, min(1.0, v))
@@ -0,0 +1,60 @@
"""Tests para cramers_v."""
import os
import random
import sys
sys.path.insert(0, os.path.dirname(__file__))
from cramers_v import cramers_v
def test_perfect_association_is_near_one():
a = ["x", "y", "z", "x", "y", "z", "x", "y", "z", "x", "y", "z"]
b = list(a) # b == a -> asociacion perfecta
v = cramers_v(a, b)
assert v > 0.95
assert v <= 1.0
def test_independent_columns_low_value():
rng = random.Random(42)
cats = ["a", "b", "c", "d"]
a = [rng.choice(cats) for _ in range(2000)]
b = [rng.choice(cats) for _ in range(2000)]
v = cramers_v(a, b)
assert 0.0 <= v < 0.5
def test_single_category_returns_zero():
a = ["only"] * 10 # <2 categorias en a
b = ["x", "y", "x", "y", "x", "y", "x", "y", "x", "y"]
assert cramers_v(a, b) == 0.0
def test_fewer_than_two_pairs_returns_zero():
assert cramers_v([], []) == 0.0
assert cramers_v(["a"], ["b"]) == 0.0
def test_none_pairs_are_discarded():
a = ["x", None, "y", "x", None, "y", "x", "y"]
b = ["x", "z", "y", "x", "z", "y", None, "y"]
v = cramers_v(a, b)
assert isinstance(v, float)
assert 0.0 <= v <= 1.0
def test_always_returns_float_never_none():
assert isinstance(cramers_v(["a", "b"], ["a", "b"]), float)
assert isinstance(cramers_v([None], [None]), float)
def test_derived_column_high_association():
rng = random.Random(7)
a = [rng.choice(["red", "green", "blue"]) for _ in range(600)]
# b derivada de a (mapeo deterministico) -> alta asociacion.
mapping = {"red": "hot", "green": "cool", "blue": "cool"}
b = [mapping[x] for x in a]
v = cramers_v(a, b)
assert v > 0.5
@@ -0,0 +1,58 @@
---
name: describe_numeric
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def describe_numeric(values: list, bins: int = 20) -> dict"
description: "Calcula el bloque estadistico fino numeric de un ColumnProfile del grupo eda sobre una MUESTRA de una columna numerica. Descarta None/NaN/no-numericos y devuelve min/max/mean/median/mode/std/variance/cv, percentiles, iqr, skew, kurtosis, outliers, zero_pct, negative_pct, distribution_type e histogram. Reusa detect_distribution_type, detect_outliers y histogram del registry."
tags: [eda, statistics, profiling, distribution, histogram, datascience]
params:
- name: values
desc: "Lista de valores crudos de una columna (muestra). Puede contener None, NaN, infinitos y strings no numericos: se descartan antes de calcular. bool se trata como no numerico."
- name: bins
desc: "Numero de buckets equiespaciados del histograma. Default 20."
output: "Dict con las claves exactas del contrato numeric_sub del grupo eda: {min, max, mean, median, mode, std, variance, cv, p1, p5, p25, p50, p75, p95, p99, iqr, skew, kurtosis, n_outliers, outlier_pct, zero_pct, negative_pct, distribution_type, histogram}. cv = std/mean (None si mean==0). iqr = p75-p25. mode = valor mas frecuente (menor en empate). histogram = lista de {lo, hi, count}. Si tras limpiar quedan 0 valores: todas las claves None y histogram=[]."
uses_functions:
- detect_distribution_type_py_datascience
- detect_outliers_py_datascience
- histogram_py_datascience
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [numpy, math]
tested: true
tests: ["test_lista_con_outlier_y_none", "test_lista_vacia_todo_none", "test_cv_none_cuando_mean_cero", "test_iqr_y_percentiles"]
test_file_path: "python/functions/datascience/describe_numeric_test.py"
file_path: "python/functions/datascience/describe_numeric.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.describe_numeric import describe_numeric
# Muestra de una columna numerica (con un None y un outlier claro):
prof = describe_numeric([1, 2, 2, 3, 100, None, 4])
print(prof["min"], prof["max"], prof["median"], prof["mode"])
# 1.0 100.0 2.5 2.0
print(prof["distribution_type"]) # etiqueta de forma (too_few_samples si n < 30)
print(prof["histogram"][:2]) # [{'lo': 1.0, 'hi': 5.95, 'count': ...}, ...]
```
## Cuando usarla
- Usala cuando construyas el bloque `numeric` de un `ColumnProfile` del grupo `eda` a partir de una **muestra** de una columna numerica (no la tabla entera).
- Cuando necesites de un solo paso percentiles finos (p1..p99), iqr, dispersion (std, variance, cv), forma (skew, kurtosis, distribution_type), outliers por z-score e histograma con bordes.
- Antes de decidir transformaciones (log, winsorize, escalado) sobre una columna: el `distribution_type`, `n_outliers` y `skew` orientan la decision.
## Gotchas
- Funcion pura, sin I/O. Descarta silenciosamente None, NaN, infinitos, strings y bool (True/False no cuentan como datos numericos).
- `distribution_type`, `skew` y `kurtosis` vienen de `detect_distribution_type`, que devuelve `too_few_samples` (y skew/kurtosis None) cuando la muestra limpia tiene **menos de 30 valores**.
- Los outliers usan z-score con `std` poblacional y threshold 3.0 (de `detect_outliers`): en muestras muy pequeñas un unico valor extremo puede inflar la `std` y no marcarse como outlier (efecto masking). Para deteccion fiable, pasa una muestra suficientemente grande.
- `cv` es `None` cuando `mean == 0` (division indefinida).
@@ -0,0 +1,159 @@
"""describe_numeric — Fine-grained numeric statistics block for an EDA ColumnProfile.
Pure function: no I/O, deterministic. Computes the `numeric` sub-block of a
ColumnProfile (group `eda`) over a SAMPLE of a numeric column. Non-numeric and
missing values (None, NaN, non-numeric strings) are discarded before computing.
Reuses registry functions instead of reimplementing their logic:
- detect_distribution_type (skew, kurtosis, distribution label)
- detect_outliers (z-score outlier flags)
- histogram (counts per equal-width bucket)
"""
import math
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(__file__))
from datascience import detect_outliers, histogram # noqa: E402
from detect_distribution_type import detect_distribution_type # noqa: E402
# Keys of the numeric sub-block contract for the eda group.
_NULL_KEYS = (
"min", "max", "mean", "median", "mode", "std", "variance", "cv",
"p1", "p5", "p25", "p50", "p75", "p95", "p99", "iqr",
"skew", "kurtosis", "n_outliers", "outlier_pct",
"zero_pct", "negative_pct", "distribution_type",
)
def _clean(values: list) -> list:
"""Keep only finite numeric values, discarding None/NaN/non-numeric/bool."""
out: list = []
for v in values:
# bool is a subclass of int; treat True/False as non-numeric data.
if isinstance(v, bool):
continue
if isinstance(v, (int, float)):
f = float(v)
if not math.isnan(f) and not math.isinf(f):
out.append(f)
return out
def _mode(values: list) -> float:
"""Most frequent value; on a tie, the smallest value wins."""
counts: dict = {}
for v in values:
counts[v] = counts.get(v, 0) + 1
best_count = max(counts.values())
return min(v for v, c in counts.items() if c == best_count)
def describe_numeric(values: list, bins: int = 20) -> dict:
"""Compute the fine-grained numeric statistics block for an EDA ColumnProfile.
Designed to run on a SAMPLE of a single column, not the whole table.
None, NaN, infinities and non-numeric values are discarded first. If no
numeric value survives the cleaning, every key is None and histogram is [].
Args:
values: List of raw column values (may contain None/NaN/strings).
bins: Number of equal-width buckets for the histogram (default 20).
Returns:
Dict with the exact keys of the eda `numeric_sub` contract:
{min, max, mean, median, mode, std, variance, cv, p1, p5, p25, p50,
p75, p95, p99, iqr, skew, kurtosis, n_outliers, outlier_pct, zero_pct,
negative_pct, distribution_type, histogram}.
"""
clean = _clean(values)
n = len(clean)
if n == 0:
result = {k: None for k in _NULL_KEYS}
result["histogram"] = []
return result
arr = np.array(clean, dtype=float)
minimum = float(np.min(arr))
maximum = float(np.max(arr))
mean = float(np.mean(arr))
std = float(np.std(arr))
variance = float(np.var(arr))
cv = (std / mean) if mean != 0 else None
p1 = float(np.percentile(arr, 1))
p5 = float(np.percentile(arr, 5))
p25 = float(np.percentile(arr, 25))
p50 = float(np.percentile(arr, 50))
p75 = float(np.percentile(arr, 75))
p95 = float(np.percentile(arr, 95))
p99 = float(np.percentile(arr, 99))
median = p50
iqr = p75 - p25
mode = _mode(clean)
# Distribution shape: reuse detect_distribution_type for skew/kurtosis/type.
dist = detect_distribution_type(clean)
distribution_type = dist.get("type")
dist_stats = dist.get("stats", {})
skew = dist_stats.get("skew")
kurtosis = dist_stats.get("kurtosis")
# Outliers: reuse detect_outliers (z-score, threshold 3.0). Count the True.
outlier_flags = detect_outliers(clean, 3.0)
n_outliers = sum(1 for flag in outlier_flags if flag)
outlier_pct = 100.0 * n_outliers / n
zero_pct = 100.0 * sum(1 for v in clean if v == 0) / n
negative_pct = 100.0 * sum(1 for v in clean if v < 0) / n
# Histogram: reuse histogram for the per-bucket counts, then attach the
# equal-width [lo, hi) edges so the eda contract gets {lo, hi, count}.
counts = histogram(clean, bins)
hist: list = []
if counts:
if maximum == minimum:
# Degenerate range: histogram() places everything in bucket 0.
for i, count in enumerate(counts):
hist.append({"lo": minimum, "hi": maximum, "count": int(count)})
else:
width = (maximum - minimum) / bins
for i, count in enumerate(counts):
lo = minimum + i * width
hi = minimum + (i + 1) * width
hist.append({"lo": float(lo), "hi": float(hi), "count": int(count)})
return {
"min": minimum,
"max": maximum,
"mean": mean,
"median": median,
"mode": mode,
"std": std,
"variance": variance,
"cv": cv,
"p1": p1,
"p5": p5,
"p25": p25,
"p50": p50,
"p75": p75,
"p95": p95,
"p99": p99,
"iqr": iqr,
"skew": skew,
"kurtosis": kurtosis,
"n_outliers": n_outliers,
"outlier_pct": outlier_pct,
"zero_pct": zero_pct,
"negative_pct": negative_pct,
"distribution_type": distribution_type,
"histogram": hist,
}
@@ -0,0 +1,85 @@
"""Tests para describe_numeric."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from describe_numeric import describe_numeric
# Keys that every result dict must always contain (the eda numeric_sub contract).
_EXPECTED_KEYS = {
"min", "max", "mean", "median", "mode", "std", "variance", "cv",
"p1", "p5", "p25", "p50", "p75", "p95", "p99", "iqr",
"skew", "kurtosis", "n_outliers", "outlier_pct",
"zero_pct", "negative_pct", "distribution_type", "histogram",
}
def test_lista_con_outlier_y_none():
"""Lista con outlier claro y None descartado."""
# Tight cluster around 2-4 plus a None to drop and a clear extreme outlier.
# A wide cluster (n=40) keeps std small so the extreme value's z-score
# exceeds the 3.0 threshold used by detect_outliers.
cluster = [1, 2, 2, 3, 4] * 8 # 40 numeric values, mode == 2
values = cluster + [None, 1000]
result = describe_numeric(values)
# Contract: all keys present.
assert set(result.keys()) == _EXPECTED_KEYS
# Non-numeric / missing dropped: 41 numeric values remain.
assert result["min"] == 1.0
assert result["max"] == 1000.0
# mean/median reasonable: median sits in the cluster, mean pulled up by 1000.
assert result["median"] < result["mean"]
assert 0.0 < result["median"] <= 5.0
assert result["mean"] > result["median"]
# mode = most frequent (2 appears twice per block).
assert result["mode"] == 2.0
# At least one z-score outlier detected (the 1000).
assert result["n_outliers"] >= 1
assert result["outlier_pct"] > 0.0
# Histogram non-empty and counts cover every numeric value.
assert len(result["histogram"]) > 0
total = sum(bucket["count"] for bucket in result["histogram"])
assert total == 41
for bucket in result["histogram"]:
assert "lo" in bucket and "hi" in bucket and "count" in bucket
# No zeros, no negatives in this sample.
assert result["zero_pct"] == 0.0
assert result["negative_pct"] == 0.0
def test_lista_vacia_todo_none():
"""Lista vacia (o sin numericos) devuelve todas las claves en None."""
result = describe_numeric([None, "abc", float("nan")])
assert set(result.keys()) == _EXPECTED_KEYS
for key in _EXPECTED_KEYS - {"histogram"}:
assert result[key] is None, f"{key} debe ser None"
assert result["histogram"] == []
def test_cv_none_cuando_mean_cero():
"""cv es None cuando la media es 0."""
# Symmetric around zero so mean == 0.
result = describe_numeric([-2, -1, 0, 1, 2])
assert result["mean"] == 0.0
assert result["cv"] is None
assert result["zero_pct"] == 20.0
assert result["negative_pct"] == 40.0
def test_iqr_y_percentiles():
"""iqr = p75 - p25 y percentiles coherentes."""
result = describe_numeric(list(range(1, 101))) # 1..100
assert result["iqr"] == result["p75"] - result["p25"]
assert result["p1"] <= result["p25"] <= result["p50"] <= result["p75"] <= result["p99"]
assert result["min"] == 1.0
assert result["max"] == 100.0
@@ -0,0 +1,84 @@
---
name: eda_llm_insights
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def eda_llm_insights(profile: dict, model: str = \"claude-haiku-4-5-20251001\") -> dict"
description: "Capa LLM interpretativa del grupo eda. Toma un TableProfile YA CALCULADO (el dict de profile_table) y, con UNA sola llamada al LLM, genera el bloque 'llm': resumen de la tabla, significado de una fila, diccionario de datos, deteccion de PII (RGPD), sugerencias de limpieza y analisis sugeridos. Clave de coste/privacidad: NO envia filas crudas al LLM, solo el perfil AGREGADO (nombres, tipos, % nulos, distinct, top valores agregados de categoricas, stats de numericas, pares de correlacion fuertes). Reusa ask_llm del grupo claude-direct (API directa con token OAuth de Claude). Impura, dict-no-throw."
tags: [eda, llm, claude-direct, datascience, profiling, pii, data-dictionary]
params:
- name: profile
desc: "TableProfile ya calculado (el dict que devuelve profile_table()['profile']). Se espera {table, n_rows, columns:[{name, inferred_type, semantic_type, null_pct, distinct_count, numeric:{min,max,mean,p50,...}, categorical:{top:[{value,count,pct}], mode,...}}], correlations:{strong:[{a,b,method,value}]} | None}. Solo se le envia al LLM un resumen agregado; nunca filas crudas."
- name: model
desc: "id del modelo Anthropic a usar. Default 'claude-haiku-4-5-20251001' (haiku, coste bajo). Para mayor calidad interpretativa, pasar p.ej. 'claude-opus-4-8'."
output: "dict dict-no-throw. En exito: {status:'ok', llm:{summary:str, row_meaning:str, dictionary:[{column,description,business_meaning,unit}], pii:[{column,kind,severity}], cleaning:[str], analyses:[str]}}. Las claves que el LLM omita se rellenan con defaults vacios. En error (sin lanzar): {status:'error', error:str}."
uses_functions: [ask_llm_py_core]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_build_prompt_includes_table_and_columns", "test_build_prompt_includes_numeric_stats_and_top_values", "test_build_prompt_handles_empty_profile", "test_parse_llm_json_plain", "test_parse_llm_json_with_fences", "test_parse_llm_json_with_surrounding_text", "test_parse_llm_json_nested_braces_in_strings", "test_parse_llm_json_raises_without_object", "test_eda_llm_insights_ok_with_monkeypatched_llm", "test_eda_llm_insights_fills_missing_keys", "test_eda_llm_insights_error_on_empty_profile", "test_eda_llm_insights_error_on_empty_llm_response", "test_eda_llm_insights_error_on_unparseable_llm_response"]
test_file_path: "python/functions/datascience/eda_llm_insights_test.py"
file_path: "python/functions/datascience/eda_llm_insights.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from pipelines.profile_table import profile_table
from datascience import eda_llm_insights
# 1) Perfila la tabla (calculo agregado, sin LLM).
r = profile_table("data/ventas.duckdb", "ventas", write_report=False)
profile = r["profile"]
# 2) Interpreta el perfil con UNA llamada al LLM (solo el perfil agregado viaja).
out = eda_llm_insights(profile) # haiku por defecto
# out = eda_llm_insights(profile, model="claude-opus-4-8") # mas calidad
if out["status"] == "ok":
llm = out["llm"]
print(llm["summary"]) # que es la tabla, 2-3 frases
print(llm["row_meaning"]) # que representa una fila
for d in llm["dictionary"]: # diccionario de datos por columna
print(d["column"], "->", d["description"], f"({d['unit']})")
for p in llm["pii"]: # datos personales/sensibles RGPD
print("PII:", p["column"], p["kind"], p["severity"])
print(llm["cleaning"]) # sugerencias de limpieza
print(llm["analyses"]) # analisis sugeridos + hipotesis
else:
print("error:", out["error"])
```
## Cuando usarla
Cuando necesites entender SEMANTICAMENTE una tabla ya perfilada: generar un
diccionario de datos legible, detectar PII/datos sensibles RGPD, recibir
sugerencias de limpieza y una lista de analisis/hipotesis a explorar. Es el
paso interpretativo que sigue a `profile_table`: este calcula las metricas, y
`eda_llm_insights` las traduce a lenguaje de negocio. El resultado encaja en la
clave `llm` del TableProfile (la que `render_eda_markdown` renderiza en la
seccion "Analisis LLM").
## Gotchas
- **Impura: hace 1 llamada de red al LLM.** No es determinista ni gratis.
- **Requiere token OAuth de Claude** en `~/.claude/.credentials.json` (via
`ask_llm` / grupo `claude-direct`). Sin token, devuelve `{status:'error'}`.
- **NO envia filas crudas al LLM**, solo el perfil AGREGADO (nombres, tipos,
% nulos, distinct, top valores ya agregados, stats numericas, correlaciones
fuertes). Privacidad y coste minimos por diseno — pero requiere que el
`profile` venga ya calculado por `profile_table`.
- **Modelo `haiku` por defecto** para coste bajo; sube a `claude-opus-4-8` si
necesitas interpretacion mas fina (mas caro y lento).
- El LLM puede omitir claves: las que falten se rellenan con defaults vacios
(`""` o `[]`), nunca lanza por shape incompleto.
- El parseo tolera `\`\`\`json` fences y texto alrededor del objeto, pero si el
modelo no devuelve ningun objeto JSON, retorna `{status:'error'}`.
@@ -0,0 +1,256 @@
"""eda_llm_insights — capa LLM interpretativa del grupo de capacidad `eda`.
Toma un TableProfile YA CALCULADO (el dict que produce `profile_table`) y, con
UNA sola llamada al LLM, genera el bloque interpretativo "llm": resumen de la
tabla, significado de una fila, diccionario de datos, deteccion de PII (RGPD),
sugerencias de limpieza y analisis sugeridos.
Clave de coste y privacidad: NO se envian filas crudas al LLM. Solo viaja el
perfil AGREGADO (nombres, tipos, % nulos, distinct, top valores ya agregados de
categoricas, stats de numericas y pares de correlacion fuertes). Asi el coste es
minimo y ningun dato fila-a-fila sale del proceso.
Reusa `ask_llm` del registry (grupo claude-direct, API directa con el token
OAuth de Claude en ~/.claude/.credentials.json, arranque 0). Impura: una llamada
de red. Estilo dict-no-throw del grupo: nunca lanza; ante cualquier fallo (red,
LLM, parseo) devuelve {status:'error', error:str}.
"""
import json
from core import ask_llm
# Claves que el LLM debe devolver. Las que falten se rellenan con estos defaults.
_EXPECTED_KEYS = {
"summary": "",
"row_meaning": "",
"dictionary": [],
"pii": [],
"cleaning": [],
"analyses": [],
}
_SYSTEM = (
"Eres un analista de datos senior. Recibes el PERFIL AGREGADO de una tabla "
"(nunca filas crudas) y lo interpretas de forma util para un humano de "
"negocio. Detectas datos personales/sensibles segun el RGPD. Respondes "
"SIEMPRE y SOLO con un unico objeto JSON valido, sin texto alrededor, sin "
"fences de markdown, con EXACTAMENTE estas claves: "
'"summary" (str: que es la tabla, 2-3 frases), '
'"row_meaning" (str: que representa una fila y su granularidad), '
'"dictionary" (lista de objetos {"column","description","business_meaning","unit"}), '
'"pii" (lista de objetos {"column","kind","severity"} con severity en '
'low|medium|high, solo columnas con datos personales/sensibles), '
'"cleaning" (lista de strings con sugerencias de limpieza/transformacion), '
'"analyses" (lista de strings con preguntas/analisis sugeridos e hipotesis '
"de relaciones). Responde en el mismo idioma que los nombres de columna."
)
def _fmt_num(value) -> str:
"""Formatea un numero de forma compacta para el prompt (None -> '?')."""
if value is None:
return "?"
if isinstance(value, float):
if value == int(value):
return str(int(value))
return f"{value:.4g}"
return str(value)
def _build_prompt(profile: dict) -> str:
"""Construye un resumen textual compacto del perfil para el LLM.
Funcion interna PURA: no toca red ni disco, es testeable sin credenciales.
Incluye, por columna: name, inferred_type, semantic_type, null_pct, distinct;
top-3 valores si categorical; min/max/mean/median si numeric. Cierra con la
lista de correlations["strong"] si existe.
Args:
profile: TableProfile (dict de profile_table["profile"]).
Returns:
El texto del prompt.
"""
profile = profile or {}
table = profile.get("table", "(desconocida)")
n_rows = profile.get("n_rows")
cols = profile.get("columns") or []
lines = [
"Perfil agregado de una tabla. No hay filas crudas, solo metricas.",
f"Tabla: {table}",
f"Filas (n_rows): {_fmt_num(n_rows)}",
f"Columnas: {len(cols)}",
"",
"Columnas:",
]
for col in cols:
name = col.get("name", "?")
itype = col.get("inferred_type") or "?"
stype = col.get("semantic_type") or ""
null_pct = col.get("null_pct")
null_str = f"{null_pct * 100:.1f}%" if isinstance(null_pct, (int, float)) else "?"
distinct = col.get("distinct_count")
parts = [
f"- {name}",
f"tipo={itype}",
]
if stype:
parts.append(f"semantic={stype}")
parts.append(f"nulos={null_str}")
parts.append(f"distinct={_fmt_num(distinct)}")
if itype == "numeric" and isinstance(col.get("numeric"), dict):
num = col["numeric"]
parts.append(
"stats[min={} max={} mean={} median={}]".format(
_fmt_num(num.get("min")),
_fmt_num(num.get("max")),
_fmt_num(num.get("mean")),
_fmt_num(num.get("p50") if num.get("p50") is not None else num.get("median")),
)
)
elif isinstance(col.get("categorical"), dict):
top = col["categorical"].get("top") or []
top3 = ", ".join(
f"{t.get('value')!r}({_fmt_num(t.get('count'))})" for t in top[:3]
)
if top3:
parts.append(f"top3=[{top3}]")
lines.append(" | ".join(parts))
correlations = profile.get("correlations")
strong = (correlations or {}).get("strong") if isinstance(correlations, dict) else None
if strong:
lines.append("")
lines.append("Correlaciones/asociaciones fuertes:")
for pair in strong:
lines.append(
"- {} ~ {} ({}={})".format(
pair.get("a", "?"),
pair.get("b", "?"),
pair.get("method", "?"),
_fmt_num(pair.get("value")),
)
)
lines.append("")
lines.append(
"Devuelve el objeto JSON descrito en las instrucciones del sistema."
)
return "\n".join(lines)
def _parse_llm_json(text: str) -> dict:
"""Extrae el primer objeto JSON de la respuesta del LLM.
Funcion interna testeable sin red. Tolera fences ```json ... ``` y texto
alrededor del objeto. Localiza el primer '{' y hace matching de llaves
(respetando strings/escapes) hasta cerrar el objeto, luego json.loads.
Args:
text: respuesta cruda del LLM.
Returns:
El dict parseado.
Raises:
ValueError: si no se encuentra un objeto JSON valido.
"""
if not text or not isinstance(text, str):
raise ValueError("empty LLM response")
s = text.strip()
# Quita fences de markdown si los hay.
if s.startswith("```"):
# Elimina la primera linea de fence (```json o ```) y un posible cierre.
first_nl = s.find("\n")
if first_nl != -1:
s = s[first_nl + 1 :]
if s.rstrip().endswith("```"):
s = s.rstrip()[:-3]
s = s.strip()
start = s.find("{")
if start == -1:
raise ValueError("no JSON object found in LLM response")
depth = 0
in_str = False
escape = False
end = -1
for i in range(start, len(s)):
ch = s[i]
if in_str:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i + 1
break
if end == -1:
raise ValueError("unbalanced JSON object in LLM response")
return json.loads(s[start:end])
def _normalize(parsed: dict) -> dict:
"""Asegura todas las claves esperadas, rellenando las que falten."""
out = {}
for key, default in _EXPECTED_KEYS.items():
val = parsed.get(key, None)
if val is None:
out[key] = [] if isinstance(default, list) else default
else:
out[key] = val
return out
def eda_llm_insights(
profile: dict, model: str = "claude-haiku-4-5-20251001"
) -> dict:
"""Interpreta semanticamente un TableProfile con UNA llamada al LLM.
Args:
profile: TableProfile ya calculado (el dict que devuelve
profile_table()["profile"]). Solo se le envia al LLM el resumen
AGREGADO, nunca filas crudas.
model: id del modelo Anthropic. Default claude-haiku-4-5-20251001
(haiku, coste bajo).
Returns:
dict. En exito: {status:'ok', llm:{summary, row_meaning, dictionary,
pii, cleaning, analyses}}. En error (sin lanzar):
{status:'error', error:str}.
"""
try:
if not isinstance(profile, dict) or not profile:
return {"status": "error", "error": "profile vacio o no es dict"}
prompt = _build_prompt(profile)
text = ask_llm(prompt, model=model, system=_SYSTEM, echo=False)
if not text:
return {"status": "error", "error": "respuesta vacia del LLM"}
parsed = _parse_llm_json(text)
if not isinstance(parsed, dict):
return {"status": "error", "error": "el LLM no devolvio un objeto JSON"}
return {"status": "ok", "llm": _normalize(parsed)}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
@@ -0,0 +1,203 @@
"""Tests para eda_llm_insights.
NO acceden a red ni a credenciales: _build_prompt y _parse_llm_json son puras y
testeables aisladas; la unica via que llamaria al LLM (eda_llm_insights) se
prueba monkeypatcheando ask_llm con una respuesta simulada.
"""
import json
from datascience.eda_llm_insights import (
_build_prompt,
_parse_llm_json,
eda_llm_insights,
)
# Perfil de ejemplo con la forma que produce profile_table.
_PROFILE = {
"table": "ventas",
"n_rows": 1000,
"columns": [
{
"name": "importe",
"inferred_type": "numeric",
"semantic_type": "currency",
"null_pct": 0.0,
"distinct_count": 950,
"numeric": {"min": 1.0, "max": 999.0, "mean": 50.5, "p50": 42.0},
"categorical": None,
},
{
"name": "categoria",
"inferred_type": "categorical",
"semantic_type": "",
"null_pct": 0.05,
"distinct_count": 3,
"numeric": None,
"categorical": {
"top": [
{"value": "neumaticos", "count": 600, "pct": 0.6},
{"value": "frenos", "count": 300, "pct": 0.3},
{"value": "aceite", "count": 100, "pct": 0.1},
],
"mode": "neumaticos",
},
},
],
"correlations": {
"strong": [
{"a": "importe", "b": "categoria", "method": "correlation_ratio", "value": 0.72},
],
},
}
def test_build_prompt_includes_table_and_columns():
prompt = _build_prompt(_PROFILE)
assert isinstance(prompt, str)
assert "ventas" in prompt
assert "importe" in prompt
assert "categoria" in prompt
# n_rows presente.
assert "1000" in prompt
def test_build_prompt_includes_numeric_stats_and_top_values():
prompt = _build_prompt(_PROFILE)
# Stats numericas de importe.
assert "stats[" in prompt
assert "mean=50.5" in prompt
# Top valores de categorica.
assert "neumaticos" in prompt
# Correlaciones fuertes.
assert "correlation_ratio" in prompt
def test_build_prompt_handles_empty_profile():
prompt = _build_prompt({})
assert isinstance(prompt, str)
assert "Columnas: 0" in prompt
def test_parse_llm_json_plain():
payload = {"summary": "una tabla", "dictionary": [], "pii": []}
text = json.dumps(payload)
parsed = _parse_llm_json(text)
assert parsed["summary"] == "una tabla"
def test_parse_llm_json_with_fences():
payload = {"summary": "con fences", "analyses": ["a1"]}
text = "```json\n" + json.dumps(payload) + "\n```"
parsed = _parse_llm_json(text)
assert parsed["summary"] == "con fences"
assert parsed["analyses"] == ["a1"]
def test_parse_llm_json_with_surrounding_text():
payload = {"summary": "rodeado"}
text = "Aqui tienes el resultado:\n" + json.dumps(payload) + "\nEspero que sirva."
parsed = _parse_llm_json(text)
assert parsed["summary"] == "rodeado"
def test_parse_llm_json_nested_braces_in_strings():
# Un valor string con llaves no debe romper el matching.
text = '{"summary": "usa {placeholders}", "cleaning": ["fix {x}"]}'
parsed = _parse_llm_json(text)
assert parsed["summary"] == "usa {placeholders}"
assert parsed["cleaning"] == ["fix {x}"]
def test_parse_llm_json_raises_without_object():
try:
_parse_llm_json("no hay json aqui")
assert False, "esperaba ValueError"
except ValueError:
pass
def test_eda_llm_insights_ok_with_monkeypatched_llm(monkeypatch):
"""Simula la respuesta del LLM y verifica el shape de salida (sin red)."""
fake = {
"summary": "Tabla de ventas",
"row_meaning": "Una fila = una venta",
"dictionary": [
{
"column": "importe",
"description": "monto",
"business_meaning": "ingreso",
"unit": "EUR",
}
],
"pii": [],
"cleaning": ["normalizar categoria"],
"analyses": ["ventas por categoria"],
}
import datascience.eda_llm_insights as mod
monkeypatch.setattr(
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: json.dumps(fake)
)
out = eda_llm_insights(_PROFILE)
assert out["status"] == "ok"
llm = out["llm"]
assert set(llm.keys()) == {
"summary",
"row_meaning",
"dictionary",
"pii",
"cleaning",
"analyses",
}
assert llm["summary"] == "Tabla de ventas"
assert llm["dictionary"][0]["unit"] == "EUR"
def test_eda_llm_insights_fills_missing_keys(monkeypatch):
"""Si el LLM omite claves, se rellenan con defaults vacios."""
import datascience.eda_llm_insights as mod
monkeypatch.setattr(
mod,
"ask_llm",
lambda prompt, model="x", system="", echo=True: '{"summary": "solo summary"}',
)
out = eda_llm_insights(_PROFILE)
assert out["status"] == "ok"
llm = out["llm"]
assert llm["summary"] == "solo summary"
assert llm["dictionary"] == []
assert llm["pii"] == []
assert llm["cleaning"] == []
assert llm["analyses"] == []
assert llm["row_meaning"] == ""
def test_eda_llm_insights_error_on_empty_profile():
out = eda_llm_insights({})
assert out["status"] == "error"
assert "profile" in out["error"]
def test_eda_llm_insights_error_on_empty_llm_response(monkeypatch):
import datascience.eda_llm_insights as mod
monkeypatch.setattr(
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: ""
)
out = eda_llm_insights(_PROFILE)
assert out["status"] == "error"
def test_eda_llm_insights_error_on_unparseable_llm_response(monkeypatch):
import datascience.eda_llm_insights as mod
monkeypatch.setattr(
mod, "ask_llm", lambda prompt, model="x", system="", echo=True: "sin json"
)
out = eda_llm_insights(_PROFILE)
assert out["status"] == "error"
@@ -0,0 +1,68 @@
---
name: fetch_hackernews_search
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def fetch_hackernews_search(query: str, limit: int = 50, tags: str = \"story\") -> list[dict]"
description: "Busca en Hacker News via la API Algolia publica (sin auth ni anti-bot) y normaliza cada hit a un shape comun de market intelligence. GET a hn.algolia.com/api/v1/search filtrando por tags (story/comment/...)."
tags: [market-intel, hackernews, scraping, http, social, demand, impure, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests]
params:
- name: query
desc: "termino de busqueda (ej: 'i wish there was a tool')"
- name: limit
desc: "maximo de resultados (hitsPerPage de Algolia, topea ~1000)"
- name: tags
desc: "filtro de tipo de item Algolia: 'story' (default), 'comment', 'story,comment', 'show_hn', 'ask_hn'"
output: "list[dict] (puede ser []). Cada fila: {source:'hackernews', platform_id:str, title:str, body:str, url:str, author:str, channel:'hn', created_utc:float, platform_score:int, query:str}"
tested: true
tests:
- "parser normaliza hits al shape exacto"
- "hit sin url externa cae a news.ycombinator.com item link"
- "points None se mapea a 0"
- "hits vacio devuelve lista vacia"
test_file_path: "python/functions/datascience/fetch_hackernews_search_test.py"
file_path: "python/functions/datascience/fetch_hackernews_search.py"
---
## Ejemplo
```python
from datascience import fetch_hackernews_search
# Buscar stories
rows = fetch_hackernews_search("i wish there was a tool", limit=50, tags="story")
for r in rows[:3]:
print(r["platform_score"], r["title"], r["url"])
# Buscar comentarios (mas senal de demanda conversacional)
comments = fetch_hackernews_search("alternative to", limit=100, tags="comment")
```
## Cuando usarla
Usala como fuente complementaria a `fetch_reddit_search` en pipelines de market
intelligence. HN concentra demanda tecnica/SaaS y la API Algolia es estable y
sin anti-bot, ideal para escaneos recurrentes. Pasa `tags="comment"` para captar
demanda expresada en hilos (suele ser mas rica que los titulos de story).
Combina con `score_demand_signal` para puntuar cada hit.
## Gotchas
- **Sin red = lista vacia, no excepcion**: si la peticion falla (red, 5xx,
JSON malformado) la funcion devuelve `[]`. Revisa el tamano del resultado.
- `created_utc` viene de `created_at_i` (epoch en segundos, float).
- `platform_score` son los `points` del item, `0` si Algolia no lo provee
(tipico en comentarios, que no tienen puntos visibles en la API).
- `url`: si el hit es una story con enlace externo, `url` es ese enlace; si no
(Ask HN, comentarios, Show HN sin link), cae al permalink
`https://news.ycombinator.com/item?id={objectID}`.
- A diferencia de Reddit, Algolia **no** exige User-Agent ni rate-limitea de
forma agresiva en uso normal, pero conviene no abusar.
@@ -0,0 +1,71 @@
"""fetch_hackernews_search — busca en Hacker News via la API Algolia publica.
Funcion impura: hace peticiones HTTP a hn.algolia.com (sin auth ni anti-bot).
Normaliza cada hit a un shape comun de market intelligence.
"""
import requests
_TIMEOUT = 15
def _parse_hits(hits: list, query: str) -> list[dict]:
"""Normaliza la lista hits de la respuesta de Algolia al shape comun."""
rows = []
for hit in hits:
if not isinstance(hit, dict):
continue
object_id = str(hit.get("objectID", ""))
external_url = hit.get("url")
url = external_url if external_url else (
f"https://news.ycombinator.com/item?id={object_id}"
)
body = hit.get("story_text") or hit.get("comment_text") or ""
rows.append({
"source": "hackernews",
"platform_id": object_id,
"title": hit.get("title", "") or "",
"body": body,
"url": url,
"author": hit.get("author", "") or "",
"channel": "hn",
"created_utc": float(hit.get("created_at_i") or 0.0),
"platform_score": int(hit.get("points") or 0),
"query": query,
})
return rows
def fetch_hackernews_search(
query: str,
limit: int = 50,
tags: str = "story",
) -> list[dict]:
"""Busca en Hacker News usando la API Algolia publica (sin autenticacion).
Args:
query: Termino de busqueda.
limit: Maximo de resultados (hitsPerPage de Algolia).
tags: Filtro de tipo de item: "story" (default), "comment",
"story,comment", "show_hn", "ask_hn", etc.
Returns:
Lista de dicts normalizados (puede ser []). Cada dict tiene las claves:
source, platform_id, title, body, url, author, channel, created_utc,
platform_score, query.
"""
url = "https://hn.algolia.com/api/v1/search"
params = {
"query": query,
"tags": tags,
"hitsPerPage": limit,
}
try:
resp = requests.get(url, params=params, timeout=_TIMEOUT)
resp.raise_for_status()
payload = resp.json()
hits = payload.get("hits", []) if isinstance(payload, dict) else []
return _parse_hits(hits, query)
except Exception:
return []
@@ -0,0 +1,82 @@
"""Tests para fetch_hackernews_search.
El parser (_parse_hits) se testea con un fixture offline. La funcion completa
fetch_hackernews_search hace red real; aqui solo validamos el shape del parser
para no depender de conectividad en CI.
"""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from fetch_hackernews_search import _parse_hits
_FIXTURE_HITS = [
{
"objectID": "39000000",
"title": "Show HN: a tool to dedupe CSVs",
"story_text": "I wish there was a better way",
"url": "https://example.com/tool",
"author": "hnuser",
"created_at_i": 1700000000,
"points": 120,
},
{
"objectID": "39000001",
"title": "Ask HN: alternative to X?",
"comment_text": "Looking for a tool that does Y",
"url": None,
"author": "asker",
"created_at_i": 1700001234,
"points": None,
},
]
_EXPECTED_KEYS = {
"source", "platform_id", "title", "body", "url", "author",
"channel", "created_utc", "platform_score", "query",
}
def test_parser_normaliza_hits_al_shape_exacto():
rows = _parse_hits(_FIXTURE_HITS, "csv dedupe")
assert len(rows) == 2
r = rows[0]
assert set(r.keys()) == _EXPECTED_KEYS
assert r["source"] == "hackernews"
assert r["platform_id"] == "39000000"
assert r["title"] == "Show HN: a tool to dedupe CSVs"
assert r["body"] == "I wish there was a better way"
assert r["url"] == "https://example.com/tool"
assert r["author"] == "hnuser"
assert r["channel"] == "hn"
assert r["created_utc"] == 1700000000.0
assert isinstance(r["created_utc"], float)
assert r["platform_score"] == 120
assert isinstance(r["platform_score"], int)
assert r["query"] == "csv dedupe"
def test_hit_sin_url_externa_cae_a_news_ycombinator_item_link():
rows = _parse_hits(_FIXTURE_HITS, "q")
assert rows[1]["url"] == "https://news.ycombinator.com/item?id=39000001"
# body cae a comment_text cuando no hay story_text
assert rows[1]["body"] == "Looking for a tool that does Y"
def test_points_none_se_mapea_a_0():
rows = _parse_hits(_FIXTURE_HITS, "q")
assert rows[1]["platform_score"] == 0
def test_hits_vacio_devuelve_lista_vacia():
assert _parse_hits([], "q") == []
if __name__ == "__main__":
test_parser_normaliza_hits_al_shape_exacto()
test_hit_sin_url_externa_cae_a_news_ycombinator_item_link()
test_points_none_se_mapea_a_0()
test_hits_vacio_devuelve_lista_vacia()
print("All tests passed.")
@@ -0,0 +1,78 @@
---
name: fetch_reddit_search
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def fetch_reddit_search(query: str, subreddits: list[str] = None, limit: int = 50, sort: str = \"new\") -> list[dict]"
description: "Busca posts en Reddit via la API JSON publica (sin auth) y los normaliza a un shape comun de market intelligence. Por subreddit (o global si None), GET a search.json con t=year. Tolera errores por subreddit (429, red) continuando con los demas. Requiere User-Agent obligatorio."
tags: [market-intel, reddit, scraping, http, social, demand, impure, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests]
params:
- name: query
desc: "termino de busqueda (ej: 'csv dedupe tool')"
- name: subreddits
desc: "lista de subreddits sin prefijo r/ (ej: ['SaaS','Entrepreneur']). Si None o vacio -> busqueda global en todo Reddit"
- name: limit
desc: "maximo de resultados por subreddit (o por la busqueda global). Reddit topea ~100"
- name: sort
desc: "orden de Reddit: 'new' (default), 'relevance', 'top', 'comments', 'hot'"
output: "list[dict] (puede ser []). Cada fila: {source:'reddit', platform_id:str, title:str, body:str, url:str, author:str, channel:str, created_utc:float, platform_score:int, query:str}"
tested: true
tests:
- "parser normaliza children al shape exacto"
- "selftext vacio se mapea a body vacio"
- "children vacio devuelve lista vacia"
test_file_path: "python/functions/datascience/fetch_reddit_search_test.py"
file_path: "python/functions/datascience/fetch_reddit_search.py"
---
## Ejemplo
```python
from datascience import fetch_reddit_search
# Buscar en subreddits concretos
rows = fetch_reddit_search(
"csv dedupe tool",
subreddits=["SaaS", "Entrepreneur"],
limit=25,
sort="new",
)
for r in rows[:3]:
print(r["channel"], r["platform_score"], r["title"])
# Busqueda global (sin subreddits)
rows_global = fetch_reddit_search("i wish there was a tool", limit=50)
```
## Cuando usarla
Usala como primera fase de un pipeline de market intelligence: recolectar
conversaciones reales de Reddit donde la gente expresa necesidades o busca
herramientas. Combina la salida con `score_demand_signal` para puntuar cada
post por senal de demanda. Cubre subreddits de nicho (`subreddits=[...]`) o
escanea todo Reddit (busqueda global).
## Gotchas
- **User-Agent obligatorio**: Reddit devuelve `429 Too Many Requests` si no se
envia un User-Agent identificable. Esta funcion envia
`demand_radar/0.1 (registry market-intel)` por defecto.
- **Rate limiting**: la API publica sin auth tiene limites estrictos. Si haces
muchas llamadas seguidas o pides muchos subreddits, Reddit puede empezar a
devolver 429. La funcion **tolera** estos fallos por subreddit (try/except) y
sigue con los demas — un 429 en un subreddit no aborta la busqueda completa,
simplemente ese subreddit aporta 0 filas.
- **Sin red = lista vacia, no excepcion**: si todas las peticiones fallan,
devuelve `[]`. Revisa el tamano del resultado, no asumas exito.
- `created_utc` es epoch en segundos (float). `platform_score` son los upvotes
netos (`ups`), 0 si Reddit no lo provee.
- `t=year` fija la ventana temporal a un ano; no es parametrizable en esta
version (mantiene la firma simple).
@@ -0,0 +1,99 @@
"""fetch_reddit_search — busca posts en Reddit via la API JSON publica (sin auth).
Funcion impura: hace peticiones HTTP a www.reddit.com. Tolera errores por
subreddit y normaliza cada post a un shape comun de market intelligence.
"""
import requests
_UA = "demand_radar/0.1 (registry market-intel)"
_TIMEOUT = 15
def _parse_children(children: list, query: str) -> list[dict]:
"""Normaliza la lista children de la respuesta de Reddit al shape comun."""
rows = []
for child in children:
data = child.get("data", {}) if isinstance(child, dict) else {}
permalink = data.get("permalink", "") or ""
rows.append({
"source": "reddit",
"platform_id": str(data.get("id", "")),
"title": data.get("title", "") or "",
"body": data.get("selftext", "") or "",
"url": "https://www.reddit.com" + permalink,
"author": data.get("author", "") or "",
"channel": data.get("subreddit", "") or "",
"created_utc": float(data.get("created_utc") or 0.0),
"platform_score": int(data.get("ups") or 0),
"query": query,
})
return rows
def fetch_reddit_search(
query: str,
subreddits: list[str] = None,
limit: int = 50,
sort: str = "new",
) -> list[dict]:
"""Busca posts en Reddit usando la API JSON publica (sin autenticacion).
Por cada subreddit en `subreddits` hace una busqueda restringida a ese
subreddit. Si `subreddits` es None o vacio hace una busqueda global. Cada
fallo por subreddit (red, 429, JSON malformado) se captura y se omite,
continuando con los demas.
Args:
query: Termino de busqueda.
subreddits: Lista de subreddits a buscar (sin el prefijo "r/"). Si None
o vacio, busqueda global en todo Reddit.
limit: Maximo de resultados por subreddit (o por la busqueda global).
sort: Orden de Reddit: "new", "relevance", "top", "comments", "hot".
Returns:
Lista de dicts normalizados (puede ser []). Cada dict tiene las claves:
source, platform_id, title, body, url, author, channel, created_utc,
platform_score, query.
"""
headers = {"User-Agent": _UA}
results: list[dict] = []
targets = subreddits if subreddits else [None]
for sub in targets:
try:
if sub:
url = f"https://www.reddit.com/r/{sub}/search.json"
params = {
"q": query,
"restrict_sr": 1,
"sort": sort,
"limit": limit,
"t": "year",
}
else:
url = "https://www.reddit.com/search.json"
params = {
"q": query,
"sort": sort,
"limit": limit,
"t": "year",
}
resp = requests.get(
url, params=params, headers=headers, timeout=_TIMEOUT
)
resp.raise_for_status()
payload = resp.json()
children = (
payload.get("data", {}).get("children", [])
if isinstance(payload, dict)
else []
)
results.extend(_parse_children(children, query))
except Exception:
# Tolerar fallo por subreddit (red, 429, parsing) y seguir.
continue
return results
@@ -0,0 +1,80 @@
"""Tests para fetch_reddit_search.
El parser (_parse_children) se testea con un fixture offline. La funcion
completa fetch_reddit_search hace red real; aqui solo validamos el shape del
parser para no depender de conectividad en CI.
"""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from fetch_reddit_search import _parse_children
_FIXTURE_CHILDREN = [
{
"data": {
"id": "abc123",
"title": "I wish there was a CSV dedupe tool",
"selftext": "Anyone know a tool for this?",
"permalink": "/r/SaaS/comments/abc123/foo/",
"author": "user1",
"subreddit": "SaaS",
"created_utc": 1700000000.0,
"ups": 42,
}
},
{
"data": {
"id": "def456",
"title": "Link post no body",
"selftext": "",
"permalink": "/r/Entrepreneur/comments/def456/bar/",
"author": "user2",
"subreddit": "Entrepreneur",
"created_utc": 1700001234.0,
"ups": 7,
}
},
]
_EXPECTED_KEYS = {
"source", "platform_id", "title", "body", "url", "author",
"channel", "created_utc", "platform_score", "query",
}
def test_parser_normaliza_children_al_shape_exacto():
rows = _parse_children(_FIXTURE_CHILDREN, "csv dedupe")
assert len(rows) == 2
r = rows[0]
assert set(r.keys()) == _EXPECTED_KEYS
assert r["source"] == "reddit"
assert r["platform_id"] == "abc123"
assert r["title"] == "I wish there was a CSV dedupe tool"
assert r["body"] == "Anyone know a tool for this?"
assert r["url"] == "https://www.reddit.com/r/SaaS/comments/abc123/foo/"
assert r["author"] == "user1"
assert r["channel"] == "SaaS"
assert r["created_utc"] == 1700000000.0
assert isinstance(r["created_utc"], float)
assert r["platform_score"] == 42
assert isinstance(r["platform_score"], int)
assert r["query"] == "csv dedupe"
def test_selftext_vacio_se_mapea_a_body_vacio():
rows = _parse_children(_FIXTURE_CHILDREN, "q")
assert rows[1]["body"] == ""
def test_children_vacio_devuelve_lista_vacia():
assert _parse_children([], "q") == []
if __name__ == "__main__":
test_parser_normaliza_children_al_shape_exacto()
test_selftext_vacio_se_mapea_a_body_vacio()
test_children_vacio_devuelve_lista_vacia()
print("All tests passed.")
@@ -0,0 +1,106 @@
---
name: infer_fk_containment_duckdb
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def infer_fk_containment_duckdb(db_path: str, tables: list = None, min_inclusion: float = 0.9, max_card: int = 200000) -> dict"
description: "Infiere FOREIGN KEYs candidatas entre tablas DuckDB por containment de valores: para un par (col A de T1, col B de T2), inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|; si inclusion >= min_inclusion y B parece clave (distinct/count >= 0.95) entonces A -> B es FK candidata. Poda por tipo base y push-down SQL (COUNT DISTINCT / INTERSECT) sin traer filas a RAM. Parte del grupo eda (relaciones inter-tabla)."
tags: [eda, relations, duckdb, foreign-key, schema-inference, datascience, exploratory-data-analysis]
params:
- name: db_path
desc: "Ruta al archivo DuckDB. Debe existir (lectura read-only via las primitivas del grupo duckdb; no se crea)."
- name: tables
desc: "Lista de nombres de tabla a considerar. None (default) usa todas las del esquema main (duckdb_list_tables). Cada nombre se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlo en el SQL."
- name: min_inclusion
desc: "Umbral minimo de inclusion (0-1) para emitir una FK candidata. inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|. Default 0.9."
- name: max_card
desc: "Tope de filas en la tabla destino (lado B, el caro del INTERSECT). Si count(T2) > max_card, los pares hacia T2 se saltan para no disparar un INTERSECT gigante; se acumula una nota en skipped[]. Default 200000."
output: "dict dict-no-throw. En exito {status:'ok', fk_candidates:[{from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key}, ...], tables:[str], skipped:[str]} con fk_candidates ordenado por inclusion descendente; cardinality es '1:1' (A casi unica en T1) o 'N:1' (A se repite, apunta a la key de T2). En error {status:'error', error:str}."
uses_functions: [duckdb_list_tables_py_infra, duckdb_table_schema_py_infra, duckdb_query_readonly_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_detecta_fk_orders_customer_id", "test_shape_resultado", "test_no_inventa_fk_columnas_no_relacionadas", "test_no_fk_entre_tipos_incompatibles", "test_min_inclusion_alto_filtra", "test_subset_explicito_de_tablas", "test_db_inexistente_devuelve_error", "test_tabla_invalida_devuelve_error"]
test_file_path: "python/functions/datascience/infer_fk_containment_duckdb_test.py"
file_path: "python/functions/datascience/infer_fk_containment_duckdb.py"
---
## Ejemplo
```python
import sys, os, duckdb
sys.path.insert(0, os.path.join("python", "functions"))
from datascience import infer_fk_containment_duckdb
# Base de ejemplo en /tmp: orders.customer_id -> customers.id
path = "/tmp/fk_demo.duckdb"
if os.path.exists(path):
os.remove(path)
con = duckdb.connect(path)
con.execute("CREATE TABLE customers (id INTEGER, region VARCHAR)")
con.execute("INSERT INTO customers VALUES (1,'norte'),(2,'sur'),(3,'este'),(4,'oeste')")
con.execute("CREATE TABLE orders (order_id INTEGER, customer_id INTEGER, total DOUBLE)")
con.execute("INSERT INTO orders VALUES (10,1,99.5),(11,2,12.0),(12,1,45.25),(13,3,7.75),(14,4,60.0)")
con.close()
res = infer_fk_containment_duckdb(path, min_inclusion=0.9)
if res["status"] == "ok":
for fk in res["fk_candidates"]:
print(f"{fk['from_table']}.{fk['from_col']} -> "
f"{fk['to_table']}.{fk['to_col']} "
f"inclusion={fk['inclusion']:.2f} {fk['cardinality']}")
# -> orders.customer_id -> customers.id inclusion=1.00 N:1
else:
print("error:", res["error"])
```
## Cuando usarla
- Cuando exploras un esquema DuckDB que no conoces y quieres descubrir el grafo de relaciones (que tabla referencia a cual) sin que la base haya declarado FKs.
- Como paso del grupo `eda` que va mas alla del perfil por tabla (`summarize_table_duckdb`): aqui se modelan las relaciones INTER-tabla.
- Antes de migrar un esquema sin constraints a otro motor (PostgreSQL, etc.) para proponer las FOREIGN KEYs que faltan.
- Para auditar integridad referencial: una inclusion < 1.0 en una FK que crees que deberia ser total indica valores huerfanos (filas de T1 cuyo valor no existe en la key de T2).
## Gotchas
- **Impura**: lee de disco via las primitivas read-only del grupo `duckdb` (no crea ni modifica la base). El `db_path` debe existir.
- **Coste O(pares podados)**: el numero de comparaciones es O(tablas^2 x columnas^2) ANTES de la poda. La poda por tipo base (solo se comparan columnas de la misma clase: ambos enteros, ambos varchar, ...) recorta drasticamente ese espacio, pero en esquemas con muchas tablas y columnas del mismo tipo puede seguir siendo costoso. Cada par evaluado dispara un `INTERSECT` en el motor.
- **`INTERSECT` puede ser caro en tablas enormes**: por eso `max_card` (default 200000) limita el lado destino. Si `count(T2) > max_card`, los pares hacia T2 se saltan y se anota en `skipped[]`. Sube `max_card` con cuidado: el INTERSECT materializa los distintos de ambos lados.
- **Containment != FK declarada**: que A este contenido en B (con B key-ish) es una FK *probable*, no una garantia. Una columna puede estar contenida por coincidencia (rangos pequenos de enteros, banderas, fechas solapadas) sin ser una relacion real. Revisa siempre las candidatas; trata `inclusion` y `cardinality` como senales, no como verdad.
- **Entero y float NO se mezclan**: la poda por tipo pone INTEGER/BIGINT/... en la clase `integer` y FLOAT/DOUBLE/DECIMAL en `float`, y solo empareja columnas de la misma clase. Una FK entera contra una columna float casi nunca es real, asi que se descarta de entrada.
- **Solo esquema `main`** cuando `tables=None`: hereda el alcance de `duckdb_list_tables` (esquema `main`).
- **Identificadores interpolados**: nombres de tabla/columna se validan contra `^[A-Za-z_][A-Za-z0-9_]*$` y se citan (COUNT DISTINCT / INTERSECT no admiten parametros posicionales para identificadores). Una tabla con nombre invalido devuelve `{status:'error'}`; una columna con nombre invalido se ignora sin abortar.
- **Direccion**: cada candidata es A -> B (A es la FK, B es la key referenciada). El par inverso (B -> A) se evalua por separado y normalmente no pasa el filtro de inclusion o el de key.
## Notas
Definicion de containment usada:
```text
inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|
```
Criterio de emision de FK candidata A (de T1) -> B (de T2):
1. T1 != T2 y `type_class(A) == type_class(B)` (poda por clase de tipo base).
2. `count(T2) <= max_card` (si no, los pares hacia T2 se saltan -> `skipped[]`).
3. `distinct(A) > 0`.
4. B es key-ish: `distinct(B) / count(T2) >= 0.95`.
5. `inclusion(A subseteq B) >= min_inclusion`.
Cardinalidad: si A es (casi) unica en T1 (`distinct(A) / count(T1) >= 0.95`) ->
`1:1`; si no -> `N:1` (A se repite y apunta a la key de T2).
Todo se calcula con push-down (`COUNT(DISTINCT)`, `INTERSECT`) — nunca se traen
filas a RAM. Los `count(*)` por tabla y los `distinct` por columna se cachean para
no recomputarlos entre pares.
```text
fk_candidate = {
from_table, from_col, to_table, to_col, inclusion, cardinality, to_is_key
}
```
@@ -0,0 +1,296 @@
"""infer_fk_containment_duckdb — infiere FOREIGN KEYs candidatas por containment.
Funcion impura: lee de disco a traves de DuckDB (via las primitivas read-only del
grupo `duckdb`: duckdb_list_tables, duckdb_table_schema, duckdb_query_readonly).
Pertenece al grupo de capacidad `eda` (relaciones inter-tabla): descubre que
columnas de una tabla son una clave foranea probable hacia la clave de otra,
SIN que la base la haya declarado.
Idea: para un par (columna A de T1, columna B de T2), la inclusion (o containment)
de A en B es:
inclusion(A subseteq B) = |distinct(A) interseccion distinct(B)| / |distinct(A)|
Si inclusion >= min_inclusion y B "parece clave" (alta unicidad en T2, distinct(B)
/ count(T2) >= 0.95), entonces A -> B es una FK candidata. Todo se calcula con
push-down en el motor de DuckDB (COUNT DISTINCT / INTERSECT); nunca se traen filas
a RAM.
PODA por tipo: solo se evaluan pares cuyas columnas comparten tipo base (ambos
enteros, ambos varchar, ambos fecha, ...). Esto evita el O(n^2) de calcular
containment para todos los pares de columnas, y descarta pares incompatibles que
nunca podrian ser una FK real.
Estilo dict-no-throw del grupo duckdb: nunca lanza; captura cualquier error y
devuelve {status:'error', error:str}.
"""
import re
from infra import (
duckdb_list_tables,
duckdb_query_readonly,
duckdb_table_schema,
)
# Identificador SQL valido. Los nombres de tabla/columna se interpolan citados en
# el SQL (COUNT DISTINCT / INTERSECT no admiten parametros posicionales para
# identificadores), asi que se validan antes de tocar la base.
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Clases de tipo base. Dos columnas solo se comparan si caen en la misma clase.
# Agrupar por clase (no por tipo exacto) permite emparejar INTEGER con BIGINT,
# DECIMAL con DOUBLE, etc. — combinaciones legitimas de FK numerica.
_INTEGER_TYPES = {
"TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT",
"UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "UHUGEINT",
}
_FLOAT_TYPES = {"FLOAT", "REAL", "DOUBLE", "DECIMAL", "NUMERIC"}
_TEXT_TYPES = {"VARCHAR", "TEXT", "STRING", "CHAR", "BPCHAR", "UUID"}
_DATETIME_TYPES = {
"DATE", "TIME", "TIMESTAMP", "DATETIME",
"TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS", "TIMESTAMP_US",
"TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ", "TIMETZ",
}
_BOOL_TYPES = {"BOOLEAN", "BOOL"}
def _base_physical_type(column_type: str) -> str:
"""Normaliza un tipo fisico DuckDB a su forma base en mayusculas.
Quita parametros (DECIMAL(10,2) -> DECIMAL) y modificadores de array
(INTEGER[] -> INTEGER) para poder mapearlo a una clase de tipo.
"""
t = (column_type or "").strip().upper()
t = re.sub(r"\[.*\]$", "", t).strip() # INTEGER[] -> INTEGER
t = re.sub(r"\(.*\)$", "", t).strip() # VARCHAR(50) -> VARCHAR
return t
def _type_class(column_type: str) -> str:
"""Mapea un tipo fisico DuckDB a una clase comparable.
Devuelve 'integer' | 'float' | 'text' | 'datetime' | 'boolean' | 'other'.
Dos columnas solo se consideran emparejables para FK si comparten clase y la
clase no es 'other'. Entero y float NO se mezclan: una FK entera contra una
columna float es semanticamente sospechosa y casi nunca una FK real.
"""
base = _base_physical_type(column_type)
if base in _INTEGER_TYPES:
return "integer"
if base in _FLOAT_TYPES:
return "float"
if base in _TEXT_TYPES:
return "text"
if base in _DATETIME_TYPES:
return "datetime"
if base in _BOOL_TYPES:
return "boolean"
return "other"
def _valid_idents(*names) -> bool:
"""True si todos los identificadores casan con ^[A-Za-z_][A-Za-z0-9_]*$."""
return all(isinstance(n, str) and _IDENT_RE.match(n) for n in names)
def _scalar(res: dict):
"""Extrae el unico valor escalar de un resultado duckdb_query_readonly.
Devuelve None si el resultado no es ok o no trae filas.
"""
if res["status"] != "ok" or not res["rows"]:
return None
row = res["rows"][0]
# La query siempre alias-a la unica columna; devolvemos su valor.
return next(iter(row.values()))
def infer_fk_containment_duckdb(
db_path: str,
tables: list = None,
min_inclusion: float = 0.9,
max_card: int = 200000,
) -> dict:
"""Infiere FOREIGN KEYs candidatas entre tablas DuckDB por containment de valores.
Args:
db_path: ruta al archivo DuckDB. Debe existir (lectura read-only via las
primitivas del grupo duckdb; no se crea).
tables: lista de nombres de tabla a considerar. None (default) usa todas
las del esquema main (duckdb_list_tables).
min_inclusion: umbral minimo de inclusion (0-1) para emitir una FK
candidata. inclusion(A subseteq B) = |distinct(A) interseccion
distinct(B)| / |distinct(A)|. Default 0.9.
max_card: tope de filas en la tabla destino (lado B, el caro del INTERSECT).
Si count(T2) > max_card, el par se salta para no disparar un INTERSECT
gigante; se acumula una nota en skipped[]. Default 200000.
Returns:
dict dict-no-throw. En exito:
{status:'ok',
fk_candidates:[{from_table, from_col, to_table, to_col, inclusion,
cardinality, to_is_key}, ...], # ordenado por inclusion desc
tables:[str], skipped:[str]}
En error (sin lanzar): {status:'error', error:str}.
"""
try:
# 1) Lista de tablas a considerar.
if tables is None:
list_res = duckdb_list_tables(db_path)
if list_res["status"] != "ok":
return {"status": "error", "error": list_res["error"]}
tables = list_res["tables"]
if not isinstance(tables, list):
return {"status": "error", "error": "tables debe ser una lista o None"}
tables = [t for t in tables if isinstance(t, str)]
if not _valid_idents(*tables):
return {
"status": "error",
"error": "algun nombre de tabla no casa con ^[A-Za-z_][A-Za-z0-9_]*$",
}
skipped = []
# 2) Schema + count + cache de columnas por tabla.
# cols_by_table[t] = [{name, type, type_class}, ...]
cols_by_table = {}
count_by_table = {}
for t in tables:
sch = duckdb_table_schema(db_path, t)
if sch["status"] != "ok":
return {"status": "error", "error": sch["error"]}
cols = []
for c in sch["columns"]:
if not _valid_idents(c["name"]):
# Columna con nombre no interpolable: la ignoramos sin abortar.
continue
cols.append(
{
"name": c["name"],
"type": c["type"],
"type_class": _type_class(c["type"]),
}
)
cols_by_table[t] = cols
cnt = _scalar(
duckdb_query_readonly(db_path, f'SELECT count(*) AS n FROM "{t}"')
)
count_by_table[t] = int(cnt) if cnt is not None else 0
# 3) Cache de distinct(col) por (tabla, columna) para no recomputarlo.
distinct_cache = {}
def distinct_count(table: str, col: str):
key = (table, col)
if key in distinct_cache:
return distinct_cache[key]
val = _scalar(
duckdb_query_readonly(
db_path, f'SELECT count(DISTINCT "{col}") AS d FROM "{table}"'
)
)
val = int(val) if val is not None else 0
distinct_cache[key] = val
return val
# 4) Cache de "B es key-ish" por (tabla destino, columna). distinct/count
# >= 0.95. Solo se evalua para columnas que aparecen como lado B.
key_cache = {}
def to_is_key(table: str, col: str):
cache_key = (table, col)
if cache_key in key_cache:
return key_cache[cache_key]
n = count_by_table[table]
if n <= 0:
key_cache[cache_key] = (False, 0.0)
return key_cache[cache_key]
d = distinct_count(table, col)
ratio = d / n
key_cache[cache_key] = (ratio >= 0.95, ratio)
return key_cache[cache_key]
candidates = []
# 5) Pares (A en T1, B en T2) con T1 != T2 y misma clase de tipo (PODA).
for t1 in tables:
for t2 in tables:
if t1 == t2:
continue
# Lado caro: el INTERSECT lee distinct de T2. Si T2 es enorme,
# saltamos todos los pares hacia el (B en T2) y dejamos nota.
if count_by_table[t2] > max_card:
note = (
f"skip pares -> '{t2}': count {count_by_table[t2]} "
f"> max_card {max_card}"
)
if note not in skipped:
skipped.append(note)
continue
for a in cols_by_table[t1]:
if a["type_class"] == "other":
continue
for b in cols_by_table[t2]:
# PODA: solo pares con la misma clase de tipo base.
if a["type_class"] != b["type_class"]:
continue
# distinct(A); si es 0, no hay containment que medir.
d_a = distinct_count(t1, a["name"])
if d_a == 0:
continue
# B debe parecer key (alta unicidad en T2).
b_is_key, _b_ratio = to_is_key(t2, b["name"])
if not b_is_key:
continue
# interseccion de distintos via INTERSECT (push-down).
inter_sql = (
"SELECT count(*) AS c FROM ("
f'SELECT DISTINCT "{a["name"]}" FROM "{t1}" '
"INTERSECT "
f'SELECT DISTINCT "{b["name"]}" FROM "{t2}"'
")"
)
inter = _scalar(duckdb_query_readonly(db_path, inter_sql))
if inter is None:
continue
inter = int(inter)
inclusion = inter / d_a
if inclusion < min_inclusion:
continue
# Cardinalidad: si A es (casi) unica en T1 -> 1:1; si no N:1.
n_t1 = count_by_table[t1]
a_unique = n_t1 > 0 and (d_a / n_t1) >= 0.95
cardinality = "1:1" if a_unique else "N:1"
candidates.append(
{
"from_table": t1,
"from_col": a["name"],
"to_table": t2,
"to_col": b["name"],
"inclusion": inclusion,
"cardinality": cardinality,
"to_is_key": True,
}
)
candidates.sort(key=lambda c: c["inclusion"], reverse=True)
return {
"status": "ok",
"fk_candidates": candidates,
"tables": tables,
"skipped": skipped,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
@@ -0,0 +1,147 @@
"""Tests para infer_fk_containment_duckdb."""
import duckdb
import pytest
from .infer_fk_containment_duckdb import infer_fk_containment_duckdb
@pytest.fixture
def db(tmp_path):
"""DuckDB temporal: customers (id PK) + orders (customer_id FK -> customers.id).
Ademas una columna `total` (DOUBLE) en orders y `region` (VARCHAR) en customers
que NO estan relacionadas, para comprobar que la funcion no inventa FKs entre
columnas sin containment ni entre tipos incompatibles.
"""
path = str(tmp_path / "fk_test.duckdb")
con = duckdb.connect(path)
con.execute(
"CREATE TABLE customers ("
" id INTEGER," # PK: 1..4, unica
" region VARCHAR" # categorica, no relacionada con nada de orders
")"
)
con.execute(
"INSERT INTO customers VALUES "
"(1, 'norte'), (2, 'sur'), (3, 'este'), (4, 'oeste')"
)
con.execute(
"CREATE TABLE orders ("
" order_id INTEGER," # PK de orders, unica
" customer_id INTEGER," # FK -> customers.id (todos en 1..4)
" total DOUBLE" # numerica float, no relacionada
")"
)
con.execute(
"INSERT INTO orders VALUES "
"(10, 1, 99.5), "
"(11, 2, 12.0), "
"(12, 1, 45.25), " # customer_id se repite -> N:1
"(13, 3, 7.75), "
"(14, 4, 60.0)"
)
con.close()
return path
def _find(candidates, from_table, from_col, to_table, to_col):
"""Devuelve la primera FK candidata que casa con la firma dada, o None."""
for c in candidates:
if (
c["from_table"] == from_table
and c["from_col"] == from_col
and c["to_table"] == to_table
and c["to_col"] == to_col
):
return c
return None
def test_detecta_fk_orders_customer_id(db):
"""orders.customer_id subseteq customers.id con inclusion 1.0 y cardinalidad N:1."""
res = infer_fk_containment_duckdb(db)
assert res["status"] == "ok"
fk = _find(res["fk_candidates"], "orders", "customer_id", "customers", "id")
assert fk is not None, "no detecto orders.customer_id -> customers.id"
# Los 4 valores distintos de customer_id (1,2,3,4) estan todos en customers.id.
assert fk["inclusion"] == pytest.approx(1.0)
# customers.id es key (4 distintos / 4 filas = 1.0 >= 0.95).
assert fk["to_is_key"] is True
# customer_id NO es unica en orders (1 se repite) -> N:1.
assert fk["cardinality"] == "N:1"
def test_shape_resultado(db):
"""Estructura del resultado y de cada FK candidata."""
res = infer_fk_containment_duckdb(db)
assert res["status"] == "ok"
for key in ("status", "fk_candidates", "tables", "skipped"):
assert key in res
assert set(res["tables"]) == {"customers", "orders"}
for fk in res["fk_candidates"]:
for key in (
"from_table", "from_col", "to_table", "to_col",
"inclusion", "cardinality", "to_is_key",
):
assert key in fk, f"falta clave {key} en fk_candidate"
assert 0.0 <= fk["inclusion"] <= 1.0
assert fk["cardinality"] in ("1:1", "N:1")
def test_no_inventa_fk_columnas_no_relacionadas(db):
"""No emite FK entre columnas sin containment real.
- orders.total (DOUBLE) no debe relacionarse con nada (es float aislado).
- customers.region (VARCHAR) no tiene contraparte text con la que casar.
- order_id (PK de orders) no esta contenido en ninguna key de customers.
"""
res = infer_fk_containment_duckdb(db)
assert res["status"] == "ok"
candidates = res["fk_candidates"]
# total nunca aparece como origen de una FK.
assert _find(candidates, "orders", "total", "customers", "id") is None
assert not any(c["from_col"] == "total" for c in candidates)
# region (varchar de customers) no casa con ninguna columna text de orders.
assert not any(c["from_col"] == "region" for c in candidates)
# order_id (10..14) NO esta contenido en customers.id (1..4): inclusion baja.
assert _find(candidates, "orders", "order_id", "customers", "id") is None
def test_no_fk_entre_tipos_incompatibles(db):
"""customer_id (INTEGER) jamas se empareja con total (DOUBLE): poda por tipo."""
res = infer_fk_containment_duckdb(db)
assert res["status"] == "ok"
# No debe existir ninguna candidata cuyo destino sea orders.total.
assert not any(c["to_col"] == "total" for c in res["fk_candidates"])
def test_min_inclusion_alto_filtra(db):
"""Subir min_inclusion por encima de 1.0 deja la lista vacia."""
res = infer_fk_containment_duckdb(db, min_inclusion=1.01)
assert res["status"] == "ok"
assert res["fk_candidates"] == []
def test_subset_explicito_de_tablas(db):
"""Pasar tables=[...] limita las tablas consideradas."""
res = infer_fk_containment_duckdb(db, tables=["customers", "orders"])
assert res["status"] == "ok"
assert set(res["tables"]) == {"customers", "orders"}
def test_db_inexistente_devuelve_error(tmp_path):
"""Una base que no existe devuelve {status:'error'} sin lanzar."""
res = infer_fk_containment_duckdb(str(tmp_path / "no_existe.duckdb"))
assert res["status"] == "error"
assert isinstance(res["error"], str)
def test_tabla_invalida_devuelve_error(db):
"""Un nombre de tabla no interpolable devuelve error sin tocar la base."""
res = infer_fk_containment_duckdb(db, tables=["orders; DROP TABLE orders"])
assert res["status"] == "error"
@@ -0,0 +1,89 @@
---
id: infer_semantic_type_py_datascience
name: infer_semantic_type
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def infer_semantic_type(values: list, sample: int = 200, min_match: float = 0.8) -> dict"
description: "Detects the semantic type of a text column via regex (email, url, ipv4, ipv6, uuid, iban, credit_card, phone_intl, postal_code_es, currency, datetime_iso, date_eu, integer, decimal, boolean, hex_color). Cheap first pass for EDA without an LLM: samples non-null values and returns the type whose match rate is highest and above a threshold."
tags: [eda, semantic-type, profiling, regex, column-inference, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [re]
example: |
from infer_semantic_type import infer_semantic_type
infer_semantic_type(["ana@example.com", "bob@test.org"])
# {"semantic_type": "email", "match_rate": 1.0, "candidates": [...]}
tested: true
tests:
- "test_emails_dominante"
- "test_uuids_dominante"
- "test_mezcla_sin_tipo_dominante"
- "test_lista_vacia"
- "test_solo_nulos_y_blancos"
test_file_path: "python/functions/datascience/infer_semantic_type_test.py"
file_path: "python/functions/datascience/infer_semantic_type.py"
params:
- name: values
desc: "Column values (any type). Each is coerced to str and stripped before matching. None and empty/whitespace-only strings are treated as null and skipped."
- name: sample
desc: "Maximum number of non-null values to test against the pattern catalog. Default 200. Caps cost on large columns."
- name: min_match
desc: "Minimum fraction (0.0-1.0) of sampled values that must match a type for it to be returned as semantic_type. Default 0.8."
output: >
Dict with three keys: "semantic_type" (str) = best matching type if its
match_rate >= min_match, else ""; "match_rate" (float) = fraction of sampled
values matching the best type (0.0 when no candidate); "candidates"
(list of {"type": str, "match_rate": float}) = every type with match_rate > 0,
sorted by match_rate descending (for debugging).
---
## Ejemplo
```python
from infer_semantic_type import infer_semantic_type
# Columna homogenea de emails -> tipo claro
infer_semantic_type([
"ana@example.com",
"bob@test.org",
"carol.smith@mail.co.uk",
"dev+tag@domain.io",
])
# {"semantic_type": "email", "match_rate": 1.0,
# "candidates": [{"type": "email", "match_rate": 1.0}]}
# Columna mezclada sin tipo dominante -> "" pero candidates ayuda a depurar
infer_semantic_type([
"ana@example.com",
"https://example.com/path",
"550e8400-e29b-41d4-a716-446655440000",
"just free text",
])
# {"semantic_type": "", "match_rate": 0.25,
# "candidates": [{"type": "email", "match_rate": 0.25}, ...]}
```
## Cuando usarla
Cuando perfilas un dataset y necesitas saber QUE representa una columna de texto
(email, url, iban, uuid, fecha, importe...) antes de decidir parsing, validacion
o anonimizacion. Es el primer paso barato del EDA: corre en regex puro, sin LLM
ni dependencias, y dejas la inferencia cara (LLM, ML) solo para las columnas que
salen ambiguas (`semantic_type == ""`, mirar `candidates`).
## Notas
- Funcion pura: solo usa `re` de stdlib, sin I/O ni estado mutable.
- El match es por `fullmatch` (el valor entero debe conformar al tipo, no un
substring), asi un texto libre que "contiene" un email no cuenta como email.
- Tipos solapan a proposito (un entero matchea `integer` y `boolean` para "0"/"1");
por eso se devuelve el de mayor `match_rate` y, en empate, el alfabeticamente
menor para que el resultado sea determinista. Revisar `candidates` cuando el
resultado sorprenda.
- `credit_card` no aplica validacion Luhn; el regex de 16 digitos basta para EDA.
@@ -0,0 +1,114 @@
"""Infer the semantic type of a text column via regex pattern matching.
Pure, stdlib-only. No LLM, no I/O, no external dependencies. Cheap first pass
for exploratory data analysis: classify what a column "means" (email, url,
iban, ...) by sampling values and matching them against a regex catalog.
"""
import re
# Catalog of semantic types -> compiled regex.
# Each pattern is anchored (fullmatch semantics) so a value only counts as a
# match when the whole string conforms to the type, not just a substring.
_PATTERNS = {
"email": re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+", re.IGNORECASE),
"url": re.compile(r"https?://[^\s]+", re.IGNORECASE),
"ipv4": re.compile(
r"(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}"
r"(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)"
),
"ipv6": re.compile(
r"(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{0,4}", re.IGNORECASE
),
"uuid": re.compile(
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
r"[0-9a-f]{4}-[0-9a-f]{12}",
re.IGNORECASE,
),
"iban": re.compile(r"[A-Z]{2}\d{2}[A-Z0-9]{11,30}", re.IGNORECASE),
"credit_card": re.compile(r"\d{4}(?:[ -]?\d{4}){3}"),
"phone_intl": re.compile(r"\+\d[\d\s()-]{6,}\d"),
"postal_code_es": re.compile(r"\d{5}"),
"currency": re.compile(
r"(?:[€$£]\s?\d[\d.,]*|\d[\d.,]*\s?(?:EUR|USD|GBP))",
re.IGNORECASE,
),
"datetime_iso": re.compile(
r"\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?"
r"(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?"
),
"date_eu": re.compile(r"\d{1,2}/\d{1,2}/\d{4}"),
"integer": re.compile(r"[+-]?\d+"),
"decimal": re.compile(r"[+-]?\d+[.,]\d+"),
"boolean": re.compile(r"true|false|0|1|si|no|yes", re.IGNORECASE),
"hex_color": re.compile(r"#[0-9a-f]{6}", re.IGNORECASE),
}
def infer_semantic_type(
values: list, sample: int = 200, min_match: float = 0.8
) -> dict:
"""Detect the semantic type of a column of values via regex.
Samples up to ``sample`` non-null values, tests each against a catalog of
regex patterns (email, url, ipv4, uuid, iban, ...), and returns the type
whose match rate is the highest and at least ``min_match``.
Args:
values: Column values (any type; each is coerced to ``str`` and
stripped before matching). ``None`` and empty/whitespace-only
strings are treated as null and skipped.
sample: Maximum number of non-null values to test (default 200).
min_match: Minimum fraction of sampled values that must match a type
for it to be returned as ``semantic_type`` (default 0.8).
Returns:
Dict with three keys:
- ``semantic_type`` (str): best matching type if its match_rate is
>= ``min_match``, otherwise ``""``.
- ``match_rate`` (float): fraction of sampled values matching the best
type (0.0 when there is no candidate).
- ``candidates`` (list[dict]): every type with match_rate > 0 as
``{"type": str, "match_rate": float}``, sorted by match_rate desc.
"""
# Collect non-null, stripped string values up to the sample size.
sampled: list = []
for v in values:
if v is None:
continue
s = str(v).strip()
if not s:
continue
sampled.append(s)
if len(sampled) >= sample:
break
if not sampled:
return {"semantic_type": "", "match_rate": 0.0, "candidates": []}
n = len(sampled)
candidates: list = []
for type_name, pattern in _PATTERNS.items():
hits = sum(1 for s in sampled if pattern.fullmatch(s) is not None)
if hits > 0:
candidates.append(
{"type": type_name, "match_rate": hits / n}
)
# Sort by match_rate desc, then type name for deterministic ties.
candidates.sort(key=lambda c: (-c["match_rate"], c["type"]))
if candidates and candidates[0]["match_rate"] >= min_match:
best = candidates[0]
return {
"semantic_type": best["type"],
"match_rate": best["match_rate"],
"candidates": candidates,
}
best_rate = candidates[0]["match_rate"] if candidates else 0.0
return {
"semantic_type": "",
"match_rate": best_rate,
"candidates": candidates,
}
@@ -0,0 +1,65 @@
"""Tests para infer_semantic_type."""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from infer_semantic_type import infer_semantic_type
def test_emails_dominante():
"""Lista de emails devuelve semantic_type email con match_rate alto."""
values = [
"ana@example.com",
"bob@test.org",
"carol.smith@mail.co.uk",
"dev+tag@domain.io",
"user@sub.domain.net",
]
result = infer_semantic_type(values)
assert result["semantic_type"] == "email"
assert result["match_rate"] >= 0.8
assert any(c["type"] == "email" for c in result["candidates"])
def test_uuids_dominante():
"""Lista de UUIDs devuelve semantic_type uuid."""
values = [
"550e8400-e29b-41d4-a716-446655440000",
"f47ac10b-58cc-4372-a567-0e02b2c3d479",
"6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"00000000-0000-0000-0000-000000000000",
]
result = infer_semantic_type(values)
assert result["semantic_type"] == "uuid"
assert result["match_rate"] == 1.0
def test_mezcla_sin_tipo_dominante():
"""Lista mezclada sin tipo dominante devuelve cadena vacia."""
values = [
"ana@example.com",
"https://example.com/path",
"550e8400-e29b-41d4-a716-446655440000",
"#ff00aa",
"just some free text here",
]
result = infer_semantic_type(values)
assert result["semantic_type"] == ""
def test_lista_vacia():
"""Lista vacia devuelve semantic_type vacio y match_rate 0."""
result = infer_semantic_type([])
assert result["semantic_type"] == ""
assert result["match_rate"] == 0.0
assert result["candidates"] == []
def test_solo_nulos_y_blancos():
"""Valores nulos y en blanco se tratan como vacio."""
result = infer_semantic_type([None, "", " ", None])
assert result["semantic_type"] == ""
assert result["match_rate"] == 0.0
assert result["candidates"] == []
@@ -0,0 +1,79 @@
---
name: isolation_forest_outliers
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def isolation_forest_outliers(columns: dict, contamination: float = 0.05, max_report: int = 50) -> dict"
description: "Detecta outliers MULTIVARIANTE (filas anomalas considerando todas las columnas a la vez, no columna a columna) con sklearn IsolationForest. Estandariza con StandardScaler, descarta filas con None y usa random_state=0 para resultados deterministas. Devuelve conteo, porcentaje, filas anomalas ordenadas (mas anomala primero) con su score, umbral y dimensiones usadas. Con <2 columnas numericas o <10 filas validas devuelve note 'datos insuficientes' sin petar."
tags: [eda, models, outliers, anomaly-detection, isolation-forest, multivariate, sklearn]
params:
- name: columns
desc: "dict {nombre_columna: [valores numericos]}. Listas alineadas por fila (la fila i de cada columna forma una observacion). Solo se usan columnas cuyos valores sean todos numericos (None permitido por fila, NaN/Inf descartan la columna); el resto se ignora."
- name: contamination
desc: "Proporcion esperada de outliers en [0, 0.5], pasada a IsolationForest. Sube/baja la fraccion de filas marcadas. Default 0.05."
- name: max_report
desc: "Maximo de filas anomalas a devolver en outlier_rows, mas anomala primero. n_outliers cuenta TODAS aunque se trunque el detalle. Default 50."
output: "dict {n_outliers: total de filas outlier; outlier_pct: % sobre filas validas (0-100); outlier_rows: lista de {row_index, score} ordenada por score asc (mas anomala primero), truncada a max_report; threshold: umbral de decision (model.offset_), outlier <=> decision_function < threshold; n_rows_used: filas validas tras descartar None; n_features: columnas numericas usadas}. row_index cuenta SOLO las filas validas (sin None), en orden de aparicion empezando en 0 — no es el indice original si se descarto alguna fila. Si <2 columnas numericas o <10 filas validas: {n_outliers: 0, note: 'datos insuficientes'}."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_cloud_with_three_far_points_flags_them", "test_insufficient_columns_returns_note", "test_insufficient_rows_returns_note"]
test_file_path: "python/functions/datascience/isolation_forest_outliers_test.py"
file_path: "python/functions/datascience/isolation_forest_outliers.py"
---
## Ejemplo
```python
from datascience import isolation_forest_outliers
# Nube densa alrededor de (0, 0) + 3 puntos claramente alejados al final.
xs = [0.1, -0.2, 0.0, 0.3, -0.1, 0.2, -0.3, 0.05, -0.15, 0.25, 0.0, -0.05]
ys = [0.0, 0.1, -0.1, 0.2, -0.2, 0.05, -0.05, 0.15, -0.25, 0.1, 0.0, 0.2]
# 3 outliers multivariante (lejos de la nube en el plano):
xs += [9.0, -8.5, 10.0]
ys += [9.5, -9.0, -8.0]
columns = {"x": xs, "y": ys}
result = isolation_forest_outliers(columns, contamination=0.2, max_report=10)
print(result["n_outliers"]) # >= 3
print(result["n_rows_used"], result["n_features"]) # 15 2
for row in result["outlier_rows"]:
print(row["row_index"], round(row["score"], 4))
# Las filas 12, 13, 14 (los 3 puntos lejanos) aparecen primero, score mas bajo.
```
## Cuando usarla
Cuando quieras encontrar **filas anomalas de una tabla mirando todas sus
columnas a la vez** en la fase EDA, en lugar de buscar outliers columna a
columna con z-score/IQR. Es el caso en que una observacion es razonable en cada
variable por separado pero rara en su combinacion (p.ej. peso bajo + altura
alta). Pasale las columnas numericas alineadas por fila y te devuelve las filas
mas sospechosas ordenadas por anomalia para inspeccionarlas. Modelo barato y
determinista (`random_state=0`), apto para correr de forma reproducible dentro
de un perfilado.
## Gotchas
- **Pura solo porque fija `random_state=0`**: IsolationForest es estocastico;
sin la semilla los resultados variarian entre llamadas. No cambiar la semilla
si se quiere determinismo.
- **row_index es relativo a las filas validas**: si alguna fila tenia None en
una columna usada, se descarta y los indices se recalculan sobre las filas
que quedan (orden de aparicion, base 0). No mapea 1:1 con las listas de
entrada cuando hay None.
- **Seleccion de columnas estricta**: una columna con cualquier valor no
numerico (str, bool, NaN, Inf) se ignora por completo. Si quedan <2 columnas
numericas, devuelve `note: "datos insuficientes"`.
- **Minimo 10 filas validas**: con menos, devuelve `note` en vez de un modelo
poco fiable.
- `contamination` influye en cuantas filas se marcan; con datos sin outliers
reales un valor alto forzara falsos positivos.
@@ -0,0 +1,118 @@
"""Deteccion de outliers multivariante con Isolation Forest.
Detecta filas anomalas considerando TODAS las columnas a la vez (no columna a
columna): una fila puede ser normal en cada variable por separado y aun asi ser
un outlier por la combinacion de sus valores. Pura y determinista
(`random_state=0`).
"""
import numpy as np
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler
def isolation_forest_outliers(
columns: dict,
contamination: float = 0.05,
max_report: int = 50,
) -> dict:
"""Detecta outliers multivariante con Isolation Forest.
Args:
columns: dict {nombre_columna: [valores numericos]}. Todas las listas se
asumen alineadas por fila (misma longitud, la fila i de cada columna
forma una observacion). Solo se usan columnas cuyos valores sean
numericos; las demas se ignoran.
contamination: proporcion esperada de outliers en [0, 0.5], pasada a
IsolationForest. Default 0.05.
max_report: numero maximo de filas anomalas a devolver en
outlier_rows, las mas anomalas primero. Default 50.
Returns:
dict con:
n_outliers: numero total de filas marcadas como outlier.
outlier_pct: porcentaje de outliers sobre filas validas (0-100).
outlier_rows: lista de {row_index, score} de los outliers, mas
anomalo primero, truncada a max_report.
threshold: umbral de decision del modelo (offset_). Una fila es
outlier cuando su score (decision_function) es < threshold.
n_rows_used: filas validas usadas (tras descartar filas con None).
n_features: numero de columnas numericas usadas.
IMPORTANTE: row_index es el indice contando SOLO las filas validas (las
que no tenian ningun None en las columnas numericas usadas), empezando
en 0 en orden de aparicion. No es el indice en las listas originales si
se descarto alguna fila por contener None.
Si hay menos de 2 columnas numericas o menos de 10 filas validas,
devuelve {n_outliers: 0, note: "datos insuficientes"} sin petar.
"""
# Selecciona solo columnas con todos los valores numericos (ints/floats,
# bool no cuenta). None se permite a nivel de fila y se filtra despues.
numeric_cols: dict[str, list] = {}
for name, values in columns.items():
if not isinstance(values, (list, tuple)):
continue
ok = True
for v in values:
if v is None:
continue
if isinstance(v, bool) or not isinstance(v, (int, float)):
ok = False
break
if isinstance(v, float) and (np.isnan(v) or np.isinf(v)):
ok = False
break
if ok:
numeric_cols[name] = list(values)
if len(numeric_cols) < 2:
return {"n_outliers": 0, "note": "datos insuficientes"}
col_names = list(numeric_cols.keys())
n_rows_total = min(len(numeric_cols[c]) for c in col_names)
# Construye matriz fila a fila, descartando filas con None en cualquier
# columna usada. row_index = posicion entre las filas validas.
rows: list[list[float]] = []
for i in range(n_rows_total):
row = [numeric_cols[c][i] for c in col_names]
if any(v is None for v in row):
continue
rows.append([float(v) for v in row])
if len(rows) < 10:
return {"n_outliers": 0, "note": "datos insuficientes"}
matrix = np.asarray(rows, dtype=float)
n_rows_used = matrix.shape[0]
n_features = matrix.shape[1]
# Estandariza para que ninguna columna domine por escala.
scaled = StandardScaler().fit_transform(matrix)
model = IsolationForest(contamination=contamination, random_state=0)
labels = model.fit_predict(scaled) # -1 = outlier, 1 = inlier
# decision_function: cuanto menor, mas anomalo. Outlier <=> score < 0
# tras el ajuste de offset_ que aplica sklearn (score = raw - offset_).
scores = model.decision_function(scaled)
threshold = float(model.offset_)
outlier_idx = [i for i, lab in enumerate(labels) if lab == -1]
# Mas anomalo primero (score mas bajo primero).
outlier_idx.sort(key=lambda i: scores[i])
n_outliers = len(outlier_idx)
outlier_rows = [
{"row_index": int(i), "score": float(scores[i])}
for i in outlier_idx[:max_report]
]
return {
"n_outliers": n_outliers,
"outlier_pct": round(100.0 * n_outliers / n_rows_used, 4),
"outlier_rows": outlier_rows,
"threshold": threshold,
"n_rows_used": n_rows_used,
"n_features": n_features,
}
@@ -0,0 +1,51 @@
"""Tests para isolation_forest_outliers."""
from isolation_forest_outliers import isolation_forest_outliers
def test_cloud_with_three_far_points_flags_them():
# Nube densa alrededor del origen.
xs = [0.1, -0.2, 0.0, 0.3, -0.1, 0.2, -0.3, 0.05, -0.15, 0.25, 0.0, -0.05]
ys = [0.0, 0.1, -0.1, 0.2, -0.2, 0.05, -0.05, 0.15, -0.25, 0.1, 0.0, 0.2]
n_cloud = len(xs)
# 3 puntos claramente alejados de la nube (outliers multivariante).
far = [(9.0, 9.5), (-8.5, -9.0), (10.0, -8.0)]
for fx, fy in far:
xs.append(fx)
ys.append(fy)
far_indices = {n_cloud, n_cloud + 1, n_cloud + 2}
result = isolation_forest_outliers(
{"x": xs, "y": ys}, contamination=0.2, max_report=50
)
assert "note" not in result
assert result["n_rows_used"] == len(xs)
assert result["n_features"] == 2
assert result["n_outliers"] >= 3
reported = {row["row_index"] for row in result["outlier_rows"]}
# Los 3 puntos lejanos deben estar entre los outliers detectados.
assert far_indices.issubset(reported)
# outlier_rows ordenadas: mas anomalo (score mas bajo) primero.
scores = [row["score"] for row in result["outlier_rows"]]
assert scores == sorted(scores)
def test_insufficient_columns_returns_note():
# Una sola columna numerica -> multivariante no aplica.
result = isolation_forest_outliers(
{"x": list(range(20))}, contamination=0.05
)
assert result == {"n_outliers": 0, "note": "datos insuficientes"}
def test_insufficient_rows_returns_note():
# Dos columnas pero <10 filas validas.
result = isolation_forest_outliers(
{"x": [1.0, 2.0, 3.0, 4.0], "y": [4.0, 3.0, 2.0, 1.0]},
contamination=0.05,
)
assert result == {"n_outliers": 0, "note": "datos insuficientes"}
@@ -0,0 +1,76 @@
---
name: kmeans_segments
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def kmeans_segments(columns: dict, k_min: int = 2, k_max: int = 8) -> dict"
description: "Detecta segmentos naturales con KMeans eligiendo el mejor k automaticamente por silhouette. Estandariza, descarta filas con None y prueba k de k_min a min(k_max, n_rows-1)."
tags: [eda, models, kmeans, clustering, segmentation, silhouette, unsupervised]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [numpy, scikit-learn]
tested: true
tests: ["test_three_separated_blobs_finds_k3", "test_insufficient_rows_returns_note", "test_insufficient_numeric_columns_returns_note", "test_rows_with_none_are_dropped"]
test_file_path: "python/functions/datascience/kmeans_segments_test.py"
file_path: "python/functions/datascience/kmeans_segments.py"
params:
- name: columns
desc: "dict {col_name: [valores numericos]} con todas las listas alineadas por fila (misma longitud). Solo se usan columnas numericas; las no numericas se ignoran. Las filas con algun None se descartan."
- name: k_min
desc: "Numero minimo de clusters a probar. Default 2. El minimo efectivo de filas validas requerido es k_min*2."
- name: k_max
desc: "Numero maximo de clusters a probar. Default 8. Se acota internamente a min(k_max, n_rows_validas-1)."
output: "dict con best_k (k de mayor silhouette), silhouette (score del mejor k), scores_by_k (lista de {k, silhouette, inertia} por cada k probado), cluster_sizes (tamano de cada cluster del mejor modelo), centers (centroides en espacio estandarizado), n_rows_used (filas validas) y n_features (columnas numericas). Si hay <2 columnas numericas o <k_min*2 filas validas devuelve {best_k: 0, note: 'datos insuficientes'}."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.kmeans_segments import kmeans_segments
# Tres grupos claramente separados en 2D.
g1 = [(0.0, 0.0)] * 30
g2 = [(10.0, 10.0)] * 30
g3 = [(0.0, 10.0)] * 30
pts = g1 + g2 + g3
columns = {
"x": [p[0] for p in pts],
"y": [p[1] for p in pts],
}
result = kmeans_segments(columns, k_min=2, k_max=6)
print(result["best_k"]) # 3
print(round(result["silhouette"], 2)) # ~1.0 (grupos perfectos)
print(result["cluster_sizes"]) # [30, 30, 30] (en algun orden)
```
## Cuando usarla
Usala cuando, durante un EDA, quieras descubrir cuantos segmentos naturales hay en un
conjunto de columnas numericas sin saber el numero de grupos de antemano: clientes por
comportamiento, productos por metricas, regiones por indicadores. Elige el k optimo por
ti via silhouette, asi que no tienes que fijarlo a mano. Pasale solo las columnas
numericas relevantes alineadas por fila.
## Gotchas
Funcion pura y determinista (KMeans con random_state=0 y n_init=10), pero requiere
`numpy` y `scikit-learn` instalados. Los centroides (`centers`) estan en el espacio
estandarizado (z-scores), no en las unidades originales de las columnas. La silhouette
puede ser baja o negativa si los datos no tienen estructura de cluster real; un best_k
alto con silhouette baja sugiere ausencia de segmentacion clara.
## Notas
Estandariza con StandardScaler antes de clusterizar para que todas las features pesen
igual. Para cada k en [k_min, min(k_max, n_rows-1)] ajusta KMeans y calcula silhouette;
devuelve el modelo con mayor silhouette. Guardas de datos insuficientes: <2 columnas
numericas o <k_min*2 filas validas devuelven {best_k: 0, note: "datos insuficientes"}
sin lanzar excepcion.
@@ -0,0 +1,101 @@
"""Detección de segmentos naturales con KMeans + selección automática de k por silhouette."""
import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler
def kmeans_segments(columns: dict, k_min: int = 2, k_max: int = 8) -> dict:
"""Detecta segmentos naturales en columnas numéricas con KMeans.
Estandariza las features, descarta las filas con algún valor None, y prueba
cada k en el rango [k_min, min(k_max, n_rows-1)] eligiendo el de mayor
silhouette. Determinista: KMeans usa random_state=0 y n_init fijo.
Args:
columns: dict {col_name: [valores numéricos]} con todas las listas
alineadas por fila (misma longitud).
k_min: número mínimo de clusters a probar (>= 2).
k_max: número máximo de clusters a probar (se acota a n_rows-1).
Returns:
dict con:
- best_k: k con mejor silhouette.
- silhouette: silhouette del mejor k.
- scores_by_k: lista de {k, silhouette, inertia} por cada k probado.
- cluster_sizes: tamaño de cada cluster del mejor modelo.
- centers: centroides del mejor modelo en el espacio estandarizado.
- n_rows_used: filas válidas usadas tras descartar None.
- n_features: número de columnas numéricas usadas.
Si hay menos de 2 columnas numéricas o menos de k_min*2 filas válidas,
devuelve {"best_k": 0, "note": "datos insuficientes"} sin lanzar error.
"""
# Quedarse solo con columnas cuyos valores sean numéricos (o None).
numeric_cols: list[str] = []
for name, values in columns.items():
ok = True
for v in values:
if v is None:
continue
if isinstance(v, bool) or not isinstance(v, (int, float)):
ok = False
break
if ok:
numeric_cols.append(name)
if len(numeric_cols) < 2:
return {"best_k": 0, "note": "datos insuficientes"}
# Construir matriz alineada por fila y descartar filas con algún None.
col_lists = [columns[name] for name in numeric_cols]
n_rows_total = min(len(c) for c in col_lists)
rows: list[list[float]] = []
for i in range(n_rows_total):
row = [col_lists[j][i] for j in range(len(numeric_cols))]
if any(v is None for v in row):
continue
rows.append([float(v) for v in row])
n_rows_used = len(rows)
n_features = len(numeric_cols)
if n_rows_used < k_min * 2:
return {"best_k": 0, "note": "datos insuficientes"}
X = np.asarray(rows, dtype=float)
X_scaled = StandardScaler().fit_transform(X)
upper_k = min(k_max, n_rows_used - 1)
if upper_k < k_min:
return {"best_k": 0, "note": "datos insuficientes"}
scores_by_k: list[dict] = []
best = None # (silhouette, k, model, labels)
for k in range(k_min, upper_k + 1):
model = KMeans(n_clusters=k, n_init=10, random_state=0)
labels = model.fit_predict(X_scaled)
# silhouette necesita al menos 2 clusters efectivos.
if len(set(labels)) < 2:
sil = -1.0
else:
sil = float(silhouette_score(X_scaled, labels))
scores_by_k.append(
{"k": k, "silhouette": sil, "inertia": float(model.inertia_)}
)
if best is None or sil > best[0]:
best = (sil, k, model, labels)
best_sil, best_k, best_model, best_labels = best
cluster_sizes = [int(np.sum(best_labels == c)) for c in range(best_k)]
centers = [[float(x) for x in center] for center in best_model.cluster_centers_]
return {
"best_k": best_k,
"silhouette": best_sil,
"scores_by_k": scores_by_k,
"cluster_sizes": cluster_sizes,
"centers": centers,
"n_rows_used": n_rows_used,
"n_features": n_features,
}
@@ -0,0 +1,64 @@
"""Tests para kmeans_segments."""
import numpy as np
from kmeans_segments import kmeans_segments
def _three_blobs(seed: int = 0, per_blob: int = 40):
"""Genera 3 blobs gaussianos bien separados en 2D, alineados por fila."""
rng = np.random.default_rng(seed)
centers = [(0.0, 0.0), (12.0, 12.0), (0.0, 12.0)]
xs: list[float] = []
ys: list[float] = []
for cx, cy in centers:
pts = rng.normal(loc=(cx, cy), scale=0.4, size=(per_blob, 2))
xs.extend(float(p[0]) for p in pts)
ys.extend(float(p[1]) for p in pts)
return {"x": xs, "y": ys}
def test_three_separated_blobs_finds_k3():
columns = _three_blobs(seed=0, per_blob=40)
result = kmeans_segments(columns, k_min=2, k_max=8)
assert result["best_k"] == 3
assert result["silhouette"] > 0.5
assert result["n_features"] == 2
assert result["n_rows_used"] == 120
assert sum(result["cluster_sizes"]) == 120
assert len(result["centers"]) == 3
# scores_by_k cubre todo el rango probado.
ks = [s["k"] for s in result["scores_by_k"]]
assert ks == list(range(2, 9))
def test_insufficient_rows_returns_note():
# Solo 3 filas válidas, k_min*2 = 4 -> insuficiente.
columns = {"x": [1.0, 2.0, 3.0], "y": [1.0, 2.0, 3.0]}
result = kmeans_segments(columns, k_min=2, k_max=8)
assert result["best_k"] == 0
assert result["note"] == "datos insuficientes"
def test_insufficient_numeric_columns_returns_note():
# Una sola columna numérica; la otra es texto -> menos de 2 numéricas.
columns = {
"x": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
"label": ["a", "b", "c", "d", "e", "f"],
}
result = kmeans_segments(columns, k_min=2, k_max=8)
assert result["best_k"] == 0
assert result["note"] == "datos insuficientes"
def test_rows_with_none_are_dropped():
columns = _three_blobs(seed=1, per_blob=40)
# Inyectar None en una fila; debe descartarse, dejando 119.
columns["x"][0] = None
result = kmeans_segments(columns, k_min=2, k_max=8)
assert result["best_k"] == 3
assert result["n_rows_used"] == 119
@@ -0,0 +1,126 @@
---
id: mutual_info_columns_py_datascience
name: mutual_info_columns
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def mutual_info_columns(a: list, b: list, a_numeric: bool = False, b_numeric: bool = False, bins: int = 10, normalized: bool = True) -> float"
description: "Informacion mutua entre dos columnas pareadas del grupo eda: detector general de dependencia que capta relaciones de cualquier forma (lineal o no, num-num, cat-cat, num-cat). Discretiza numericas por cuantiles, factoriza categoricas, devuelve NMI en [0,1] (normalized) o MI en nats. Funcion pura."
tags: [eda, correlation, mutual-information, association, profiling, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from datascience import mutual_info_columns
a = [i - 1000 for i in range(2000)]
b = [abs(x) for x in a] # V-shape: no lineal, Pearson ~ 0
mutual_info_columns(a, b, a_numeric=True, b_numeric=True) # ~0.69, NMI alto
tested: true
tests:
- "test_identical_categoricals_nmi_near_one"
- "test_nonlinear_numeric_relation_has_positive_nmi"
- "test_independent_columns_near_zero"
- "test_fewer_than_two_pairs_returns_zero"
- "test_none_pairs_are_discarded"
- "test_constant_column_returns_zero_when_normalized"
- "test_unnormalized_returns_mi_in_nats"
- "test_always_returns_float_never_none"
test_file_path: "python/functions/datascience/mutual_info_columns_test.py"
file_path: "python/functions/datascience/mutual_info_columns.py"
params:
- name: a
desc: >
Lista de valores de la primera columna, pareada posicion a posicion con
`b`. None se descarta (junto con su contraparte en `b`).
- name: b
desc: >
Lista de valores de la segunda columna, pareada con `a` (mismo criterio
de descarte de None).
- name: a_numeric
desc: >
Si True, `a` se discretiza en `bins` cubos por cuantiles antes de medir;
si False se trata como categorica (factorizacion valor->id entero).
- name: b_numeric
desc: "Idem que a_numeric pero para la columna `b`."
- name: bins
desc: >
Numero de cubos por cuantiles para las columnas numericas. Cuantiles
repetidos colapsan en menos cubos (columnas de baja variacion).
- name: normalized
desc: >
Si True devuelve la informacion mutua normalizada NMI = MI / sqrt(H(a)*H(b))
en [0, 1] (1 = dependencia total). Si False devuelve la MI cruda en nats.
output: >
float. NMI en [0, 1] cuando normalized=True; MI en nats (>= 0) cuando
normalized=False. Devuelve 0.0 si hay menos de 2 pares validos o si alguna
columna discretizada tiene entropia 0 (constante) bajo normalized. Nunca
devuelve None ni lanza excepcion.
---
## Ejemplo
```python
from datascience import mutual_info_columns
import math
# Relacion NO lineal: b = |a| (forma de V). Pearson ~ 0, pero la dependencia es real.
a = [i - 1000 for i in range(2000)] # -1000 .. 999
b = [abs(x) for x in a]
mutual_info_columns(a, b, a_numeric=True, b_numeric=True)
# -> ~0.69 (NMI alto: a determina b por completo dentro de cada cubo)
# Comparalo con la correlacion lineal, que no ve la relacion:
from datascience import pearson
pearson([float(x) for x in a], [float(x) for x in b]) # -> ~0.0
# Tambien capta relaciones oscilantes resueltas por los bins:
ax = [2 * math.pi * i / 2000 for i in range(2000)] # un periodo de seno
bx = [1.0 if math.sin(x) >= 0 else -1.0 for x in ax]
mutual_info_columns(ax, bx, a_numeric=True) # -> ~0.55, Pearson ~ -0.87
# Dos categoricas identicas -> dependencia total.
c = ["red", "green", "blue", "red", "green", "blue"]
mutual_info_columns(c, list(c)) # -> ~1.0
# MI cruda en nats (sin normalizar).
mutual_info_columns(c, list(c), normalized=False) # -> ~log(3) nats
```
## Cuando usarla
Cuando necesites un **detector general de dependencia** entre dos columnas y no
sepas (o no quieras asumir) la forma de la relacion. Pearson solo ve lineal y
solo num-num; `cramers_v` solo cat-cat. La informacion mutua funciona para
**cualquier par de tipos** (num-num, cat-cat, num-cat) y capta relaciones no
lineales (sinusoidales, escalon, agrupamientos) que la correlacion lineal pasa
por alto. Es la celda "comodin" de una matriz de asociacion en un EDA: usala
para descubrir relaciones ocultas antes de modelar, o para rankear que columnas
predicen mejor un objetivo. Activa `a_numeric`/`b_numeric` por columna segun su
tipo y deja `normalized=True` para obtener un score comparable en [0, 1].
## Notas
Funcion pura y determinista: misma entrada -> misma salida (sin estado, sin
I/O, sin aleatoriedad; `sklearn.metrics.mutual_info_score` es determinista).
- **Discretizacion**: numericas via `np.digitize` sobre los bordes de cuantil
(`np.quantile`); categoricas via mapa valor->id en orden de aparicion. La
eleccion de `bins` afecta la estimacion de MI en columnas numericas: pocos
bins suavizan, muchos bins capturan mas estructura pero inflan el ruido en
muestras pequenas. Una relacion que oscila mas rapido que la resolucion de
los bins (p.ej. un seno de muchos periodos sobre el rango de `a`) da NMI bajo
con `bins` pequeno aunque la dependencia sea real: sube `bins` para resolverla.
- **Sesgo de la MI**: en muestras pequenas la MI cruda tiende a sobreestimarse
(sesgo positivo). La normalizacion NMI lo atenua parcialmente pero no lo
elimina; para columnas independientes con muchos bins y pocos datos el valor
puede no ser exactamente 0.
- **Entropia 0**: si una columna discretizada es constante, H = 0 y la NMI se
define como 0.0 (no hay informacion compartida medible); la MI cruda tambien
es 0 en ese caso.
- **NMI** = MI / sqrt(H(a) * H(b)), clampada a [0, 1] por seguridad numerica.
@@ -0,0 +1,114 @@
"""Informacion mutua entre dos columnas pareadas (relaciones lineales y no lineales).
Funcion pura del grupo eda. Mide la dependencia estadistica general entre dos
columnas (numericas, categoricas o mezcla), capturando relaciones de cualquier
forma -- no solo lineales como Pearson. Es la metrica "general" de la matriz de
asociacion: complementa a `pearson` (solo lineal num-num) y `cramers_v` (solo
cat-cat).
"""
import math
from collections import Counter
import numpy as np
from sklearn.metrics import mutual_info_score
def _discretize(values: list, numeric: bool, bins: int) -> list:
"""Discretiza una columna a etiquetas enteras.
Columnas numericas -> `bins` cubos por cuantiles (np.digitize sobre los
bordes de cuantil). Columnas categoricas -> factorizacion valor->id.
"""
if numeric:
arr = np.asarray(values, dtype=float)
# Bordes interiores por cuantiles (excluye 0 y 1 para usar digitize).
qs = np.linspace(0.0, 1.0, bins + 1)[1:-1]
if qs.size == 0:
# bins <= 1 -> todo cae en un unico cubo.
return [0] * len(arr)
edges = np.quantile(arr, qs)
# Bordes unicos: cuantiles repetidos (columnas con poca variacion)
# colapsan en menos cubos, lo cual es correcto (menos entropia).
edges = np.unique(edges)
return list(np.digitize(arr, edges))
# Categorica: mapa valor -> id entero, en orden de aparicion.
ids: dict = {}
out = []
for v in values:
if v not in ids:
ids[v] = len(ids)
out.append(ids[v])
return out
def _entropy(labels: list) -> float:
"""Entropia de Shannon (nats) de una secuencia de etiquetas."""
n = len(labels)
if n == 0:
return 0.0
h = 0.0
for c in Counter(labels).values():
p = c / n
h -= p * math.log(p)
return h
def mutual_info_columns(
a: list,
b: list,
a_numeric: bool = False,
b_numeric: bool = False,
bins: int = 10,
normalized: bool = True,
) -> float:
"""Informacion mutua entre dos columnas pareadas posicion a posicion.
Empareja `a` y `b`, descarta los pares donde cualquiera de los dos sea None,
discretiza cada columna (numericas por cuantiles, categoricas por
factorizacion) y calcula la informacion mutua. Captura relaciones de
cualquier forma (lineal o no, num-num, cat-cat, num-cat).
Args:
a: lista de valores de la primera columna (None se descarta).
b: lista de valores pareada con `a` (mismo criterio).
a_numeric: si True, `a` se discretiza en `bins` cuantiles; si False se
factoriza como categorica.
b_numeric: idem para `b`.
bins: numero de cubos por cuantiles para columnas numericas.
normalized: si True devuelve la NMI = MI / sqrt(H(a)*H(b)) en [0, 1]
(1 = dependencia total). Si False devuelve la MI cruda en nats.
Returns:
float. NMI en [0, 1] si normalized; MI en nats (>= 0) si no. Devuelve
0.0 si hay menos de 2 pares validos o si alguna columna discretizada
tiene entropia 0 (constante) bajo normalized. Nunca None ni excepcion.
"""
pairs = [
(x, y)
for x, y in zip(a, b)
if x is not None and y is not None
]
if len(pairs) < 2:
return 0.0
a_vals = [x for x, _ in pairs]
b_vals = [y for _, y in pairs]
a_disc = _discretize(a_vals, a_numeric, bins)
b_disc = _discretize(b_vals, b_numeric, bins)
mi = float(mutual_info_score(a_disc, b_disc))
if not normalized:
return max(0.0, mi)
ha = _entropy(a_disc)
hb = _entropy(b_disc)
if ha <= 0.0 or hb <= 0.0:
# Alguna columna es constante -> no hay informacion compartida medible.
return 0.0
nmi = mi / math.sqrt(ha * hb)
# Clampa a [0, 1] por seguridad numerica.
return max(0.0, min(1.0, nmi))
@@ -0,0 +1,68 @@
"""Tests para mutual_info_columns."""
import math
import os
import random
import sys
sys.path.insert(0, os.path.dirname(__file__))
from mutual_info_columns import mutual_info_columns
def test_identical_categoricals_nmi_near_one():
a = ["x", "y", "z", "x", "y", "z", "x", "y", "z", "w", "w", "w"]
b = list(a) # b == a -> dependencia total
nmi = mutual_info_columns(a, b)
assert nmi > 0.99
assert nmi <= 1.0
def test_nonlinear_numeric_relation_has_positive_nmi():
# b = sign(sin(a)) -> relacion NO lineal fuerte (Pearson ~ 0).
rng = random.Random(11)
a = [rng.uniform(0.0, 6.0 * math.pi) for _ in range(2000)]
b = [1.0 if math.sin(x) >= 0 else -1.0 for x in a]
nmi = mutual_info_columns(a, b, a_numeric=True, b_numeric=False, bins=20)
assert nmi > 0.1
def test_independent_columns_near_zero():
rng = random.Random(42)
a = [rng.gauss(0.0, 1.0) for _ in range(3000)]
b = [rng.gauss(0.0, 1.0) for _ in range(3000)]
nmi = mutual_info_columns(a, b, a_numeric=True, b_numeric=True, bins=10)
assert 0.0 <= nmi < 0.1
def test_fewer_than_two_pairs_returns_zero():
assert mutual_info_columns([], []) == 0.0
assert mutual_info_columns(["a"], ["b"]) == 0.0
def test_none_pairs_are_discarded():
a = ["x", None, "y", "x", None, "y", "x", "y"]
b = ["x", "z", "y", "x", "z", "y", None, "y"]
nmi = mutual_info_columns(a, b)
assert isinstance(nmi, float)
assert 0.0 <= nmi <= 1.0
def test_constant_column_returns_zero_when_normalized():
a = ["c"] * 20 # entropia 0
b = ["x", "y"] * 10
assert mutual_info_columns(a, b) == 0.0
def test_unnormalized_returns_mi_in_nats():
a = ["x", "y", "z", "x", "y", "z", "x", "y", "z"]
b = list(a)
mi = mutual_info_columns(a, b, normalized=False)
# MI cruda de columnas identicas = entropia ~ log(3) nats.
assert mi > 0.9
assert mi == mi # no NaN
def test_always_returns_float_never_none():
assert isinstance(mutual_info_columns(["a", "b"], ["a", "b"]), float)
assert isinstance(mutual_info_columns([None], [None]), float)
@@ -0,0 +1,89 @@
---
id: normality_tests_py_datascience
name: normality_tests
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def normality_tests(values: list, alpha: float = 0.05) -> dict"
description: "Tests de normalidad (Jarque-Bera, D'Agostino-Pearson, Shapiro-Wilk) sobre una columna numerica para decidir si sigue una distribucion normal. Descarta None/NaN/no-numericos y reporta consenso de los tests aplicables."
tags: [eda, models, statistics, normality, hypothesis-test, distribution, shapiro, jarque-bera]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [math, scipy]
example: |
from normality_tests import normality_tests
import numpy as np
result = normality_tests(np.random.default_rng(0).normal(0, 1, 1000).tolist())
# result["is_normal"] == True
tested: true
tests:
- "test_normal_large_sample_is_normal"
- "test_skewed_sample_is_not_normal"
- "test_small_sample_returns_note"
- "test_drops_none_nan_and_non_numeric"
- "test_shapiro_skipped_above_5000"
- "test_normal_below_eight_after_cleaning_is_note"
test_file_path: "python/functions/datascience/normality_tests_test.py"
file_path: "python/functions/datascience/normality_tests.py"
params:
- name: values
desc: "Lista de valores numericos (una columna). None, NaN, infinitos y no-numericos se descartan antes de testear. Los booleanos se excluyen."
- name: alpha
desc: "Nivel de significancia por test (default 0.05). normal = p > alpha (no se rechaza H0 de normalidad)."
output: >
dict. Si n < 8 (tras limpiar): {n, note: "muestra insuficiente", is_normal: None}.
En otro caso: {n, jarque_bera:{stat,p,normal}, dagostino:{stat,p,normal},
shapiro:{stat,p,normal}|None (solo 3<=n<=5000), is_normal:bool}. En cada test
normal = p > alpha. is_normal es el consenso (todos los tests aplicables coinciden
en que los datos son normales).
---
## Ejemplo
```python
from normality_tests import normality_tests
import numpy as np
# Muestra normal -> is_normal True
normal = np.random.default_rng(0).normal(loc=10, scale=2, size=1000).tolist()
r = normality_tests(normal)
r["is_normal"] # True
r["jarque_bera"]["normal"] # True
r["shapiro"]["p"] > 0.05 # True
# Muestra exponencial (sesgada) -> is_normal False
expo = np.random.default_rng(7).exponential(scale=1.0, size=1000).tolist()
normality_tests(expo)["is_normal"] # False
# Muestra insuficiente
normality_tests([1, 2, 3, 4, 5])
# {"n": 5, "note": "muestra insuficiente", "is_normal": None}
```
## Cuando usarla
Antes de aplicar un test parametrico o un estimador que asume normalidad
(t-test, ANOVA, regresion OLS con intervalos de confianza, z-score para
outliers): comprueba primero si la columna es realmente normal. Tambien en la
fase EDA para decidir entre media (datos normales) y mediana/transformacion log
(datos sesgados), y como gate barato antes de elegir un modelo que asuma
errores gaussianos.
## Gotchas
- Funcion pura y determinista para una entrada dada, pero los p-valores
dependen del tamano de muestra: con n muy grande casi cualquier desviacion
minuscula de la normalidad rechaza H0 (poder estadistico alto). Interpreta
`is_normal` junto al tamano `n` y al contexto, no como verdad absoluta.
- Shapiro-Wilk solo se ejecuta para `3 <= n <= 5000`; fuera de ese rango su
clave es `None` y `is_normal` se decide solo con Jarque-Bera y D'Agostino.
- Con `n < 8` no se ejecuta ningun test: devuelve `note` e `is_normal: None`.
Cuenta el `n` tras limpiar (None/NaN/no-numericos descartados), no la longitud
bruta de la lista.
- D'Agostino-Pearson (`normaltest`) requiere internamente `n >= 8` para skew y
kurtosis; por eso el umbral de muestra insuficiente es 8.
@@ -0,0 +1,106 @@
"""Normality tests for a numeric column.
Pure, deterministic helper that runs a battery of normality hypothesis
tests over a numeric sample and reports, per test, whether the data is
consistent with a normal distribution at a given significance level.
"""
from __future__ import annotations
import math
from scipy import stats
def _clean(values: list) -> list[float]:
"""Keep only finite numeric values, dropping None/NaN/non-numeric.
Booleans are excluded explicitly: in Python ``bool`` is a subclass of
``int`` but treating True/False as numbers in a normality test is
almost always a data-typing mistake.
"""
out: list[float] = []
for v in values:
if v is None or isinstance(v, bool):
continue
if not isinstance(v, (int, float)):
continue
x = float(v)
if math.isnan(x) or math.isinf(x):
continue
out.append(x)
return out
def normality_tests(values: list, alpha: float = 0.05) -> dict:
"""Run normality hypothesis tests on a numeric sample.
Cleans the input (drops None, NaN, infinities and non-numeric values)
and applies up to three normality tests: Jarque-Bera, D'Agostino-Pearson
(``scipy.stats.normaltest``) and Shapiro-Wilk. For each test the
null hypothesis is "the data comes from a normal distribution", so the
sample is flagged ``normal = p > alpha`` (fail to reject the null).
Shapiro-Wilk is only applied when ``3 <= n <= 5000``; outside that range
its key is ``None``.
Args:
values: Sample of numeric values. None/NaN/non-numeric are discarded.
alpha: Significance level for each test (default 0.05).
Returns:
For ``n < 8`` (insufficient sample) a dict
``{"n": n, "note": "muestra insuficiente", "is_normal": None}``.
Otherwise a dict with::
{
"n": int,
"jarque_bera": {"stat": float, "p": float, "normal": bool},
"dagostino": {"stat": float, "p": float, "normal": bool},
"shapiro": {"stat": float, "p": float, "normal": bool} | None,
"is_normal": bool, # consensus of applicable tests
}
``is_normal`` is the consensus (all applicable tests agree the data
is normal) over the tests that were actually run.
"""
clean = _clean(values)
n = len(clean)
if n < 8:
return {"n": n, "note": "muestra insuficiente", "is_normal": None}
jb_stat, jb_p = stats.jarque_bera(clean)
jb = {
"stat": float(jb_stat),
"p": float(jb_p),
"normal": bool(jb_p > alpha),
}
da_stat, da_p = stats.normaltest(clean)
dagostino = {
"stat": float(da_stat),
"p": float(da_p),
"normal": bool(da_p > alpha),
}
shapiro: dict | None = None
if 3 <= n <= 5000:
sw_stat, sw_p = stats.shapiro(clean)
shapiro = {
"stat": float(sw_stat),
"p": float(sw_p),
"normal": bool(sw_p > alpha),
}
applicable = [jb, dagostino] + ([shapiro] if shapiro is not None else [])
is_normal = all(t["normal"] for t in applicable)
return {
"n": n,
"jarque_bera": jb,
"dagostino": dagostino,
"shapiro": shapiro,
"is_normal": bool(is_normal),
}
@@ -0,0 +1,58 @@
"""Tests para normality_tests."""
import numpy as np
from normality_tests import normality_tests
def test_normal_large_sample_is_normal():
rng = np.random.default_rng(42)
values = rng.normal(loc=10.0, scale=2.0, size=2000).tolist()
result = normality_tests(values)
assert result["n"] == 2000
assert result["shapiro"] is not None
assert result["jarque_bera"]["normal"] is True
assert result["dagostino"]["normal"] is True
assert result["is_normal"] is True
def test_skewed_sample_is_not_normal():
rng = np.random.default_rng(7)
values = rng.exponential(scale=1.0, size=2000).tolist()
result = normality_tests(values)
assert result["jarque_bera"]["normal"] is False
assert result["dagostino"]["normal"] is False
assert result["is_normal"] is False
def test_small_sample_returns_note():
result = normality_tests([1, 2, 3, 4, 5])
assert result["n"] == 5
assert result["note"] == "muestra insuficiente"
assert result["is_normal"] is None
assert "jarque_bera" not in result
def test_drops_none_nan_and_non_numeric():
rng = np.random.default_rng(1)
base = rng.normal(0.0, 1.0, size=50).tolist()
dirty = base + [None, float("nan"), "x", float("inf")]
result = normality_tests(dirty)
assert result["n"] == 50
def test_shapiro_skipped_above_5000():
rng = np.random.default_rng(3)
values = rng.normal(0.0, 1.0, size=6000).tolist()
result = normality_tests(values)
assert result["n"] == 6000
assert result["shapiro"] is None
# is_normal still computed from JB + D'Agostino.
assert result["is_normal"] is True
def test_normal_below_eight_after_cleaning_is_note():
result = normality_tests([1.0, 2.0, None, 3.0])
assert result["n"] == 3
assert result["note"] == "muestra insuficiente"
assert result["is_normal"] is None
@@ -0,0 +1,61 @@
---
name: parse_amazon_ranking_html
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def parse_amazon_ranking_html(html: str, marketplace: str = 'amazon.es', list_type: str = 'bestsellers', max_items: int = 50) -> list[dict]"
description: "Parser PURO de HTML de rankings Amazon (Best Sellers y Movers & Shakers): recibe el HTML de la pagina (de requests o de outerHTML renderizado por CDP) y devuelve una lista de productos (rank, ASIN, titulo, precio, rating, reseñas, pct_change). Nucleo compartido por el scraper HTTP y el scraper CDP."
tags: [amazon, scraping, parser, market-intel, datascience, dropship]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [bs4]
tested: true
tests: ["test_parsea_dos_cards_con_todos_los_campos", "test_contrato_de_claves_exacto", "test_pct_change_solo_en_movers_shakers", "test_html_vacio_devuelve_lista_vacia", "test_max_items_limita_resultados"]
test_file_path: "python/functions/datascience/parse_amazon_ranking_html_test.py"
file_path: "python/functions/datascience/parse_amazon_ranking_html.py"
params:
- name: html
desc: "HTML crudo de una pagina de ranking Amazon, o el outerHTML del contenedor del grid (.p13n-desktop-grid) renderizado via CDP. Puede ser el documento entero o solo el grid."
- name: marketplace
desc: "Dominio Amazon (amazon.es, amazon.com, ...). Se usa para construir URLs absolutas de producto y para inferir la moneda fallback cuando el precio no trae simbolo."
- name: list_type
desc: "'bestsellers' o 'movers_shakers'. Solo afecta a si se parsea pct_change (movers) o se fuerza a None (bestsellers)."
- name: max_items
desc: "Numero maximo de productos devueltos. Default 50."
output: "Lista de dicts, uno por producto, con exactamente estas claves: marketplace, list_type, category (siempre None aqui — lo rellena el caller que conoce la URL), rank, asin, title, price, currency, rating, reviews, pct_change, url. None donde no haya dato. price/rating/pct_change son float; rank/reviews son int. pct_change solo se rellena en movers_shakers. HTML vacio o sin cards -> lista vacia (nunca lanza)."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.parse_amazon_ranking_html import parse_amazon_ranking_html
# `html` puede venir de requests.get(...).text o de un outerHTML renderizado por CDP.
html = open("/tmp/amazon_grid.html").read()
rows = parse_amazon_ranking_html(html, marketplace="amazon.es", list_type="movers_shakers", max_items=30)
print(len(rows), "productos")
print(rows[0])
# {'marketplace': 'amazon.es', 'list_type': 'movers_shakers', 'category': None,
# 'rank': 1, 'asin': 'B0...', 'title': '...', 'price': 13.95, 'currency': 'EUR',
# 'rating': 4.0, 'reviews': 666, 'pct_change': 150.0, 'url': 'https://www.amazon.es/dp/B0...'}
```
## Cuando usarla
Usala cuando ya tengas el HTML de una pagina de ranking de Amazon (Best Sellers o Movers & Shakers) y quieras extraer los productos sin volver a escribir selectores DOM. Es el bloque de parsing reutilizable: la usan tanto `scrape_amazon_bestsellers` (fetch HTTP con requests) como `scrape_amazon_movers_cdp` (fetch renderizado via Chrome DevTools Protocol). Si construyes otro fetcher (proxy, browser MCP, HAR replay), pasale el HTML aqui en vez de duplicar el parser.
## Notas
- **Funcion pura**: sin red ni I/O; para un HTML fijo devuelve siempre lo mismo. Por eso es testeable con fixtures y compartible entre estrategias de fetch.
- **Plantillas multiples**: Amazon sirve varias plantillas DOM a la vez (A/B test) y las rota. Cada campo usa varios selectores fallback; un campo que ninguna plantilla conocida expone se devuelve `None` en vez de petar.
- **Seleccion de cards**: prioriza el wrapper del grid (`div[id="gridItemRoot"]`) sobre el faceout interno, porque el badge de rank (`span.zg-bdg-text`) es hermano del faceout DENTRO del wrapper — seleccionar el faceout solo perderia el rank.
- **pct_change defensivo**: apunta solo al badge de subida de ranking de movers (`.zg-percent-change` y variantes), NO al `%` generico de descuento/ahorro (`apex-savings-percent`) de cards de oferta, que daria un pct_change falso.
- **category = None**: el parser no conoce la URL, asi que deja `category` en `None`; el caller (que si sabe que categoria pidio) lo rellena.
- **rank fallback posicional**: si Amazon no renderiza el badge de rank, se usa la posicion (1-indexada) del item en el grid.
@@ -0,0 +1,347 @@
"""Pure HTML parser for Amazon ranking pages (Best Sellers and Movers & Shakers).
This module holds the *pure* DOM-parsing core shared by the HTTP scraper
(``scrape_amazon_bestsellers``) and the CDP/browser scraper
(``scrape_amazon_movers_cdp``). It takes a chunk of already-fetched HTML (from
``requests`` or from a rendered ``outerHTML`` via Chrome DevTools Protocol) and
returns a list of product dicts. No I/O, no network, deterministic for a fixed
input string — so it can be unit-tested with HTML fixtures and reused by any
fetch strategy.
Amazon serves several DOM templates at once (A/B tests) and rotates them often,
so every field is parsed defensively with multiple fallback selectors. A field
that no known template exposes is returned as ``None`` rather than raising.
"""
from __future__ import annotations
import re
from urllib.parse import urljoin
from bs4 import BeautifulSoup
# Currency guessed from the marketplace TLD (used only as a fallback when the
# price string has no recognisable symbol).
_CURRENCY_BY_MARKET = {
"amazon.es": "EUR",
"amazon.com": "USD",
"amazon.co.uk": "GBP",
"amazon.de": "EUR",
"amazon.fr": "EUR",
"amazon.it": "EUR",
"amazon.com.mx": "MXN",
"amazon.com.br": "BRL",
}
# Map common currency symbols to ISO codes.
_SYMBOL_TO_CURRENCY = {
"": "EUR",
"$": "USD",
"£": "GBP",
"R$": "BRL",
"US$": "USD",
}
_ASIN_RE = re.compile(r"/(?:dp|gp/product)/([A-Z0-9]{10})(?:[/?]|$)")
_RANK_RE = re.compile(r"#?\s*(\d+)")
_PRICE_NUM_RE = re.compile(r"[-+]?\d[\d.,]*")
_REVIEWS_RE = re.compile(r"[\d.,]+")
_RATING_RE = re.compile(r"([\d.,]+)\s*(?:out of|de|von|su|sur|de um total de)")
_PCT_RE = re.compile(r"([\d.,]+)\s*%")
def _text(node) -> str:
return node.get_text(" ", strip=True) if node is not None else ""
def _parse_asin(card) -> str | None:
"""ASIN from a data-asin attribute or any /dp/<ASIN>/ link inside the card."""
asin = card.get("data-asin")
if asin and re.fullmatch(r"[A-Z0-9]{10}", asin):
return asin
# Some templates put data-asin on a descendant, not the card root.
inner = card.select_one("[data-asin]")
if inner is not None:
inner_asin = inner.get("data-asin")
if inner_asin and re.fullmatch(r"[A-Z0-9]{10}", inner_asin):
return inner_asin
for a in card.find_all("a", href=True):
m = _ASIN_RE.search(a["href"])
if m:
return m.group(1)
return None
def _parse_url(card, marketplace: str) -> str | None:
"""Absolute product URL from the first /dp/ link in the card."""
base = f"https://www.{marketplace}"
for a in card.find_all("a", href=True):
if _ASIN_RE.search(a["href"]):
return urljoin(base, a["href"].split("?")[0])
# Fall back to the first link at all.
first = card.find("a", href=True)
if first is not None:
return urljoin(base, first["href"].split("?")[0])
return None
def _parse_rank(card) -> int | None:
"""Rank badge. Amazon renders it as '#1', '1', etc."""
badge = card.select_one(".zg-bdg-text, .zg-badge-text, [class*='badge']")
txt = _text(badge)
if not txt:
# Sometimes the rank is in a class like a11y .zg-bdg-text sibling.
for sel in (".a-badge-text", "[class*='rank']"):
node = card.select_one(sel)
txt = _text(node)
if txt:
break
m = _RANK_RE.search(txt)
return int(m.group(1)) if m else None
def _parse_title(card) -> str | None:
"""Product title — several templates over the years."""
for sel in (
"._cDEzb_p13n-sc-css-line-clamp-3_g3dy1",
"._cDEzb_p13n-sc-css-line-clamp-2_EWgCb",
"[class*='line-clamp']",
".p13n-sc-truncate",
".p13n-sc-truncated",
"a.a-link-normal[title]",
"img[alt]",
):
node = card.select_one(sel)
if node is None:
continue
if node.name == "img":
alt = node.get("alt")
if alt:
return alt.strip()
continue
if node.has_attr("title") and node["title"].strip():
return node["title"].strip()
txt = _text(node)
if txt:
return txt
return None
def _parse_price(card, marketplace: str) -> tuple[float | None, str | None]:
"""Price value (float) and ISO currency, best-effort across templates."""
for sel in (
"._cDEzb_p13n-sc-price_3mJ9Z",
".p13n-sc-price",
"span.a-price > span.a-offscreen",
".a-price .a-offscreen",
"[class*='price']",
):
node = card.select_one(sel)
txt = _text(node)
if not txt:
continue
currency = None
for sym, iso in _SYMBOL_TO_CURRENCY.items():
if sym in txt:
currency = iso
break
if currency is None:
currency = _CURRENCY_BY_MARKET.get(marketplace)
m = _PRICE_NUM_RE.search(txt)
if not m:
continue
raw = m.group(0)
value = _to_float(raw)
if value is not None:
return value, currency
return None, None
def _parse_rating(card) -> float | None:
"""Star rating, e.g. '4,5 de 5 estrellas' / '4.5 out of 5 stars'."""
for sel in ("[class*='review-stars']", ".a-icon-alt", "[title*='star']", "[aria-label*='star']"):
node = card.select_one(sel)
txt = _text(node) or (node.get("title", "") if node is not None else "") or (
node.get("aria-label", "") if node is not None else ""
)
if not txt:
continue
m = _RATING_RE.search(txt)
if m:
return _to_float(m.group(1))
# Some templates only render the number ('4,5').
m2 = _PRICE_NUM_RE.search(txt)
if m2 and ("star" in txt.lower() or "estrella" in txt.lower()):
return _to_float(m2.group(0))
return None
def _parse_reviews(card) -> int | None:
"""Number of ratings/reviews shown next to the stars."""
for sel in (
"a.a-size-small.a-link-normal",
".a-size-small.a-link-normal",
"[class*='review-count']",
"span.a-size-small",
):
for node in card.select(sel):
txt = _text(node)
if not txt:
continue
m = _REVIEWS_RE.search(txt)
if not m:
continue
digits = m.group(0).replace(".", "").replace(",", "")
if digits.isdigit() and len(digits) >= 1:
# Avoid catching rank/price by requiring a plausible count token.
return int(digits)
return None
def _parse_pct_change(card) -> float | None:
"""Movers & Shakers percentage change ('+150%').
Targets the sales-rank-gain badge specific to the movers grid, NOT the
generic discount/savings percent (``apex-savings-percent``) that appears on
bestseller/deal cards — matching those would report a bogus pct_change.
"""
for sel in (
".zg-percent-change",
"[class*='sales-movement']",
"[class*='percent-change']",
"[class*='zg_percent']",
):
node = card.select_one(sel)
txt = _text(node)
if not txt:
continue
m = _PCT_RE.search(txt)
if m:
value = _to_float(m.group(1))
if value is None:
continue
return -value if txt.strip().startswith("-") else value
return None
def _to_float(raw: str) -> float | None:
"""Parse a numeric string with EU or US decimal/grouping conventions."""
if raw is None:
return None
s = raw.strip().replace("\xa0", "").replace(" ", "")
if not s:
return None
if "," in s and "." in s:
# The rightmost separator is the decimal one.
if s.rfind(",") > s.rfind("."):
s = s.replace(".", "").replace(",", ".")
else:
s = s.replace(",", "")
elif "," in s:
# Treat a single comma as decimal separator (EU markets).
s = s.replace(",", ".")
try:
return float(s)
except ValueError:
return None
def _select_cards(soup: BeautifulSoup) -> list:
"""Locate the list-item cards across known Amazon templates.
Prefers the grid *wrapper* (``gridItemRoot``) over the inner faceout: the
rank badge (``span.zg-bdg-text``) is a sibling of the faceout *inside* the
wrapper, so selecting the wrapper keeps both rank and product data in the
same card. Older / alternative templates fall back to their own roots.
"""
selectors = (
'div[id="gridItemRoot"]',
"div[id^='gridItemRoot']",
"div.zg-grid-general-faceout",
"li.zg-item-immersion",
"div.a-cardui[data-asin]",
"div.p13n-sc-uncoverable-faceout",
"div[data-asin]",
)
for sel in selectors:
cards = soup.select(sel)
if cards:
return cards
return []
def parse_amazon_ranking_html(
html: str,
marketplace: str = "amazon.es",
list_type: str = "bestsellers",
max_items: int = 50,
) -> list[dict]:
"""Parse Amazon ranking HTML into a list of product dicts (pure).
Pure function: given a fixed HTML string it always returns the same list,
with no I/O. Used by both the HTTP scraper (``scrape_amazon_bestsellers``)
and the CDP scraper (``scrape_amazon_movers_cdp``).
Args:
html: Raw HTML of an Amazon ranking page (or the rendered ``outerHTML``
of the grid container). May be the whole document or just the grid.
marketplace: Amazon domain, e.g. ``"amazon.es"``, ``"amazon.com"``. Used
to build absolute product URLs and to infer the fallback currency.
list_type: ``"bestsellers"`` or ``"movers_shakers"``. Only affects
whether ``pct_change`` is parsed (movers) or forced to ``None``.
max_items: Maximum number of products returned.
Returns:
A list of dicts, one per product, with exactly these keys:
``marketplace, list_type, category, rank, asin, title, price,
currency, rating, reviews, pct_change, url``. Missing values are
``None``. ``price``/``rating``/``pct_change`` are floats,
``rank``/``reviews`` are ints. ``category`` is always ``None`` here —
the caller (which knows the URL) fills it in. Returns ``[]`` for empty
or card-less HTML (never raises on missing fields).
"""
if not html:
return []
soup = BeautifulSoup(html, "lxml")
cards = _select_cards(soup)
results: list[dict] = []
count = 0
for idx, card in enumerate(cards):
if count >= max_items:
break
asin = _parse_asin(card)
title = _parse_title(card)
# Skip empty / non-product wrappers.
if asin is None and title is None:
continue
rank = _parse_rank(card)
if rank is None:
rank = idx + 1 # positional fallback when no badge is rendered
price, currency = _parse_price(card, marketplace)
results.append(
{
"marketplace": marketplace,
"list_type": list_type,
"category": None,
"rank": rank,
"asin": asin,
"title": title,
"price": price,
"currency": currency,
"rating": _parse_rating(card),
"reviews": _parse_reviews(card),
"pct_change": _parse_pct_change(card)
if list_type == "movers_shakers"
else None,
"url": _parse_url(card, marketplace),
}
)
count += 1
return results
@@ -0,0 +1,111 @@
"""Tests para parse_amazon_ranking_html (parser puro de rankings Amazon)."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from datascience.parse_amazon_ranking_html import parse_amazon_ranking_html
# Fixture: dos cards estilo grid 2026 (gridItemRoot) con badge de rank,
# data-asin, titulo, precio y rating. La segunda lleva un badge de pct_change
# tipo movers ('zg-percent-change') para validar ese campo.
_GRID_HTML = """
<div class="p13n-desktop-grid">
<div id="gridItemRoot" class="a-column a-span12 a-text-center">
<div class="a-cardui p13n-grid-content">
<div data-asin="B0D49Y53FS">
<div class="zg-bdg-ctr"><span class="zg-bdg-text">#1</span></div>
<div class="zg-percent-change">+150%</div>
<div class="zg-grid-general-faceout">
<div class="p13n-sc-uncoverable-faceout">
<a class="a-link-normal aok-block" href="/Sun-Shade/dp/B0D49Y53FS/ref=zg_bs_1?psc=1">
<img alt="Sun Shade Car Protector Folding Umbrella" />
</a>
<div class="_cDEzb_p13n-sc-css-line-clamp-3_g3dy1">Sun Shade Car Protector Folding Umbrella</div>
<div class="a-icon-row">
<a class="a-link-normal" href="/product-reviews/B0D49Y53FS">
<i class="a-icon a-icon-star-small"><span class="a-icon-alt">4.0 out of 5 stars</span></i>
<span class="a-size-small">666</span>
</a>
</div>
<span class="a-price"><span class="a-offscreen">&euro;13.95</span></span>
</div>
</div>
</div>
</div>
</div>
<div id="gridItemRoot" class="a-column a-span12 a-text-center">
<div class="a-cardui p13n-grid-content">
<div data-asin="B0GXZDZRS5">
<div class="zg-bdg-ctr"><span class="zg-bdg-text">#2</span></div>
<div class="zg-percent-change">-30%</div>
<div class="zg-grid-general-faceout">
<div class="p13n-sc-uncoverable-faceout">
<a class="a-link-normal aok-block" href="/Emergency-Light/dp/B0GXZDZRS5/ref=zg_bs_2?psc=1">
<img alt="GSC Emergency Light V16 Geolocation" />
</a>
<div class="_cDEzb_p13n-sc-css-line-clamp-3_g3dy1">GSC Emergency Light V16 Geolocation</div>
<div class="a-icon-row">
<a class="a-link-normal" href="/product-reviews/B0GXZDZRS5">
<i class="a-icon a-icon-star-small"><span class="a-icon-alt">4.6 out of 5 stars</span></i>
<span class="a-size-small">24</span>
</a>
</div>
<span class="a-price"><span class="a-offscreen">&euro;15.90</span></span>
</div>
</div>
</div>
</div>
</div>
</div>
"""
def test_parsea_dos_cards_con_todos_los_campos():
rows = parse_amazon_ranking_html(
_GRID_HTML, marketplace="amazon.es", list_type="bestsellers", max_items=50
)
assert len(rows) == 2
first = rows[0]
assert first["rank"] == 1
assert first["asin"] == "B0D49Y53FS"
assert "Sun Shade" in first["title"]
assert first["price"] == 13.95
assert first["currency"] == "EUR"
assert first["rating"] == 4.0
assert first["reviews"] == 666
assert first["url"] == "https://www.amazon.es/Sun-Shade/dp/B0D49Y53FS/ref=zg_bs_1"
assert first["marketplace"] == "amazon.es"
assert first["list_type"] == "bestsellers"
def test_contrato_de_claves_exacto():
rows = parse_amazon_ranking_html(_GRID_HTML, marketplace="amazon.es")
expected = {
"marketplace", "list_type", "category", "rank", "asin", "title",
"price", "currency", "rating", "reviews", "pct_change", "url",
}
assert set(rows[0].keys()) == expected
def test_pct_change_solo_en_movers_shakers():
# En bestsellers pct_change siempre es None aunque el badge exista.
bs = parse_amazon_ranking_html(_GRID_HTML, list_type="bestsellers")
assert bs[0]["pct_change"] is None
assert bs[1]["pct_change"] is None
# En movers_shakers se parsea: +150% y -30%.
mv = parse_amazon_ranking_html(_GRID_HTML, list_type="movers_shakers")
assert mv[0]["pct_change"] == 150.0
assert mv[1]["pct_change"] == -30.0
def test_html_vacio_devuelve_lista_vacia():
assert parse_amazon_ranking_html("") == []
assert parse_amazon_ranking_html("<html><body><p>nada</p></body></html>") == []
def test_max_items_limita_resultados():
rows = parse_amazon_ranking_html(_GRID_HTML, max_items=1)
assert len(rows) == 1
assert rows[0]["rank"] == 1
@@ -0,0 +1,65 @@
---
name: pca_explained
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def pca_explained(columns: dict, n_components: int = 2) -> dict"
description: "PCA rapido sobre columnas numericas: estandariza (z-score), descarta filas con valores faltantes y ajusta sklearn PCA determinista para revelar estructura latente y cuanta varianza concentran pocos componentes. EDA barato."
tags: [eda, models, pca, dimensionality-reduction, variance, datascience, sklearn]
params:
- name: columns
desc: "Mapa {nombre_columna: [valores numericos]}. Listas alineadas por fila (misma longitud). Columnas no numericas o constantes se descartan; None/NaN marcan filas a descartar."
- name: n_components
desc: "Numero maximo de componentes principales (default 2). Se acota a min(n_features, n_filas_validas)."
output: "dict con n_components, n_rows_used, n_features, explained_variance_ratio (lista), cumulative (lista), top_loadings (lista de {component, feature, loading}) y projection (matriz cap a 1000 filas). Con <2 columnas numericas o <3 filas validas devuelve {n_components:0, explained_variance_ratio:[], note:'datos insuficientes'}."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [numpy, scikit-learn]
tested: true
tests: ["test_pc1_concentra_varianza_con_columnas_colineales", "test_una_sola_columna_numerica_datos_insuficientes", "test_pocas_filas_validas_datos_insuficientes"]
test_file_path: "python/functions/datascience/pca_explained_test.py"
file_path: "python/functions/datascience/pca_explained.py"
---
## Ejemplo
```python
from datascience import pca_explained
# x e y casi colineales (y ~= 2x); z independiente.
n = 50
cols = {
"x": [float(i) for i in range(n)],
"y": [2.0 * i for i in range(n)],
"z": [float((i * 7) % 13) for i in range(n)],
}
res = pca_explained(cols, n_components=2)
# res["explained_variance_ratio"][0] > 0.6 -> PC1 concentra la varianza
# res["cumulative"][-1] ~ 1.0 con 2 componentes sobre 3 features
# res["top_loadings"][0] -> {"component": 0, "feature": "x" o "y", "loading": ...}
```
## Cuando usarla
Cuando exploras un dataset tabular numerico y quieres ver, de un vistazo y sin
montar un pipeline, si pocas dimensiones explican casi toda la varianza (alta
correlacion entre columnas) y que features pesan en cada componente. Util como
primer paso de EDA antes de decidir reduccion de dimensionalidad o seleccion de
variables. Pasa las columnas alineadas por fila; la funcion limpia filas con
faltantes y estandariza por ti.
## Notas
Funcion pura y determinista: estandariza con `StandardScaler`, ajusta
`sklearn.decomposition.PCA` con `random_state=0`. No hace I/O. Las columnas no
numericas o que no pueden coercerse a float se descartan; los None se tratan
como NaN y eliminan la fila completa. `projection` se acota a las primeras 1000
filas para mantener la salida manejable. Degrada con gracia: con menos de 2
columnas numericas o menos de 3 filas validas devuelve `note: "datos
insuficientes"` sin lanzar excepcion.
@@ -0,0 +1,121 @@
"""PCA rapido sobre columnas numericas para revelar estructura latente.
Estandariza las columnas (z-score), descarta filas con valores faltantes y
ajusta un PCA determinista para ver cuanta varianza concentran pocos
componentes. Pensado para exploracion de datos (EDA) barata.
"""
import math
def pca_explained(columns: dict, n_components: int = 2) -> dict:
"""Ejecuta PCA sobre columnas numericas y resume la varianza explicada.
Args:
columns: mapa {nombre_columna: [valores numericos]}. Las listas estan
alineadas por fila (misma longitud). Las columnas no numericas o
con menos de dos valores distintos se descartan.
n_components: numero maximo de componentes principales a calcular.
Se acota a min(n_features, n_filas_validas).
Returns:
dict con:
n_components: numero de componentes realmente calculados.
n_rows_used: filas validas usadas (sin None/NaN).
n_features: columnas numericas usadas.
explained_variance_ratio: varianza explicada por componente.
cumulative: varianza acumulada componente a componente.
top_loadings: cargas mas grandes (en valor absoluto) por componente.
projection: proyeccion de las filas (cap a 1000 filas).
Si hay menos de 2 columnas numericas o menos de 3 filas validas,
devuelve {n_components: 0, explained_variance_ratio: [],
note: "datos insuficientes"} sin lanzar excepcion.
"""
import numpy as np
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
insufficient = {
"n_components": 0,
"explained_variance_ratio": [],
"note": "datos insuficientes",
}
if not isinstance(columns, dict) or not columns:
return insufficient
# Quedarnos solo con columnas que se puedan interpretar como numericas.
numeric_cols: dict[str, list] = {}
for name, values in columns.items():
if not isinstance(values, (list, tuple)):
continue
coerced = []
usable = True
for v in values:
if v is None:
coerced.append(math.nan)
continue
try:
f = float(v)
except (TypeError, ValueError):
usable = False
break
coerced.append(f)
if usable:
numeric_cols[name] = coerced
if len(numeric_cols) < 2:
return insufficient
feature_names = list(numeric_cols.keys())
matrix = np.array([numeric_cols[n] for n in feature_names], dtype=float).T
# Descartar filas con cualquier NaN (incluye los None convertidos).
valid_mask = ~np.isnan(matrix).any(axis=1)
data = matrix[valid_mask]
if data.shape[0] < 3:
return insufficient
n_rows_used = int(data.shape[0])
n_features = int(data.shape[1])
k = min(n_components, n_features, n_rows_used)
if k < 1:
return insufficient
scaled = StandardScaler().fit_transform(data)
pca = PCA(n_components=k, random_state=0)
proj = pca.fit_transform(scaled)
evr = [float(x) for x in pca.explained_variance_ratio_]
cumulative = []
running = 0.0
for x in evr:
running += x
cumulative.append(float(running))
# Cargas: una fila por componente, una columna por feature.
top_loadings = []
for comp_idx, comp in enumerate(pca.components_):
order = np.argsort(np.abs(comp))[::-1]
for feat_idx in order:
top_loadings.append(
{
"component": int(comp_idx),
"feature": feature_names[int(feat_idx)],
"loading": float(comp[int(feat_idx)]),
}
)
projection = [[float(v) for v in row] for row in proj[:1000]]
return {
"n_components": int(k),
"n_rows_used": n_rows_used,
"n_features": n_features,
"explained_variance_ratio": evr,
"cumulative": cumulative,
"top_loadings": top_loadings,
"projection": projection,
}
@@ -0,0 +1,38 @@
"""Tests para pca_explained."""
from pca_explained import pca_explained
def test_pc1_concentra_varianza_con_columnas_colineales():
# x e y son casi colineales (y = 2x + ruido minimo); z es independiente.
n = 50
x = [float(i) for i in range(n)]
y = [2.0 * i + (0.01 if i % 2 == 0 else -0.01) for i in range(n)]
z = [float((i * 7) % 13) for i in range(n)]
result = pca_explained({"x": x, "y": y, "z": z}, n_components=2)
assert result["n_components"] == 2
assert result["n_rows_used"] == n
assert result["n_features"] == 3
# Con dos columnas casi colineales, PC1 debe concentrar mucha varianza.
assert result["explained_variance_ratio"][0] > 0.6
# Cumulative es monotona creciente.
assert result["cumulative"][-1] >= result["cumulative"][0]
assert len(result["projection"]) == n
def test_una_sola_columna_numerica_datos_insuficientes():
result = pca_explained({"x": [1.0, 2.0, 3.0, 4.0, 5.0]})
assert result["n_components"] == 0
assert result["explained_variance_ratio"] == []
assert result["note"] == "datos insuficientes"
def test_pocas_filas_validas_datos_insuficientes():
# Solo 2 filas validas (la tercera tiene un None) -> insuficiente.
result = pca_explained({"a": [1.0, 2.0, None], "b": [4.0, 5.0, 6.0]})
assert result["n_components"] == 0
assert result["note"] == "datos insuficientes"
@@ -0,0 +1,109 @@
---
name: pull_gsc_search_analytics
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def pull_gsc_search_analytics(service: object, site_url: str, start_date: str, end_date: str, dimensions: list = None, row_limit: int = 25000, max_total_rows: int = 0, search_type: str = 'web') -> list"
description: "Extrae datos de la Search Analytics API de Google Search Console (GSC): impresiones, clicks, CTR y posicion por las dimensiones pedidas (query, page, date, country, device, searchAppearance). Recibe un objeto service GSC ya autenticado (el que devuelve gsc_auth, inyectado) y llama a service.searchanalytics().query(siteUrl, body).execute(). Pagina automaticamente con startRow en pasos de row_limit (tope duro 25000 filas/request) hasta que una pagina devuelve menos de row_limit filas o se alcanza max_total_rows. Aplana cada fila mapeando el array keys posicionalmente a los nombres de dimensions y añade clicks, impressions, ctr y position. Si la API no devuelve filas (rows ausente), retorna lista vacia sin error. Es el extractor principal de datos SEO para alimentar un pipeline hacia DuckDB/Postgres."
tags: [seo, gsc, datascience, search-console, google, extractor]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [typing.Any]
params:
- name: service
desc: "objeto service autenticado de la Google Search Console API (el que devuelve gsc_auth_py_infra). Se inyecta ya construido; esta funcion NO lo crea ni llama a gsc_auth internamente. Debe exponer .searchanalytics().query(siteUrl=..., body=...).execute()."
- name: site_url
desc: "propiedad de Search Console. Formato 'sc-domain:ejemplo.com' para propiedad de dominio, o URL completa 'https://ejemplo.com/' para propiedad de prefijo. El formato importa: usar el que coincida con como la propiedad esta dada de alta en GSC."
- name: start_date
desc: "fecha inicial inclusiva en formato YYYY-MM-DD."
- name: end_date
desc: "fecha final inclusiva en formato YYYY-MM-DD. La API tiene ~2-3 dias de lag; el caller deberia pedir hasta hoy-3 para datos completos."
- name: dimensions
desc: "lista de dimensiones a desglosar. Por defecto ['query', 'page']. Otras validas: 'date', 'country', 'device', 'searchAppearance'. El orden define el orden de las keys en cada fila."
- name: row_limit
desc: "filas por request y tamaño de paso de la paginacion. Rango 1..25000 (se clampa al tope duro de la API). Por defecto 25000."
- name: max_total_rows
desc: "tope total de filas acumuladas en todas las paginas. 0 = sin tope (trae todo lo disponible). Si >0, recorta el resultado al llegar a ese numero."
- name: search_type
desc: "tipo de busqueda: 'web' | 'image' | 'video' | 'news' | 'discover' | 'googleNews'. Va en el body de la API como 'type'. Por defecto 'web'."
output: "list de dicts aplanados. Cada dict tiene una clave por cada dimension (con su nombre real, ej. query, page) mas clicks, impressions, ctr y position. Ejemplo con dimensions=['query','page']: {'query': '...', 'page': '...', 'clicks': 5, 'impressions': 100, 'ctr': 0.05, 'position': 12.3}. Lista vacia si la API no devuelve filas."
tested: true
tests:
- "test_aplanado_mapea_keys_a_nombres_de_dimension"
- "test_paginacion_recorre_varias_paginas_y_para_en_pagina_corta"
- "test_max_total_rows_recorta"
- "test_rows_ausente_retorna_lista_vacia"
- "test_dimension_unica_date"
test_file_path: "python/functions/datascience/pull_gsc_search_analytics_test.py"
file_path: "python/functions/datascience/pull_gsc_search_analytics.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra import gsc_auth
from datascience import pull_gsc_search_analytics
# 1. Autenticar (service account JSON via env var GSC_SA_JSON o ruta explicita)
service = gsc_auth() # o gsc_auth("/ruta/fuera/del/repo/sa.json")
# 2. Extraer datos SEO por query + page de los ultimos dias (hasta hoy-3 por el lag)
rows = pull_gsc_search_analytics(
service,
site_url="sc-domain:ejemplo.com", # propiedad de dominio
# site_url="https://ejemplo.com/", # alternativa: propiedad de prefijo
start_date="2026-06-01",
end_date="2026-06-17",
dimensions=["query", "page"],
)
print(len(rows), "filas")
# rows[0] -> {'query': 'comprar zapatillas', 'page': 'https://ejemplo.com/zapatillas',
# 'clicks': 5, 'impressions': 100, 'ctr': 0.05, 'position': 12.3}
# Desglose temporal (1 fila por dia) con tope de filas:
serie = pull_gsc_search_analytics(
service, "sc-domain:ejemplo.com", "2026-06-01", "2026-06-17",
dimensions=["date"], max_total_rows=1000,
)
```
## Cuando usarla
Cuando necesites ingerir datos SEO de Google Search Console (impresiones, clicks,
CTR, posicion por query/pagina/fecha/pais/dispositivo) para volcarlos a DuckDB o
Postgres. Es el paso de extraccion del pipeline SEO: primero `gsc_auth` para
construir el `service`, luego esta funcion para traer las filas paginadas y
aplanadas, listas para upsert en una tabla.
## Gotchas
- **Lag de 2-3 dias**: GSC no tiene los datos del dia actual ni los 1-2 previos
completos. Pide `end_date` = hoy-3 para evitar dias parciales que luego cambian.
- **Privacy threshold (anonimizacion)**: las queries de baja frecuencia se ocultan
por privacidad de Google. La suma de clicks/impressions por `query` NO cuadra con
el total agregado sin dimension `query` — falta la "cola" anonimizada. Para totales
exactos, pide tambien una consulta sin la dimension `query` (ej. solo `["date"]`).
- **Formato de site_url**: `sc-domain:ejemplo.com` para propiedad de dominio; URL
completa con esquema y barra final `https://ejemplo.com/` para propiedad de prefijo.
Si no coincide con como esta dada de alta la propiedad, la API devuelve 403/permission.
- **Tope 25000 filas/request**: `row_limit` se clampa a 25000. Para propiedades grandes
la paginacion puede dar muchas requests; vigila los rate limits de la API (la funcion
no reintenta — el error de quota se propaga al caller).
- **Permisos**: la service account debe estar añadida como usuario (al menos lectura)
en la propiedad de GSC; si no, error de permisos al ejecutar el query.
## Notas
`service` se inyecta ya construido (separacion auth/extraccion), por eso esta funcion
no aparece acoplada a `gsc_auth` en `uses_functions`: no la importa ni la llama. El
test ejercita la logica de paginado y aplanado con un service mock, sin red ni
credenciales. Funcion impura: hace I/O de red contra la API de Google; cualquier error
HTTP (auth, permisos, quota) se propaga.
@@ -0,0 +1,106 @@
"""Extractor de Search Analytics de Google Search Console (GSC).
Consulta la Search Analytics API de Google Search Console y devuelve las filas
aplanadas (impresiones, clicks, CTR, posicion) por las dimensiones pedidas.
Es el extractor principal de datos SEO para alimentar un pipeline hacia
DuckDB/Postgres.
"""
from typing import Any
def pull_gsc_search_analytics(
service: object,
site_url: str,
start_date: str,
end_date: str,
dimensions: list = None,
row_limit: int = 25000,
max_total_rows: int = 0,
search_type: str = "web",
) -> list:
"""Extrae datos de Search Analytics de Google Search Console.
Llama a ``service.searchanalytics().query(...).execute()`` paginando los
resultados (la API devuelve como maximo ``row_limit`` filas por request,
con tope duro de 25000) y aplana cada fila a un dict donde el array ``keys``
se mapea posicionalmente a los nombres de ``dimensions``.
Args:
service: objeto service autenticado de la API de Search Console
(el que devuelve ``gsc_auth`` del registry). Se inyecta ya
construido; esta funcion NO lo crea.
site_url: propiedad de Search Console. ``sc-domain:ejemplo.com`` para
propiedad de dominio, o la URL completa ``https://ejemplo.com/``
para propiedad de prefijo.
start_date: fecha inicial inclusiva en formato ``YYYY-MM-DD``.
end_date: fecha final inclusiva en formato ``YYYY-MM-DD``. La API tiene
~2-3 dias de lag; el caller deberia pedir hasta hoy-3.
dimensions: lista de dimensiones a desglosar. Por defecto
``["query", "page"]``. Otras validas: ``date``, ``country``,
``device``, ``searchAppearance``.
row_limit: filas por request (1..25000). Tambien el tamaño de paso de
la paginacion. Por defecto 25000.
max_total_rows: tope total de filas acumuladas. ``0`` = sin tope (trae
todas las paginas disponibles).
search_type: tipo de busqueda. ``"web"`` | ``"image"`` | ``"video"`` |
``"news"`` | ``"discover"`` | ``"googleNews"``. Va en el body como
``"type"``.
Returns:
Lista de dicts aplanados. Cada dict tiene una clave por cada dimension
(con su nombre real, ej. ``query``, ``page``) mas ``clicks``,
``impressions``, ``ctr`` y ``position``. Lista vacia si la API no
devuelve filas.
Raises:
Exception: cualquier error de la API HTTP de Google se propaga
(autenticacion, permisos sobre la propiedad, rate limit, etc.).
"""
dims = list(dimensions) if dimensions else ["query", "page"]
# Clamp del row_limit al rango valido de la API (1..25000).
page_size = max(1, min(int(row_limit), 25000))
results: list = []
start_row = 0
while True:
body: dict[str, Any] = {
"startDate": start_date,
"endDate": end_date,
"dimensions": dims,
"type": search_type,
"rowLimit": page_size,
"startRow": start_row,
}
response = (
service.searchanalytics().query(siteUrl=site_url, body=body).execute()
)
rows = response.get("rows") if isinstance(response, dict) else None
if not rows:
# rows ausente o vacio => no hay mas datos.
break
for row in rows:
keys = row.get("keys", [])
flat: dict[str, Any] = {}
for i, dim in enumerate(dims):
flat[dim] = keys[i] if i < len(keys) else None
flat["clicks"] = row.get("clicks")
flat["impressions"] = row.get("impressions")
flat["ctr"] = row.get("ctr")
flat["position"] = row.get("position")
results.append(flat)
if max_total_rows > 0 and len(results) >= max_total_rows:
return results[:max_total_rows]
# Si la pagina trajo menos filas que el tope, no hay mas paginas.
if len(rows) < page_size:
break
start_row += page_size
return results
@@ -0,0 +1,163 @@
"""Tests para pull_gsc_search_analytics (sin red ni credenciales)."""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from pull_gsc_search_analytics import pull_gsc_search_analytics
class _FakeQuery:
"""Simula el objeto que devuelve service.searchanalytics().query(...)."""
def __init__(self, pages, calls_log):
self._pages = pages
self._calls_log = calls_log
def execute(self):
# Devuelve la pagina cuyo startRow coincide; si no existe, {} (sin rows).
start_row = self._calls_log[-1]["startRow"]
return self._pages.get(start_row, {})
class _FakeSearchAnalytics:
def __init__(self, pages, calls_log):
self._pages = pages
self._calls_log = calls_log
def query(self, siteUrl, body): # noqa: N803 (firma de la API real)
self._calls_log.append(
{
"siteUrl": siteUrl,
"startRow": body["startRow"],
"rowLimit": body["rowLimit"],
"dimensions": body["dimensions"],
"type": body["type"],
}
)
return _FakeQuery(self._pages, self._calls_log)
class _FakeService:
"""Mock del service autenticado de GSC."""
def __init__(self, pages):
# pages: dict startRow -> response dict
self._pages = pages
self.calls_log = []
def searchanalytics(self):
return _FakeSearchAnalytics(self._pages, self.calls_log)
def _row(keys, clicks, impressions, ctr, position):
return {
"keys": keys,
"clicks": clicks,
"impressions": impressions,
"ctr": ctr,
"position": position,
}
def test_aplanado_mapea_keys_a_nombres_de_dimension():
# Una sola pagina con menos filas que row_limit => para en la primera.
pages = {
0: {
"rows": [
_row(["seo tips", "https://ej.com/a"], 5, 100, 0.05, 12.3),
]
}
}
service = _FakeService(pages)
result = pull_gsc_search_analytics(
service,
"sc-domain:ej.com",
"2026-06-01",
"2026-06-10",
dimensions=["query", "page"],
row_limit=2,
)
assert result == [
{
"query": "seo tips",
"page": "https://ej.com/a",
"clicks": 5,
"impressions": 100,
"ctr": 0.05,
"position": 12.3,
}
]
def test_paginacion_recorre_varias_paginas_y_para_en_pagina_corta():
# row_limit=2: pagina 0 llena (2 filas), pagina 2 llena (2 filas),
# pagina 4 corta (1 fila) => para tras la corta. 5 filas en total.
pages = {
0: {"rows": [_row(["q1", "p1"], 1, 10, 0.1, 1.0), _row(["q2", "p2"], 2, 20, 0.1, 2.0)]},
2: {"rows": [_row(["q3", "p3"], 3, 30, 0.1, 3.0), _row(["q4", "p4"], 4, 40, 0.1, 4.0)]},
4: {"rows": [_row(["q5", "p5"], 5, 50, 0.1, 5.0)]},
}
service = _FakeService(pages)
result = pull_gsc_search_analytics(
service,
"https://ej.com/",
"2026-06-01",
"2026-06-10",
dimensions=["query", "page"],
row_limit=2,
)
assert len(result) == 5
assert [r["query"] for r in result] == ["q1", "q2", "q3", "q4", "q5"]
# Tres requests: startRow 0, 2 y 4 (la de startRow 4 fue corta => no pide 6).
assert [c["startRow"] for c in service.calls_log] == [0, 2, 4]
def test_max_total_rows_recorta():
pages = {
0: {"rows": [_row(["q1", "p1"], 1, 10, 0.1, 1.0), _row(["q2", "p2"], 2, 20, 0.1, 2.0)]},
2: {"rows": [_row(["q3", "p3"], 3, 30, 0.1, 3.0), _row(["q4", "p4"], 4, 40, 0.1, 4.0)]},
}
service = _FakeService(pages)
result = pull_gsc_search_analytics(
service,
"sc-domain:ej.com",
"2026-06-01",
"2026-06-10",
dimensions=["query", "page"],
row_limit=2,
max_total_rows=3,
)
assert len(result) == 3
assert [r["query"] for r in result] == ["q1", "q2", "q3"]
def test_rows_ausente_retorna_lista_vacia():
# Primera (y unica) pagina sin clave 'rows' => lista vacia, sin error.
pages = {0: {"responseAggregationType": "byPage"}}
service = _FakeService(pages)
result = pull_gsc_search_analytics(
service,
"sc-domain:ej.com",
"2026-06-01",
"2026-06-10",
dimensions=["query"],
row_limit=2,
)
assert result == []
def test_dimension_unica_date():
pages = {0: {"rows": [_row(["2026-06-01"], 7, 70, 0.1, 8.0)]}}
service = _FakeService(pages)
result = pull_gsc_search_analytics(
service,
"sc-domain:ej.com",
"2026-06-01",
"2026-06-01",
dimensions=["date"],
row_limit=2,
)
assert result == [
{"date": "2026-06-01", "clicks": 7, "impressions": 70, "ctr": 0.1, "position": 8.0}
]
@@ -0,0 +1,103 @@
---
name: render_eda_markdown
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def render_eda_markdown(profile: dict) -> str"
description: "Convierte un TableProfile (dict del grupo eda) en un report markdown legible y autosuficiente. Render puro: dict de entrada -> string markdown de salida. Lee todo defensivamente con .get(...) porque muchas claves del perfil pueden venir None. Genera secciones Overview, Columnas, Numéricas (con sparkline ASCII del histograma), Categóricas, Calidad, Correlaciones y Análisis LLM, omitiendo limpiamente lo que esté vacío."
tags: [eda, markdown, render, report, profiling, datascience]
params:
- name: profile
desc: "TableProfile dict del grupo eda: {table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows, duplicate_pct, constant_cols, all_null_cols, null_cell_pct, type_breakdown, columns:[ColumnProfile], correlations, key_candidates, quality_score, llm, models}. Cada ColumnProfile puede traer sub-dicts numeric/categorical/datetime que pueden ser None. Todas las claves se leen defensivamente."
output: "String markdown con el report EDA. Empieza por '# EDA — <table>' y contiene las secciones disponibles (Overview, Columnas, Numéricas, Categóricas, Calidad, Correlaciones, Análisis LLM). Las secciones sin datos se omiten."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_contains_title_and_sections", "test_contains_column_names", "test_contains_sparkline", "test_pct_fields_scaled_by_100", "test_pct_handles_none_as_blank", "test_tolerates_none_correlations_and_llm", "test_tolerates_empty_profile", "test_tolerates_none_profile"]
test_file_path: "python/functions/datascience/render_eda_markdown_test.py"
file_path: "python/functions/datascience/render_eda_markdown.py"
---
## Ejemplo
```python
from datascience import render_eda_markdown
profile = {
"table": "sales",
"source": "data/sales.csv",
"n_rows": 1000,
"n_cols": 1,
"null_cell_pct": 0.015,
"type_breakdown": {"numeric": 1},
"columns": [
{
"name": "price",
"inferred_type": "float",
"semantic_type": "currency",
"null_pct": 0.0,
"distinct_count": 850,
"unique_pct": 0.85,
"quality_score": 0.95,
"flags": [],
"numeric": {
"min": 1.0, "median": 40.0, "mean": 42.5, "std": 12.3,
"p25": 30.0, "p75": 55.0, "p95": 80.0, "p99": 95.0,
"skew": 0.4, "outlier_pct": 0.012, "distribution_type": "right-skewed",
"histogram": [
{"lo": 0, "hi": 25, "count": 100},
{"lo": 25, "hi": 50, "count": 500},
{"lo": 50, "hi": 75, "count": 300},
{"lo": 75, "hi": 100, "count": 50},
],
},
"categorical": None,
},
],
"correlations": None,
"llm": None,
}
md = render_eda_markdown(profile)
print(md)
```
Salida (extracto):
```markdown
# EDA — sales
source: `data/sales.csv` · 1000 rows × 1 cols
...
### price
...
histogram: `▂█▅▁`
```
## Cuando usarla
Úsala como paso final de un pipeline EDA: tras construir el `TableProfile` (con las
funciones del grupo `eda` que perfilan columnas, calidad e histogramas), pásaselo a
esta función para obtener un report markdown listo para volcar a un `.md`, una celda
de notebook, una nota de vault o un mensaje. Es render puro: no escribe a disco,
solo devuelve el string, así que tú decides dónde guardarlo. Tolera perfiles
parciales (correlaciones o LLM aún no calculados) sin fallar.
## Gotchas
Función pura sin efectos. El sparkline del histograma escala los `count` de cada bin
linealmente sobre la rampa de bloques `▁▂▃▄▅▆▇█`; si todos los counts son iguales, se
dibuja el bloque más bajo para todos. No escribe el report a ningún archivo — el
caller es responsable de persistirlo.
Convención de porcentajes: TODOS los campos `*_pct` (`null_pct`, `empty_pct`,
`unique_pct`, `outlier_pct`, `zero_pct`, `negative_pct`, `null_cell_pct`,
`duplicate_pct`, y el `pct`/`mode_pct` del sub-dict categorical) se esperan como
**fracción 0-1** (p.ej. `unique_pct=0.857` = 85.7%). El render los multiplica por 100
al formatear, mostrando `85.70%`. No pases valores ya en escala 0-100 o saldrán inflados.
@@ -0,0 +1,302 @@
"""Render a TableProfile dict (eda capability group) into a readable markdown report.
Pure render function: dict in, markdown string out. No I/O, stdlib only.
Reads every key defensively with .get(...) because most profile phases may be
absent (None / missing) depending on how complete the profiling was.
"""
# ASCII block characters used to draw histogram sparklines, low -> high.
_SPARK_BLOCKS = "▁▂▃▄▅▆▇█"
def _fmt_num(value, decimals: int = 4) -> str:
"""Format a number compactly, falling back to str for non-numerics."""
if value is None:
return ""
if isinstance(value, bool):
return str(value)
if isinstance(value, int):
return str(value)
if isinstance(value, float):
if value != value: # NaN
return "NaN"
if value in (float("inf"), float("-inf")):
return str(value)
# Trim trailing zeros for readability.
text = f"{value:.{decimals}f}".rstrip("0").rstrip(".")
return text if text else "0"
return str(value)
def _fmt_pct(value, decimals: int = 2) -> str:
"""Format a fraction (0-1) as a percentage 'NN.NN%'. Returns '' for None.
Every ``*_pct`` field in a TableProfile/ColumnProfile is a fraction in the
[0, 1] range (e.g. ``unique_pct=0.857`` means 85.7%). This helper multiplies
by 100 so the rendered markdown shows the human-facing percentage.
"""
if value is None:
return ""
try:
num = float(value)
except (TypeError, ValueError):
return str(value)
return f"{num * 100:.{decimals}f}%"
def _sparkline(histogram) -> str:
"""Build an ASCII block sparkline from a histogram list of bins.
Each bin is a dict with a 'count' key. Counts are scaled linearly across the
block character ramp. Returns '' when the histogram is empty/None.
"""
if not histogram:
return ""
counts = []
for bin_ in histogram:
if not isinstance(bin_, dict):
return ""
counts.append(bin_.get("count") or 0)
if not counts:
return ""
lo = min(counts)
hi = max(counts)
span = hi - lo
chars = []
last_idx = len(_SPARK_BLOCKS) - 1
for c in counts:
if span <= 0:
idx = 0
else:
idx = int(round((c - lo) / span * last_idx))
idx = max(0, min(last_idx, idx))
chars.append(_SPARK_BLOCKS[idx])
return "".join(chars)
def _md_table(headers, rows) -> str:
"""Render a markdown table from headers and a list of row lists."""
head = "| " + " | ".join(str(h) for h in headers) + " |"
sep = "| " + " | ".join("---" for _ in headers) + " |"
body = []
for row in rows:
cells = [str(c) if c is not None else "" for c in row]
body.append("| " + " | ".join(cells) + " |")
return "\n".join([head, sep] + body)
def render_eda_markdown(profile: dict) -> str:
"""Convert a TableProfile dict into a readable, self-contained markdown report.
Args:
profile: TableProfile dict from the eda capability group. May have many
keys set to None or missing; everything is read defensively and
empty sections are omitted cleanly.
Returns:
A markdown string. Sections with no data are skipped.
"""
if profile is None:
profile = {}
parts: list[str] = []
columns = profile.get("columns") or []
# 1. Title + identity line.
table = profile.get("table") or "(unnamed)"
parts.append(f"# EDA — {table}")
identity_bits = []
source = profile.get("source")
if source:
identity_bits.append(f"source: `{source}`")
profiled_at = profile.get("profiled_at")
if profiled_at:
identity_bits.append(f"profiled_at: {profiled_at}")
n_rows = profile.get("n_rows")
n_cols = profile.get("n_cols")
if n_rows is not None or n_cols is not None:
identity_bits.append(f"{n_rows if n_rows is not None else '?'} rows × "
f"{n_cols if n_cols is not None else '?'} cols")
if identity_bits:
parts.append(" · ".join(identity_bits))
# 2. Overview.
overview_rows = []
if profile.get("n_rows") is not None:
overview_rows.append(["Rows", profile.get("n_rows")])
if profile.get("n_cols") is not None:
overview_rows.append(["Columns", profile.get("n_cols")])
if profile.get("size_bytes") is not None:
overview_rows.append(["Size (bytes)", profile.get("size_bytes")])
if profile.get("duplicate_rows") is not None:
dup = f"{profile.get('duplicate_rows')}"
if profile.get("duplicate_pct") is not None:
dup += f" ({_fmt_pct(profile.get('duplicate_pct'))})"
overview_rows.append(["Duplicate rows", dup])
if profile.get("null_cell_pct") is not None:
overview_rows.append(["Null cells", _fmt_pct(profile.get("null_cell_pct"))])
constant_cols = profile.get("constant_cols") or []
if constant_cols:
overview_rows.append(["Constant columns", ", ".join(constant_cols)])
all_null_cols = profile.get("all_null_cols") or []
if all_null_cols:
overview_rows.append(["All-null columns", ", ".join(all_null_cols)])
if profile.get("quality_score") is not None:
overview_rows.append(["Quality score", _fmt_num(profile.get("quality_score"))])
type_breakdown = profile.get("type_breakdown") or {}
if type_breakdown:
tb = ", ".join(f"{k}: {v}" for k, v in type_breakdown.items() if v is not None)
if tb:
overview_rows.append(["Type breakdown", tb])
key_candidates = profile.get("key_candidates") or []
if key_candidates:
overview_rows.append(["Key candidates", ", ".join(key_candidates)])
if overview_rows:
parts.append("## Overview")
parts.append(_md_table(["Metric", "Value"], overview_rows))
# 3. Columns summary table.
if columns:
rows = []
for col in columns:
if not isinstance(col, dict):
continue
rows.append([
col.get("name"),
col.get("inferred_type"),
col.get("semantic_type"),
_fmt_pct(col.get("null_pct")),
col.get("distinct_count"),
_fmt_pct(col.get("unique_pct")),
_fmt_num(col.get("quality_score")),
", ".join(col.get("flags") or []),
])
if rows:
parts.append("## Columnas")
parts.append(_md_table(
["name", "inferred_type", "semantic_type", "null_pct",
"distinct", "unique_pct", "quality_score", "flags"],
rows,
))
# 4. Numeric columns.
numeric_blocks = []
for col in columns:
if not isinstance(col, dict):
continue
num = col.get("numeric")
if not num:
continue
name = col.get("name") or "(col)"
stat_rows = []
for label, key in [
("min", "min"), ("median", "median"), ("mean", "mean"),
("std", "std"), ("p25", "p25"), ("p75", "p75"),
("p95", "p95"), ("p99", "p99"), ("skew", "skew"),
("outlier_pct", "outlier_pct"),
("distribution_type", "distribution_type"),
]:
val = num.get(key)
if val is None:
continue
if key == "outlier_pct":
stat_rows.append([label, _fmt_pct(val)])
elif key == "distribution_type":
stat_rows.append([label, str(val)])
else:
stat_rows.append([label, _fmt_num(val)])
block = [f"### {name}"]
if stat_rows:
block.append(_md_table(["stat", "value"], stat_rows))
spark = _sparkline(num.get("histogram"))
if spark:
block.append(f"histogram: `{spark}`")
numeric_blocks.append("\n\n".join(block))
if numeric_blocks:
parts.append("## Numéricas")
parts.extend(numeric_blocks)
# 5. Categorical columns.
categorical_blocks = []
for col in columns:
if not isinstance(col, dict):
continue
cat = col.get("categorical")
if not cat:
continue
name = col.get("name") or "(col)"
block = [f"### {name}"]
top = cat.get("top") or []
top_rows = []
for item in top:
if not isinstance(item, dict):
continue
top_rows.append([
item.get("value"),
item.get("count"),
_fmt_pct(item.get("pct")),
])
if top_rows:
block.append(_md_table(["value", "count", "pct"], top_rows))
if cat.get("entropy") is not None:
block.append(f"entropy: {_fmt_num(cat.get('entropy'))}")
categorical_blocks.append("\n\n".join(block))
if categorical_blocks:
parts.append("## Categóricas")
parts.extend(categorical_blocks)
# 6. Quality ranking (worst quality_score first).
scored = [
col for col in columns
if isinstance(col, dict) and col.get("quality_score") is not None
]
if scored:
scored.sort(key=lambda c: c.get("quality_score"))
rows = []
for col in scored:
issues = col.get("issues") or col.get("flags") or []
rows.append([
col.get("name"),
_fmt_num(col.get("quality_score")),
", ".join(issues) if isinstance(issues, list) else str(issues),
])
parts.append("## Calidad")
parts.append(_md_table(["column", "quality_score", "issues"], rows))
# 7. Correlations (tolerate None for now).
correlations = profile.get("correlations")
if correlations:
pairs = correlations
if isinstance(correlations, dict):
pairs = correlations.get("pairs") or correlations.get("strongest") or []
corr_rows = []
for pair in pairs or []:
if isinstance(pair, dict):
corr_rows.append([
pair.get("a") or pair.get("col_a"),
pair.get("b") or pair.get("col_b"),
_fmt_num(pair.get("value") if pair.get("value") is not None
else pair.get("corr")),
])
if corr_rows:
parts.append("## Correlaciones")
parts.append(_md_table(["a", "b", "corr"], corr_rows))
# 8. LLM analysis (tolerate None for now).
llm = profile.get("llm")
if llm:
parts.append("## Análisis LLM")
if isinstance(llm, dict):
for key, value in llm.items():
if value is None:
continue
parts.append(f"### {key}")
if isinstance(value, (list, tuple)):
parts.append("\n".join(f"- {v}" for v in value))
else:
parts.append(str(value))
else:
parts.append(str(llm))
return "\n\n".join(parts) + "\n"
@@ -0,0 +1,166 @@
"""Tests para render_eda_markdown."""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from render_eda_markdown import render_eda_markdown
def _sample_profile(correlations=None, llm=None):
return {
"table": "sales",
"source": "data/sales.csv",
"profiled_at": "2026-06-20T10:00:00Z",
"n_rows": 1000,
"n_cols": 2,
"size_bytes": 40960,
"duplicate_rows": 3,
"duplicate_pct": 0.003,
"constant_cols": [],
"all_null_cols": [],
"null_cell_pct": 0.015,
"type_breakdown": {"numeric": 1, "categorical": 1},
"quality_score": 0.92,
"key_candidates": ["order_id"],
"correlations": correlations,
"llm": llm,
"models": None,
"columns": [
{
"name": "price",
"physical_type": "DOUBLE",
"inferred_type": "float",
"semantic_type": "currency",
"count": 1000,
"n_rows": 1000,
"null_count": 0,
"null_pct": 0.0,
"distinct_count": 857,
"unique_pct": 0.857,
"flags": [],
"quality_score": 0.95,
"numeric": {
"min": 1.0,
"max": 99.0,
"mean": 42.5,
"median": 40.0,
"std": 12.3,
"p25": 30.0,
"p75": 55.0,
"p95": 80.0,
"p99": 95.0,
"skew": 0.4,
"kurtosis": 2.1,
"outlier_pct": 0.012,
"distribution_type": "right-skewed",
"histogram": [
{"lo": 0, "hi": 25, "count": 100},
{"lo": 25, "hi": 50, "count": 500},
{"lo": 50, "hi": 75, "count": 300},
{"lo": 75, "hi": 100, "count": 50},
],
},
"categorical": None,
"datetime": None,
},
{
"name": "region",
"physical_type": "VARCHAR",
"inferred_type": "string",
"semantic_type": "category",
"count": 1000,
"n_rows": 1000,
"null_count": 10,
"null_pct": 0.01,
"distinct_count": 3,
"unique_pct": 0.003,
"flags": ["low_cardinality"],
"quality_score": 0.80,
"numeric": None,
"categorical": {
"top": [
{"value": "north", "count": 500, "pct": 0.5},
{"value": "south", "count": 300, "pct": 0.3},
{"value": "east", "count": 200, "pct": 0.2},
],
"mode": "north",
"mode_pct": 0.5,
"n_distinct": 3,
"entropy": 1.48,
},
"datetime": None,
},
],
}
def test_contains_title_and_sections():
md = render_eda_markdown(_sample_profile())
assert "# EDA — sales" in md
assert "## Overview" in md
assert "## Columnas" in md
assert "## Numéricas" in md
assert "## Categóricas" in md
def test_contains_column_names():
md = render_eda_markdown(_sample_profile())
assert "price" in md
assert "region" in md
def test_contains_sparkline():
md = render_eda_markdown(_sample_profile())
# Histogram sparkline must render with block characters.
assert "histogram: `" in md
assert any(block in md for block in "▁▂▃▄▅▆▇█")
def test_pct_fields_scaled_by_100():
# *_pct fields are fractions 0-1; the render must show them ×100.
md = render_eda_markdown(_sample_profile())
# unique_pct=0.857 -> "85.70%" (must NOT show the raw "0.86%").
assert "85.7" in md
assert "0.86%" not in md
# categorical top pct=0.5 -> "50.0%".
assert "50.0" in md
# outlier_pct=0.012 -> "1.20%".
assert "1.20%" in md
def test_pct_handles_none_as_blank():
profile = {
"table": "t",
"columns": [
{
"name": "c",
"inferred_type": "float",
"null_pct": None,
"unique_pct": None,
"quality_score": 0.5,
}
],
}
# None pct renders as empty cell, never "None%" or a crash.
md = render_eda_markdown(profile)
assert "None%" not in md
def test_tolerates_none_correlations_and_llm():
md = render_eda_markdown(_sample_profile(correlations=None, llm=None))
assert "## Correlaciones" not in md
assert "## Análisis LLM" not in md
# Still produced the main body.
assert "# EDA — sales" in md
def test_tolerates_empty_profile():
md = render_eda_markdown({})
assert "# EDA — (unnamed)" in md
def test_tolerates_none_profile():
md = render_eda_markdown(None)
assert "# EDA — (unnamed)" in md
@@ -0,0 +1,119 @@
---
id: run_eda_models_py_datascience
name: run_eda_models
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def run_eda_models(columns: dict, run_pca: bool = True, run_kmeans: bool = True, run_isolation: bool = True, run_normality: bool = True) -> dict"
description: "Orquesta los modelos baratos del grupo eda (PCA, KMeans, Isolation Forest, normalidad) sobre las columnas numericas de un perfil de tabla y devuelve el bloque models de un TableProfile. Composicion canonica del flag --models de profile_table. Compone funciones puras del registry, no reescribe logica."
tags: [eda, models, datascience, profiling, pca, kmeans, isolation-forest, normality, multivariate, composition]
uses_functions:
- pca_explained_py_datascience
- kmeans_segments_py_datascience
- isolation_forest_outliers_py_datascience
- normality_tests_py_datascience
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [datascience]
example: |
from run_eda_models import run_eda_models
cols = {
"x": {"values": [1.0, 2.0, 3.0, 4.0], "type": "numeric"},
"y": {"values": [2.0, 4.0, 6.0, 8.0], "type": "numeric"},
"z": {"values": [5.0, 4.0, 6.0, 5.5], "type": "numeric"},
}
block = run_eda_models(cols)
# block["n_numeric_cols"] == 3; block["pca"], block["kmeans"], block["normality"] poblados
tested: true
tests:
- "test_three_numeric_columns_runs_all_models"
- "test_single_numeric_column_note_and_normality_only"
- "test_flags_disable_models"
- "test_no_numeric_columns_returns_note_and_no_normality"
test_file_path: "python/functions/datascience/run_eda_models_test.py"
file_path: "python/functions/datascience/run_eda_models.py"
params:
- name: columns
desc: "Mapa {nombre_columna: {values: list, type: 'numeric'|'categorical'|'datetime'|...}}. Mismo shape que recibe association_matrix; listas alineadas por fila. Solo las columnas con type=='numeric' alimentan los modelos."
- name: run_pca
desc: "Si True, ejecuta pca_explained sobre el subconjunto numerico (estructura latente / varianza explicada). Default True."
- name: run_kmeans
desc: "Si True, ejecuta kmeans_segments con seleccion automatica de k por silhouette (segmentos naturales). Default True."
- name: run_isolation
desc: "Si True, ejecuta isolation_forest_outliers (anomalias multivariante). Default True."
- name: run_normality
desc: "Si True, ejecuta normality_tests por cada columna numerica. Es univariante: basta 1 columna. Default True."
output: >
dict con {n_numeric_cols, pca, kmeans, outliers, normality, note}. pca/kmeans/outliers
son la salida de su funcion del registry o None (flag desactivado o <2 columnas numericas).
normality es {col: salida de normality_tests} o None (flag desactivado o sin columnas
numericas). Con <2 columnas numericas los multivariantes quedan en None y note =
"insuficientes columnas numericas para modelos multivariantes" (normality sigue
poblandose si hay >=1 columna numerica). Con >=2 columnas y todo activado, note = "".
Nunca lanza excepcion.
---
## Ejemplo
```python
from run_eda_models import run_eda_models
import numpy as np
rng = np.random.default_rng(0)
n = 120
x = rng.normal(0, 1, n)
y = x * 2 + rng.normal(0, 0.3, n) # correlacionada con x
z = rng.normal(5, 1, n) # ruido independiente
cols = {
"x": {"values": x.tolist(), "type": "numeric"},
"y": {"values": y.tolist(), "type": "numeric"},
"z": {"values": z.tolist(), "type": "numeric"},
}
models = run_eda_models(cols)
models["n_numeric_cols"] # 3
models["pca"]["explained_variance_ratio"] # PC1 concentra la varianza de x/y
models["kmeans"]["best_k"] # k elegido por silhouette
models["outliers"]["n_outliers"] # filas anomalas multivariante
models["normality"]["z"]["is_normal"] # True (z es normal)
models["note"] # ""
# Una sola columna numerica: solo normalidad, multivariantes en None
solo = {"v": {"values": x.tolist(), "type": "numeric"}}
run_eda_models(solo)["note"]
# "insuficientes columnas numericas para modelos multivariantes"
```
## Cuando usarla
Es la capa `--models` de un EDA: cuando ya tienes el perfil de columnas de una
tabla (mismo shape que alimenta `association_matrix`) y quieres, de un solo golpe,
la estructura latente (PCA), los segmentos naturales (KMeans), las anomalias
multivariante (Isolation Forest) y la normalidad de cada columna numerica. En vez
de llamar a las cuatro funciones por separado y montar el bloque a mano, esta las
compone y devuelve el bloque `models` listo para incrustar en un `TableProfile`.
Usa los flags `run_*` para apagar los modelos que no necesites.
## Gotchas
- PCA, KMeans e Isolation Forest son multivariantes y necesitan **>=2 columnas
numericas**; con menos, sus claves quedan en `None` y se devuelve `note`. La
normalidad es univariante y se corre con 1 columna.
- Cada modelo subyacente tiene su propio umbral minimo de filas validas y puede
devolver `{"note": "datos insuficientes"}` (PCA: >=3 filas; KMeans: >=k_min*2;
Isolation Forest: >=10 filas; normalidad: >=8 tras limpiar). Esta funcion los
propaga tal cual dentro del bloque, sin petar.
- Solo se usan columnas con `type == "numeric"`. Los valores se convierten a
`float` cuando es posible; None, booleanos y no parseables se descartan por
columna, asi que la longitud efectiva puede ser menor que la lista original.
- `trend_slope` NO se ejecuta aqui: requiere un orden temporal explicito y queda
disponible suelto en el registry.
- Aunque compone funciones impuras-en-apariencia (sklearn/scipy), todas son
deterministas (`random_state=0`), por lo que el resultado es reproducible para
una misma entrada.
@@ -0,0 +1,130 @@
"""Orquesta los modelos baratos del grupo `eda` en un solo bloque.
Compone las funciones puras de modelado del registry (PCA, KMeans, Isolation
Forest, tests de normalidad) sobre el subconjunto de columnas numericas de un
perfil de tabla y devuelve el bloque "models" canonico que consume el flag
``--models`` de ``profile_table``. No reescribe logica: delega en cada funcion
del registry. Es pura y determinista (todas las dependencias lo son).
"""
from datascience import (
isolation_forest_outliers,
kmeans_segments,
normality_tests,
pca_explained,
)
def _to_numeric_subset(columns: dict) -> dict:
"""Extrae las columnas numericas como {nombre: [float values]}.
Solo se quedan las columnas con ``type == "numeric"``. Para cada una, los
valores se convierten a float cuando es posible y los que son None o no
parseables se descartan (la lista resultante puede ser mas corta que la
original). Mantiene el orden de aparicion de las columnas.
Args:
columns: mapa {nombre_columna: {"values": list, "type": str}}.
Returns:
dict {nombre_columna: [float, ...]} solo con columnas numericas.
"""
numeric: dict[str, list] = {}
if not isinstance(columns, dict):
return numeric
for name, meta in columns.items():
if not isinstance(meta, dict):
continue
if meta.get("type") != "numeric":
continue
values = meta.get("values")
if not isinstance(values, (list, tuple)):
continue
parsed: list[float] = []
for v in values:
if v is None or isinstance(v, bool):
continue
try:
parsed.append(float(v))
except (TypeError, ValueError):
continue
numeric[name] = parsed
return numeric
def run_eda_models(
columns: dict,
run_pca: bool = True,
run_kmeans: bool = True,
run_isolation: bool = True,
run_normality: bool = True,
) -> dict:
"""Ejecuta los modelos baratos del grupo `eda` sobre las columnas numericas.
Composicion canonica para el flag ``--models`` de ``profile_table``. Toma el
mapa de columnas con el mismo shape que recibe ``association_matrix`` (cada
columna con ``values`` y ``type``), extrae el subconjunto numerico, y corre
los modelos pedidos sobre el. No reescribe ninguno: compone las funciones
puras ``pca_explained``, ``kmeans_segments``, ``isolation_forest_outliers``
y ``normality_tests`` del registry.
Los tests de normalidad se corren por columna numerica individual (basta 1
columna). PCA, KMeans e Isolation Forest son multivariantes y necesitan al
menos 2 columnas numericas; con menos, sus claves quedan en None y se
devuelve una ``note`` explicativa. No lanza excepciones.
``trend_slope`` NO se ejecuta aqui: requiere un orden temporal explicito y
queda disponible suelto en el registry.
Args:
columns: mapa {nombre_columna: {"values": list, "type": str}}, mismo
shape que recibe ``association_matrix``; listas alineadas por fila.
run_pca: si True, ejecuta PCA sobre el subconjunto numerico.
run_kmeans: si True, ejecuta KMeans con seleccion automatica de k.
run_isolation: si True, ejecuta Isolation Forest multivariante.
run_normality: si True, ejecuta tests de normalidad por columna.
Returns:
dict con:
n_numeric_cols: numero de columnas numericas detectadas.
pca: salida de pca_explained o None (si run_pca False / <2 cols).
kmeans: salida de kmeans_segments o None (si run_kmeans False / <2).
outliers: salida de isolation_forest_outliers o None.
normality: {col: salida de normality_tests} o None (si run_normality
False o no hay columnas numericas).
note: descripcion de por que faltan los multivariantes, si aplica.
Con menos de 2 columnas numericas devuelve los multivariantes en None y
una ``note``; ``normality`` sigue poblandose si run_normality True y hay
al menos 1 columna numerica.
"""
numeric = _to_numeric_subset(columns)
n_numeric_cols = len(numeric)
# normality es univariante: basta una columna numerica.
normality = None
if run_normality and n_numeric_cols >= 1:
normality = {name: normality_tests(values) for name, values in numeric.items()}
if n_numeric_cols < 2:
return {
"n_numeric_cols": n_numeric_cols,
"pca": None,
"kmeans": None,
"outliers": None,
"normality": normality,
"note": "insuficientes columnas numericas para modelos multivariantes",
}
pca = pca_explained(numeric) if run_pca else None
kmeans = kmeans_segments(numeric) if run_kmeans else None
outliers = isolation_forest_outliers(numeric) if run_isolation else None
return {
"n_numeric_cols": n_numeric_cols,
"pca": pca,
"kmeans": kmeans,
"outliers": outliers,
"normality": normality,
"note": "",
}
@@ -0,0 +1,111 @@
"""Tests para run_eda_models."""
import numpy as np
from run_eda_models import run_eda_models
def _numeric(values: list) -> dict:
"""Envuelve una lista como columna numerica del perfil."""
return {"values": values, "type": "numeric"}
def test_three_numeric_columns_runs_all_models():
# Tres columnas con estructura latente: x e y correlacionadas, z ruido.
rng = np.random.default_rng(0)
n = 120
x = rng.normal(0.0, 1.0, n)
y = x * 2.0 + rng.normal(0.0, 0.3, n)
z = rng.normal(5.0, 1.0, n)
columns = {
"x": _numeric(x.tolist()),
"y": _numeric(y.tolist()),
"z": _numeric(z.tolist()),
}
result = run_eda_models(columns)
assert result["n_numeric_cols"] == 3
assert result["note"] == ""
# PCA presente y con varianza explicada.
assert result["pca"] is not None
assert result["pca"]["n_components"] >= 1
assert len(result["pca"]["explained_variance_ratio"]) >= 1
# KMeans presente con un k elegido.
assert result["kmeans"] is not None
assert result["kmeans"]["best_k"] >= 2
# Outliers presente (puede ser 0 outliers, pero el bloque existe).
assert result["outliers"] is not None
assert "n_outliers" in result["outliers"]
# Normality presente, una entrada por columna numerica.
assert result["normality"] is not None
assert set(result["normality"].keys()) == {"x", "y", "z"}
for col in ("x", "y", "z"):
assert result["normality"][col]["n"] == n
def test_single_numeric_column_note_and_normality_only():
rng = np.random.default_rng(7)
values = rng.normal(10.0, 2.0, 100).tolist()
columns = {
"only": _numeric(values),
"label": {"values": ["a"] * 100, "type": "categorical"},
}
result = run_eda_models(columns)
assert result["n_numeric_cols"] == 1
assert result["note"] == "insuficientes columnas numericas para modelos multivariantes"
# Multivariantes en None.
assert result["pca"] is None
assert result["kmeans"] is None
assert result["outliers"] is None
# Normality univariante si se ejecuta con una sola columna.
assert result["normality"] is not None
assert "only" in result["normality"]
assert result["normality"]["only"]["n"] == 100
def test_flags_disable_models():
rng = np.random.default_rng(1)
n = 60
columns = {
"a": _numeric(rng.normal(0, 1, n).tolist()),
"b": _numeric(rng.normal(0, 1, n).tolist()),
}
result = run_eda_models(
columns,
run_pca=False,
run_kmeans=False,
run_isolation=False,
run_normality=False,
)
assert result["n_numeric_cols"] == 2
assert result["pca"] is None
assert result["kmeans"] is None
assert result["outliers"] is None
assert result["normality"] is None
assert result["note"] == ""
def test_no_numeric_columns_returns_note_and_no_normality():
columns = {
"cat": {"values": ["x", "y", "z"], "type": "categorical"},
}
result = run_eda_models(columns)
assert result["n_numeric_cols"] == 0
assert result["note"] == "insuficientes columnas numericas para modelos multivariantes"
assert result["pca"] is None
# run_normality True pero no hay columnas numericas -> None.
assert result["normality"] is None
@@ -0,0 +1,65 @@
---
name: score_demand_signal
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def score_demand_signal(text: str, phrases: list[str] = None) -> dict"
description: "Puntua una pieza de texto por senales de demanda de mercado: cuenta frases tipo 'i wish there was', 'looking for a tool', 'willing to pay' que indican demanda latente de una solucion. Funcion pura y determinista para clasificar posts/comentarios en pipelines de market intelligence."
tags: [market-intel, demand, scoring, text, nlp, pure, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: text
desc: "texto a analizar (ej: titulo + cuerpo de un post de Reddit/HN concatenados)"
- name: phrases
desc: "lista de frases-senal a buscar por substring case-insensitive. Si None, usa el catalogo por defecto orientado a demanda de herramientas/SaaS (15 frases)"
output: "dict con {demand_score: int (nº de frases distintas que matchearon), matched_phrases: list[str] (frases coincidentes en minusculas)}"
tested: true
tests:
- "frase por defecto matchea (i wish there was)"
- "varias frases matchean suman score"
- "ninguna frase matchea da score 0"
- "match es case-insensitive"
- "phrases custom override del default"
- "texto vacio da score 0"
test_file_path: "python/functions/datascience/score_demand_signal_test.py"
file_path: "python/functions/datascience/score_demand_signal.py"
---
## Ejemplo
```python
from datascience import score_demand_signal
result = score_demand_signal("I wish there was a tool to dedupe my CSVs")
# {"demand_score": 2, "matched_phrases": ["i wish there was", "is there a tool"]}
# (matchea "i wish there was" y, dentro de "is there a tool"? -> ver Gotchas)
# Con frases custom
score_demand_signal("Necesito automatizar esto", phrases=["necesito"])
# {"demand_score": 1, "matched_phrases": ["necesito"]}
```
## Cuando usarla
Usala para clasificar posts y comentarios en pipelines de market intelligence:
tras recolectar texto con `fetch_reddit_search` o `fetch_hackernews_search`,
puntua cada fila para detectar demanda latente ("alguien busca una herramienta
que no existe"). Un `demand_score >= 1` marca el item como senal de oportunidad.
## Gotchas
- El match es por **substring**, no por palabra completa: frases solapadas o
contenidas en otras pueden contar (ej. una frase que sea prefijo de otra del
catalogo). El catalogo por defecto esta curado para minimizar solapes, pero al
pasar `phrases` custom conviene evitar frases que sean substring unas de otras
si quieres conteos disjuntos.
- Solo cuenta **frases distintas** del catalogo: si una misma frase aparece 3
veces en el texto, suma 1, no 3.
- `text=None` se trata como cadena vacia (score 0), no lanza error.
@@ -0,0 +1,56 @@
"""score_demand_signal — puntua una pieza de texto por senales de demanda de mercado.
Funcion pura: sin I/O, determinista. Detecta frases que indican que alguien
busca, desea o pagaria por una herramienta/solucion (demanda latente).
"""
DEFAULT_PHRASES = [
"i wish there was",
"is there a tool",
"looking for a tool",
"looking for an",
"alternative to",
"anyone know a tool",
"does anyone know",
"how do i automate",
"willing to pay",
"would pay for",
"frustrated with",
"need a way to",
"wish there was a way",
"is there any app",
"recommend a tool",
]
def score_demand_signal(text: str, phrases: list[str] = None) -> dict:
"""Puntua un texto contando frases que senalan demanda de mercado.
Match case-insensitive por substring. Cada frase que aparece en `text`
suma 1 al score y se anade (en minusculas) a la lista de coincidencias.
Args:
text: Texto a analizar (titulo + cuerpo de un post, comentario, etc.).
phrases: Lista de frases-senal a buscar. Si es None, usa el catalogo
por defecto orientado a demanda de herramientas/SaaS.
Returns:
Dict con:
- demand_score (int): numero de frases distintas que matchearon.
- matched_phrases (list[str]): las frases que coincidieron, en minusculas.
"""
if phrases is None:
phrases = DEFAULT_PHRASES
text_lower = (text or "").lower()
matched = []
for phrase in phrases:
phrase_lower = phrase.lower()
if phrase_lower in text_lower:
matched.append(phrase_lower)
return {
"demand_score": len(matched),
"matched_phrases": matched,
}
@@ -0,0 +1,58 @@
"""Tests para score_demand_signal."""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from score_demand_signal import score_demand_signal
def test_frase_por_defecto_matchea_i_wish_there_was():
result = score_demand_signal("I wish there was a better way to do X")
assert result["demand_score"] >= 1
assert "i wish there was" in result["matched_phrases"]
def test_varias_frases_matchean_suman_score():
text = "I wish there was a tool. Anyone know a tool for this? Would pay for it."
result = score_demand_signal(text)
assert result["demand_score"] >= 3
assert "i wish there was" in result["matched_phrases"]
assert "anyone know a tool" in result["matched_phrases"]
assert "would pay for" in result["matched_phrases"]
def test_ninguna_frase_matchea_da_score_0():
result = score_demand_signal("Just a normal sentence about cats and dogs.")
assert result["demand_score"] == 0
assert result["matched_phrases"] == []
def test_match_es_case_insensitive():
result = score_demand_signal("WILLING TO PAY for a fix")
assert result["demand_score"] >= 1
assert "willing to pay" in result["matched_phrases"]
def test_phrases_custom_override_del_default():
result = score_demand_signal("Necesito automatizar esto", phrases=["necesito"])
assert result["demand_score"] == 1
assert result["matched_phrases"] == ["necesito"]
# Una frase del default que NO esta en el custom no debe contar.
result2 = score_demand_signal("I wish there was a fix", phrases=["necesito"])
assert result2["demand_score"] == 0
def test_texto_vacio_da_score_0():
assert score_demand_signal("")["demand_score"] == 0
assert score_demand_signal(None)["demand_score"] == 0
if __name__ == "__main__":
test_frase_por_defecto_matchea_i_wish_there_was()
test_varias_frases_matchean_suman_score()
test_ninguna_frase_matchea_da_score_0()
test_match_es_case_insensitive()
test_phrases_custom_override_del_default()
test_texto_vacio_da_score_0()
print("All tests passed.")
@@ -6,14 +6,14 @@ domain: datascience
version: "1.0.0"
purity: impure
signature: "def scrape_amazon_bestsellers(marketplace: str = 'amazon.es', categories: list[str] | None = None, list_type: str = 'bestsellers', max_items: int = 50) -> list[dict]"
description: "Scrapea los rankings de Amazon (Best Sellers y Movers & Shakers) de un marketplace para captar señales de demanda de productos: rank, ASIN, titulo, precio, rating, reseñas y, en movers, el cambio porcentual."
description: "Scrapea los rankings de Amazon (Best Sellers y Movers & Shakers) de un marketplace via HTTP (requests) para captar señales de demanda de productos: rank, ASIN, titulo, precio, rating, reseñas y, en movers, el cambio porcentual. Delega el parsing en el parser puro parse_amazon_ranking_html."
tags: [amazon, scraping, trends, market-intel, datascience]
uses_functions: []
uses_functions: [parse_amazon_ranking_html_py_datascience]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests, bs4]
imports: [requests]
tested: false
tests: []
test_file_path: ""
@@ -1,13 +1,22 @@
"""Scrape Amazon Best Sellers and Movers & Shakers ranking pages for product demand signals."""
"""Scrape Amazon Best Sellers and Movers & Shakers ranking pages for product demand signals.
HTTP fetch strategy: fetches each ranking page with ``requests`` (browser-ish
headers + retry/backoff) and delegates DOM parsing to the pure, reusable
``parse_amazon_ranking_html`` function of the registry — so the HTTP scraper and
the CDP scraper (``scrape_amazon_movers_cdp``) share one robust parser.
"""
from __future__ import annotations
import re
import os
import sys
import time
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from datascience.parse_amazon_ranking_html import parse_amazon_ranking_html
# Accept-Language hint per marketplace TLD. Falls back to a generic value.
_ACCEPT_LANGUAGE = {
@@ -21,28 +30,6 @@ _ACCEPT_LANGUAGE = {
"amazon.com.br": "pt-BR,pt;q=0.9,en;q=0.6",
}
# Currency guessed from the marketplace TLD (used only as a fallback when the
# price string has no recognisable symbol).
_CURRENCY_BY_MARKET = {
"amazon.es": "EUR",
"amazon.com": "USD",
"amazon.co.uk": "GBP",
"amazon.de": "EUR",
"amazon.fr": "EUR",
"amazon.it": "EUR",
"amazon.com.mx": "MXN",
"amazon.com.br": "BRL",
}
# Map common currency symbols to ISO codes.
_SYMBOL_TO_CURRENCY = {
"": "EUR",
"$": "USD",
"£": "GBP",
"R$": "BRL",
"US$": "USD",
}
_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
@@ -133,213 +120,6 @@ def _fetch(url: str, headers: dict, timeout: int, retries: int) -> requests.Resp
raise RuntimeError(f"could not fetch {url}: {last_exc}")
_ASIN_RE = re.compile(r"/(?:dp|gp/product)/([A-Z0-9]{10})(?:[/?]|$)")
_RANK_RE = re.compile(r"#?\s*(\d+)")
_PRICE_NUM_RE = re.compile(r"[-+]?\d[\d.,]*")
_REVIEWS_RE = re.compile(r"[\d.,]+")
_RATING_RE = re.compile(r"([\d.,]+)\s*(?:out of|de|von|su|sur|de um total de)")
_PCT_RE = re.compile(r"([\d.,]+)\s*%")
def _text(node) -> str:
return node.get_text(" ", strip=True) if node is not None else ""
def _parse_asin(card) -> str | None:
"""ASIN from a data-asin attribute or any /dp/<ASIN>/ link inside the card."""
asin = card.get("data-asin")
if asin and re.fullmatch(r"[A-Z0-9]{10}", asin):
return asin
for a in card.find_all("a", href=True):
m = _ASIN_RE.search(a["href"])
if m:
return m.group(1)
return None
def _parse_url(card, marketplace: str) -> str | None:
"""Absolute product URL from the first /dp/ link in the card."""
base = f"https://www.{marketplace}"
for a in card.find_all("a", href=True):
if _ASIN_RE.search(a["href"]):
return urljoin(base, a["href"].split("?")[0])
# Fall back to the first link at all.
first = card.find("a", href=True)
if first is not None:
return urljoin(base, first["href"].split("?")[0])
return None
def _parse_rank(card) -> int | None:
"""Rank badge. Amazon renders it as '#1', '1', etc."""
badge = card.select_one(".zg-bdg-text, .zg-badge-text, [class*='badge']")
txt = _text(badge)
if not txt:
# Sometimes the rank is in a class like a11y .zg-bdg-text sibling.
for sel in (".a-badge-text", "[class*='rank']"):
node = card.select_one(sel)
txt = _text(node)
if txt:
break
m = _RANK_RE.search(txt)
return int(m.group(1)) if m else None
def _parse_title(card) -> str | None:
"""Product title — several templates over the years."""
for sel in (
"._cDEzb_p13n-sc-css-line-clamp-3_g3dy1",
"._cDEzb_p13n-sc-css-line-clamp-2_EWgCb",
"[class*='line-clamp']",
".p13n-sc-truncate",
".p13n-sc-truncated",
"a.a-link-normal[title]",
"img[alt]",
):
node = card.select_one(sel)
if node is None:
continue
if node.name == "img":
alt = node.get("alt")
if alt:
return alt.strip()
continue
if node.has_attr("title") and node["title"].strip():
return node["title"].strip()
txt = _text(node)
if txt:
return txt
return None
def _parse_price(card, marketplace: str) -> tuple[float | None, str | None]:
"""Price value (float) and ISO currency, best-effort across templates."""
for sel in (
"._cDEzb_p13n-sc-price_3mJ9Z",
".p13n-sc-price",
"span.a-price > span.a-offscreen",
".a-price .a-offscreen",
"[class*='price']",
):
node = card.select_one(sel)
txt = _text(node)
if not txt:
continue
currency = None
for sym, iso in _SYMBOL_TO_CURRENCY.items():
if sym in txt:
currency = iso
break
if currency is None:
currency = _CURRENCY_BY_MARKET.get(marketplace)
m = _PRICE_NUM_RE.search(txt)
if not m:
continue
raw = m.group(0)
value = _to_float(raw)
if value is not None:
return value, currency
return None, None
def _parse_rating(card) -> float | None:
"""Star rating, e.g. '4,5 de 5 estrellas' / '4.5 out of 5 stars'."""
for sel in ("[class*='review-stars']", ".a-icon-alt", "[title*='star']", "[aria-label*='star']"):
node = card.select_one(sel)
txt = _text(node) or (node.get("title", "") if node is not None else "") or (
node.get("aria-label", "") if node is not None else ""
)
if not txt:
continue
m = _RATING_RE.search(txt)
if m:
return _to_float(m.group(1))
# Some templates only render the number ('4,5').
m2 = _PRICE_NUM_RE.search(txt)
if m2 and ("star" in txt.lower() or "estrella" in txt.lower()):
return _to_float(m2.group(0))
return None
def _parse_reviews(card) -> int | None:
"""Number of ratings/reviews shown next to the stars."""
for sel in (
"a.a-size-small.a-link-normal",
".a-size-small.a-link-normal",
"[class*='review-count']",
"span.a-size-small",
):
for node in card.select(sel):
txt = _text(node)
if not txt:
continue
m = _REVIEWS_RE.search(txt)
if not m:
continue
digits = m.group(0).replace(".", "").replace(",", "")
if digits.isdigit() and len(digits) >= 1:
# Avoid catching rank/price by requiring a plausible count token.
return int(digits)
return None
def _parse_pct_change(card) -> float | None:
"""Movers & Shakers percentage change ('+150%')."""
for sel in (".zg-percent-change", "[class*='percent']", "[class*='sales-movement']"):
node = card.select_one(sel)
txt = _text(node)
if not txt:
continue
m = _PCT_RE.search(txt)
if m:
value = _to_float(m.group(1))
if value is None:
continue
return -value if txt.strip().startswith("-") else value
return None
def _to_float(raw: str) -> float | None:
"""Parse a numeric string with EU or US decimal/grouping conventions."""
if raw is None:
return None
s = raw.strip().replace("\xa0", "").replace(" ", "")
if not s:
return None
if "," in s and "." in s:
# The rightmost separator is the decimal one.
if s.rfind(",") > s.rfind("."):
s = s.replace(".", "").replace(",", ".")
else:
s = s.replace(",", "")
elif "," in s:
# Treat a single comma as decimal separator (EU markets).
s = s.replace(",", ".")
try:
return float(s)
except ValueError:
return None
def _select_cards(soup: BeautifulSoup) -> list:
"""Locate the list-item cards across known Amazon templates."""
selectors = (
"div.p13n-sc-uncoverable-faceout",
"div[id^='gridItemRoot']",
"div.zg-grid-general-faceout",
"li.zg-item-immersion",
"div.a-cardui[data-asin]",
"div[data-asin]",
)
for sel in selectors:
cards = soup.select(sel)
if cards:
return cards
return []
def scrape_amazon_bestsellers(
marketplace: str = "amazon.es",
categories: list[str] | None = None,
@@ -365,7 +145,8 @@ def scrape_amazon_bestsellers(
``marketplace, list_type, category, rank, asin, title, price,
currency, rating, reviews, pct_change, url``. Missing values are
``None``. ``price``/``rating``/``pct_change`` are floats,
``rank``/``reviews`` are ints.
``rank``/``reviews`` are ints. ``pct_change`` only filled for
``movers_shakers``.
Raises:
ValueError: If ``list_type`` is not one of the allowed values.
@@ -384,42 +165,16 @@ def scrape_amazon_bestsellers(
for category in cats:
url = _build_url(marketplace, list_type, category)
resp = _fetch(url, headers, timeout=20, retries=2)
soup = BeautifulSoup(resp.text, "lxml")
cards = _select_cards(soup)
count = 0
for idx, card in enumerate(cards):
if count >= max_items:
break
asin = _parse_asin(card)
title = _parse_title(card)
# Skip empty / non-product wrappers.
if asin is None and title is None:
continue
rank = _parse_rank(card)
if rank is None:
rank = idx + 1 # positional fallback when no badge is rendered
price, currency = _parse_price(card, marketplace)
results.append(
{
"marketplace": marketplace,
"list_type": list_type,
"category": category,
"rank": rank,
"asin": asin,
"title": title,
"price": price,
"currency": currency,
"rating": _parse_rating(card),
"reviews": _parse_reviews(card),
"pct_change": _parse_pct_change(card)
if list_type == "movers_shakers"
else None,
"url": _parse_url(card, marketplace),
}
)
count += 1
rows = parse_amazon_ranking_html(
resp.text,
marketplace=marketplace,
list_type=list_type,
max_items=max_items,
)
# The pure parser leaves category=None (it doesn't know the URL);
# stamp the category we requested.
for row in rows:
row["category"] = category
results.extend(rows)
return results
@@ -0,0 +1,58 @@
---
name: spearman_corr
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def spearman_corr(xs: list, ys: list) -> float"
description: "Coeficiente de correlacion de Spearman (correlacion de rangos) entre dos listas pareadas. Capta relaciones monotonicas no lineales que Pearson no detecta. Descarta pares None/NaN/no-numericos; <3 pares validos o varianza cero -> 0.0."
tags: [statistics, correlation, spearman, rank, eda, monotonic, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [math, scipy]
params:
- name: xs
desc: "lista de valores numericos de la primera variable. None/NaN/no-numericos se descartan junto a su par."
- name: ys
desc: "lista de valores numericos de la segunda variable, pareada por indice con xs."
output: "coeficiente de Spearman en rango [-1, 1] como float. 1.0=relacion monotonica creciente perfecta, -1.0=decreciente perfecta, 0.0=sin relacion monotonica o datos insuficientes. Nunca None ni excepcion."
tested: true
tests: ["test_relacion_monotonica_perfecta", "test_relacion_monotonica_decreciente", "test_pares_con_none_se_ignoran", "test_pares_con_nan_se_ignoran", "test_menos_de_3_pares_validos_retorna_cero", "test_varianza_cero_retorna_cero", "test_listas_vacias_retorna_cero", "test_resultado_es_float"]
test_file_path: "python/functions/datascience/spearman_corr_test.py"
file_path: "python/functions/datascience/spearman_corr.py"
---
## Ejemplo
```python
from datascience import spearman_corr
# Relacion monotonica NO lineal (ys = x**2): Pearson < 1, Spearman = 1.0
xs = [1, 2, 3, 4, 5, 6]
ys = [x ** 2 for x in xs] # [1, 4, 9, 16, 25, 36]
spearman_corr(xs, ys) # -> 1.0
# Pares con None se descartan automaticamente
spearman_corr([1, 2, None, 4], [2, 4, 99, 8]) # -> 1.0
```
## Cuando usarla
Cuando sospechas una relacion monotonica no lineal entre dos variables que
Pearson (lineal) no capta: una crece consistentemente cuando la otra crece,
pero no en linea recta (curvas exponenciales, logaritmicas, potencias). Util
en EDA para rankear que pares de variables estan asociadas antes de modelar,
y cuando hay outliers que distorsionarian a Pearson (Spearman usa rangos).
## Gotchas
- Es pura pero importa `scipy.stats.spearmanr` (scipy ya vive en `python/.venv`).
- Necesita al menos 3 pares validos tras limpiar None/NaN; si no, devuelve 0.0.
- Si alguna de las dos series es constante (varianza cero), Spearman es
indefinido -> devuelve 0.0 en lugar de NaN.
- Solo detecta monotonicidad: una relacion en U (sube y luego baja) puede dar
~0 aunque exista dependencia. Para eso usa otra metrica.
@@ -0,0 +1,54 @@
"""Coeficiente de correlacion de Spearman (correlacion de rangos)."""
import math
from scipy.stats import spearmanr
def spearman_corr(xs: list, ys: list) -> float:
"""Coeficiente de correlacion de Spearman entre dos listas pareadas.
La correlacion de rangos capta relaciones monotonicas (no necesariamente
lineales) entre dos variables. Es robusta frente a outliers y a relaciones
curvas siempre que sean monotonas.
Descarta los pares en los que cualquiera de los dos valores sea None, NaN
o no numerico. Si tras la limpieza quedan menos de 3 pares validos, o la
varianza de alguna de las dos series es cero, devuelve 0.0.
Args:
xs: lista de valores numericos de la primera variable.
ys: lista de valores numericos de la segunda variable, pareada con xs.
Returns:
coeficiente de Spearman en rango [-1, 1] como float. Nunca None ni
excepcion: ante datos insuficientes o degenerados devuelve 0.0.
"""
def _is_num(v) -> bool:
return isinstance(v, (int, float)) and not isinstance(v, bool) and not (
isinstance(v, float) and math.isnan(v)
)
pairs = [
(float(x), float(y))
for x, y in zip(xs, ys)
if _is_num(x) and _is_num(y)
]
if len(pairs) < 3:
return 0.0
clean_x = [p[0] for p in pairs]
clean_y = [p[1] for p in pairs]
# Varianza cero en cualquiera de las series => correlacion indefinida.
if len(set(clean_x)) < 2 or len(set(clean_y)) < 2:
return 0.0
corr = spearmanr(clean_x, clean_y).statistic
if corr is None or math.isnan(float(corr)):
return 0.0
return float(corr)
@@ -0,0 +1,56 @@
"""Tests para spearman_corr."""
import math
from spearman_corr import spearman_corr
def test_relacion_monotonica_perfecta():
# ys = x**2 es monotonica creciente para x>0 (no lineal): Spearman ~ 1.0
xs = [1, 2, 3, 4, 5, 6]
ys = [x ** 2 for x in xs]
result = spearman_corr(xs, ys)
assert math.isclose(result, 1.0, abs_tol=1e-9)
def test_relacion_monotonica_decreciente():
xs = [1, 2, 3, 4, 5]
ys = [10, 8, 6, 4, 2]
result = spearman_corr(xs, ys)
assert math.isclose(result, -1.0, abs_tol=1e-9)
def test_pares_con_none_se_ignoran():
# Los pares (3, None) y (None, 99) se descartan; el resto es monotonico perfecto.
xs = [1, 2, 3, 4, None, 5]
ys = [1, 4, None, 16, 99, 25]
result = spearman_corr(xs, ys)
assert math.isclose(result, 1.0, abs_tol=1e-9)
def test_pares_con_nan_se_ignoran():
xs = [1, 2, float("nan"), 4, 5]
ys = [2, 4, 100, 8, 10]
result = spearman_corr(xs, ys)
assert math.isclose(result, 1.0, abs_tol=1e-9)
def test_menos_de_3_pares_validos_retorna_cero():
xs = [1, 2, None]
ys = [5, 9, 3]
assert spearman_corr(xs, ys) == 0.0
def test_varianza_cero_retorna_cero():
xs = [7, 7, 7, 7]
ys = [1, 2, 3, 4]
assert spearman_corr(xs, ys) == 0.0
def test_listas_vacias_retorna_cero():
assert spearman_corr([], []) == 0.0
def test_resultado_es_float():
result = spearman_corr([1, 2, 3, 4], [4, 3, 2, 1])
assert isinstance(result, float)
@@ -0,0 +1,73 @@
---
id: summarize_categorical_py_datascience
name: summarize_categorical
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def summarize_categorical(values: list, top_k: int = 10) -> dict"
description: "Profiles a categorical/text column for EDA: top-k frequencies, mode, distinct count, Shannon entropy (bits), imbalance ratio and string-length stats. None is dropped; empty string counts as a value. Produces the `categorical_sub` block of an eda ColumnProfile."
tags: [eda, categorical, frequency, entropy, profiling, datascience, pure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [math, collections]
example: |
from summarize_categorical import summarize_categorical
result = summarize_categorical(["a", "a", "b", "c", "a", None, ""])
tested: true
tests:
- "test_summarize_categorical_repeated"
- "test_summarize_categorical_empty"
- "test_summarize_categorical_all_none"
- "test_summarize_categorical_single_value"
- "test_summarize_categorical_top_k"
- "test_summarize_categorical_keys"
test_file_path: "python/functions/datascience/summarize_categorical_test.py"
file_path: "python/functions/datascience/summarize_categorical.py"
params:
- name: values
desc: "List of categorical/text values. None entries are discarded from every computation; an empty string \"\" is kept as the empty-string category (counts and has length 0)."
- name: top_k
desc: "Maximum number of most-frequent values to include in the `top` list. Default 10. Does not affect n_distinct/entropy/imbalance."
output: "Dict with the exact keys top, mode, mode_pct, n_distinct, entropy, imbalance, len_mean, len_min, len_max. `top` is a list of {value, count, pct} sorted by count descending (pct over the non-null total). When there are no non-null values, top=[] and every other key is None. With a single distinct value, entropy=0.0 and imbalance=1.0."
---
## Ejemplo
```python
from summarize_categorical import summarize_categorical
summarize_categorical(["a", "a", "b", "c", "a", None, ""])
# {
# "top": [
# {"value": "a", "count": 3, "pct": 0.5},
# {"value": "b", "count": 1, "pct": 0.1666...},
# {"value": "c", "count": 1, "pct": 0.1666...},
# {"value": "", "count": 1, "pct": 0.1666...},
# ],
# "mode": "a", "mode_pct": 0.5,
# "n_distinct": 4,
# "entropy": 1.79..., # Shannon entropy in bits
# "imbalance": 3.0, # max_count(3) / min_count(1)
# "len_mean": 0.833..., "len_min": 0, "len_max": 1,
# }
```
## Cuando usarla
Úsala al perfilar una columna categórica o de texto en un EDA: cuando necesites
el bloque `categorical` de un ColumnProfile del grupo `eda` (top valores, moda,
cardinalidad, entropía/desbalanceo de la distribución y estadísticas de longitud
de los strings). Pásale la lista de valores crudos de la columna; `None` se
ignora automáticamente.
## Notas
Función pura, solo stdlib (`collections.Counter` + `math.log2`). No usa numpy ni
pandas. La entropía es de Shannon en base 2 (bits): 0.0 con un único valor
distinto, máxima cuando todos los valores son distintos. `imbalance` es
`max_count / min_count` sobre los valores distintos (1.0 si solo hay uno).
@@ -0,0 +1,87 @@
"""Pure EDA helper: categorical/text column profiling for the `eda` group.
Computes the ``categorical`` sub-block of a ColumnProfile from a list of
categorical or text values. No external dependencies (stdlib only).
"""
import math
from collections import Counter
def summarize_categorical(values: list, top_k: int = 10) -> dict:
"""Summarize a list of categorical/text values into an EDA profile block.
``None`` entries are dropped from every computation. An empty string
(``""``) is treated as a regular value (it counts and has length 0).
Args:
values: List of categorical or text values. ``None`` is discarded;
``""`` is kept as the empty-string category.
top_k: Maximum number of most-frequent values to include in ``top``.
Returns:
Dict with the exact keys of the `eda` group ``categorical_sub``
contract: ``top``, ``mode``, ``mode_pct``, ``n_distinct``,
``entropy``, ``imbalance``, ``len_mean``, ``len_min``, ``len_max``.
``top`` is a list of ``{value, count, pct}`` sorted by ``count``
descending (``pct`` is over the non-null total). When there are no
non-null values, ``top`` is ``[]`` and every other key is ``None``.
"""
non_null = [v for v in values if v is not None]
total = len(non_null)
if total == 0:
return {
"top": [],
"mode": None,
"mode_pct": None,
"n_distinct": None,
"entropy": None,
"imbalance": None,
"len_mean": None,
"len_min": None,
"len_max": None,
}
counter = Counter(non_null)
# most_common is sorted by count descending (insertion order for ties).
ordered = counter.most_common()
top = [
{"value": value, "count": count, "pct": count / total}
for value, count in ordered[:top_k]
]
mode_value, mode_count = ordered[0]
n_distinct = len(counter)
# Shannon entropy (base 2) of the frequency distribution.
if n_distinct <= 1:
entropy = 0.0
else:
entropy = 0.0
for count in counter.values():
p = count / total
entropy -= p * math.log2(p)
counts = list(counter.values())
max_count = max(counts)
min_count = min(counts)
imbalance = 1.0 if n_distinct <= 1 else max_count / min_count
lengths = [len(str(v)) for v in non_null]
len_mean = sum(lengths) / total
len_min = min(lengths)
len_max = max(lengths)
return {
"top": top,
"mode": mode_value,
"mode_pct": mode_count / total,
"n_distinct": n_distinct,
"entropy": entropy,
"imbalance": imbalance,
"len_mean": len_mean,
"len_min": len_min,
"len_max": len_max,
}
@@ -0,0 +1,90 @@
"""Tests para summarize_categorical."""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from summarize_categorical import summarize_categorical
def test_summarize_categorical_repeated():
"""Lista con repetidos: top ordenado por count desc, mode/n_distinct/entropy."""
values = ["a", "a", "b", "c", "a", None, ""]
result = summarize_categorical(values)
# None descartado; total no-nulo = 6 (a,a,b,c,a,"").
assert [t["value"] for t in result["top"]] == ["a", "b", "c", ""]
assert result["top"][0]["count"] == 3
# top ordenado por count descendente.
counts = [t["count"] for t in result["top"]]
assert counts == sorted(counts, reverse=True)
assert abs(result["top"][0]["pct"] - 3 / 6) < 1e-12
assert result["mode"] == "a"
assert abs(result["mode_pct"] - 3 / 6) < 1e-12
assert result["n_distinct"] == 4
assert result["entropy"] > 0
assert result["imbalance"] == 3 / 1 # max_count(3) / min_count(1)
assert result["len_min"] == 0 # the "" value
assert result["len_max"] == 1
def test_summarize_categorical_empty():
"""Lista vacia: top=[] y resto de claves None."""
result = summarize_categorical([])
assert result["top"] == []
for key in (
"mode",
"mode_pct",
"n_distinct",
"entropy",
"imbalance",
"len_mean",
"len_min",
"len_max",
):
assert result[key] is None
def test_summarize_categorical_all_none():
"""Lista de solo None se trata como vacia."""
result = summarize_categorical([None, None, None])
assert result["top"] == []
assert result["n_distinct"] is None
assert result["entropy"] is None
def test_summarize_categorical_single_value():
"""Un solo valor distinto: entropy 0.0, imbalance 1.0."""
result = summarize_categorical(["x", "x", "x"])
assert result["n_distinct"] == 1
assert result["entropy"] == 0.0
assert result["imbalance"] == 1.0
assert result["mode"] == "x"
assert result["mode_pct"] == 1.0
assert result["len_mean"] == 1.0
def test_summarize_categorical_top_k():
"""top_k limita el numero de entradas en top sin alterar n_distinct."""
values = ["a", "a", "b", "b", "c", "d", "e"]
result = summarize_categorical(values, top_k=2)
assert len(result["top"]) == 2
assert result["n_distinct"] == 5
def test_summarize_categorical_keys():
"""El dict tiene exactamente las claves del contrato categorical_sub."""
result = summarize_categorical(["a", "b"])
assert set(result.keys()) == {
"top",
"mode",
"mode_pct",
"n_distinct",
"entropy",
"imbalance",
"len_mean",
"len_min",
"len_max",
}
@@ -0,0 +1,101 @@
---
name: summarize_table_duckdb
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def summarize_table_duckdb(db_path: str, table: str, high_card_ratio: float = 0.9) -> dict"
description: "Perfila una tabla DuckDB en una sola pasada SQL (SUMMARIZE, push-down sin traer filas a RAM) y devuelve el esqueleto de un TableProfile con el perfil base por columna. Corazon del grupo eda: base barata sobre la que otras funciones anaden lo estadistico fino (skew/kurtosis/histograma sobre muestra)."
tags: [eda, duckdb, profiling, datascience, exploratory-data-analysis, table-profile]
params:
- name: db_path
desc: "Ruta al archivo DuckDB. Debe existir (lectura read-only via duckdb_query_readonly; no se crea)."
- name: table
desc: "Nombre de la tabla a perfilar. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ y se cita en el SQL (SUMMARIZE no admite parametros posicionales para el identificador)."
- name: high_card_ratio
desc: "Umbral de unicidad (unique_pct, 0-1) a partir del cual una columna categorical recibe el flag high_cardinality. Default 0.9."
output: "dict dict-no-throw. En exito {status:'ok', profile: TableProfile} con perfil base por columna (n_rows/n_cols, type_breakdown, constant_cols, all_null_cols, null_cell_pct y columns[] de ColumnProfile). En error {status:'error', error:str}. Claves estadisticas finas (skew, kurtosis, histograma, percentiles finos, moda, outliers, correlaciones, key_candidates, quality_score) quedan en None/[] para que otras funciones del grupo eda las completen."
uses_functions: [duckdb_query_readonly_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_shape_y_metadatos_tabla", "test_column_profile_shape", "test_type_breakdown", "test_tabla_invalida_devuelve_error", "test_tabla_inexistente_devuelve_error", "test_distinct_no_excede_filas", "test_columna_unica_da_possible_id"]
test_file_path: "python/functions/datascience/summarize_table_duckdb_test.py"
file_path: "python/functions/datascience/summarize_table_duckdb.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience import summarize_table_duckdb
# Perfila la tabla `keywords` de una base DuckDB de SEO.
res = summarize_table_duckdb(
db_path=os.path.expanduser("~/.fn_seo/seo.duckdb"),
table="keywords",
high_card_ratio=0.9,
)
if res["status"] == "ok":
p = res["profile"]
print(f"{p['table']}: {p['n_rows']} filas x {p['n_cols']} cols")
print("type_breakdown:", p["type_breakdown"])
for col in p["columns"]:
print(col["name"], col["inferred_type"], "nulls=", col["null_pct"], col["flags"])
else:
print("error:", res["error"])
```
## Cuando usarla
- Cuando empieces a explorar una tabla DuckDB que no conoces y necesites el esqueleto barato de su perfil (tipos inferidos, nulos, cardinalidad, flags) **antes** de gastar en estadistica fina.
- Como primer paso del grupo `eda`: construye el TableProfile base que `describe_numeric` y otras funciones del grupo enriquecen luego sobre una muestra.
- Cuando quieras perfilar tablas grandes sin traer filas a RAM: `SUMMARIZE` hace push-down en el motor de DuckDB.
## Gotchas
- **Impura**: lee de disco via `duckdb_query_readonly` (modo read-only, no crea ni modifica la base). El `db_path` debe existir.
- **`distinct_count` exacto para tablas <=200k filas, aproximado+capado por encima**: `SUMMARIZE` usa HyperLogLog (`approx_unique`), que SOBREESTIMA y en tablas pequenas puede reportar mas distintos que filas (inflando `unique_pct` por encima de 1.0 y disparando flags `possible_id` falsos). Por eso, para `n_rows <= 200000` la funcion calcula `COUNT(DISTINCT)` EXACTO en una sola query combinada (barata) y usa ese valor. Para tablas mas grandes mantiene `approx_unique` pero lo CAPA a `n_rows` (`distinct_count = min(approx_unique, n_rows)`). En ambos casos `unique_pct = min(distinct_count / n_rows, 1.0)`, asi que `distinct_count` nunca supera las filas ni `unique_pct` pasa de 1.0. Los flags `possible_id` / `high_cardinality` derivan de ese `distinct_count` ya corregido (exacto y fiable por debajo de 200k filas; aproximado y conservador por encima).
- **`SUMMARIZE` NO da skew, kurtosis ni histograma**, ni percentiles finos (p1/p5/p95/p99), moda, outliers, correlaciones, key_candidates ni quality_score. Esas claves quedan en `None`/`[]` a proposito: las rellena otra funcion del grupo `eda` sobre una muestra. El sub-dict `numeric` solo trae min, max, mean, std, p25, p50, p75.
- **`SUMMARIZE.count` es el total de filas, no el no-nulo**: la funcion deriva el `count` no-nulo del ColumnProfile como `n_rows - null_count` (con `null_count` redondeado de `null_percentage`).
- **min/max/avg/std/q25/q50/q75 vienen como strings** desde DuckDB; se convierten a float (None si la columna no es numerica).
- **Requiere DuckDB 1.5.2** (columnas de `SUMMARIZE` validadas con esa version: column_name, column_type, min, max, approx_unique, avg, std, q25, q50, q75, count, null_percentage).
- **El identificador de tabla se interpola** (no parametrizable en `SUMMARIZE`): por eso se valida contra `^[A-Za-z_][A-Za-z0-9_]*$` antes de citarlo. Un nombre invalido (p.ej. con `;` o espacios) devuelve `{status:'error'}` sin tocar la base.
## Notas
Contrato compartido por todo el grupo `eda` (mantener estable):
```text
TableProfile = {
table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows,
duplicate_pct, constant_cols:[str], all_null_cols:[str], null_cell_pct,
type_breakdown:{numeric, categorical, datetime, text, boolean},
columns:[ColumnProfile], correlations, key_candidates:[str], quality_score,
llm, models
}
ColumnProfile = {
name, physical_type, inferred_type, semantic_type, count, n_rows, null_count,
null_pct, empty_count, empty_pct, distinct_count, unique_pct, flags:[str],
quality_score, numeric:<sub>|None, categorical:<sub>|None, datetime:<sub>|None
}
numeric_sub = {
min, max, mean, median, mode, std, variance, cv, p1, p5, p25, p50, p75, p95,
p99, iqr, skew, kurtosis, n_outliers, outlier_pct, zero_pct, negative_pct,
distribution_type, histogram
}
```
Mapeo de `column_type` fisico DuckDB a `inferred_type`: enteros/decimales/float
-> numeric; date/time/timestamp -> datetime; boolean -> boolean; varchar/text ->
categorical si `approx_unique <= 50` o `approx_unique/n_rows < 0.5`, si no text.
Flags por columna: `constant` (distinct_count<=1), `possible_id` (unique_pct>=0.99
y null_pct==0), `high_cardinality` (categorical con unique_pct>=high_card_ratio),
`mostly_null` (null_pct>0.5).
@@ -0,0 +1,296 @@
"""summarize_table_duckdb — perfil base de una tabla DuckDB en una sola pasada SQL.
Funcion impura: lee de disco a traves de DuckDB (via la primitiva read-only del
grupo `duckdb`, `duckdb_query_readonly`). Es el CORAZON del grupo de capacidad
`eda` (exploratory data analysis): construye el esqueleto de un TableProfile con
el perfil base por columna usando exclusivamente `SUMMARIZE`, que hace push-down
en el motor de DuckDB y NO trae filas a RAM.
Lo que NO calcula aqui (a proposito, para ser barata): skew, kurtosis, histograma,
percentiles finos (p1/p5/p95/p99), moda, outliers, correlaciones, key_candidates,
quality_score ni el semantic_type. Esas claves quedan en None / [] para que las
rellenen luego otras funciones del grupo `eda` (p.ej. describe_numeric) sobre una
muestra. El contrato de claves (TableProfile / ColumnProfile) es compartido por
todo el grupo `eda` y debe mantenerse estable.
Estilo dict-no-throw del grupo duckdb: nunca lanza; captura cualquier error y
devuelve {status:'error', error:str}.
"""
import re
from datetime import datetime, timezone
from infra import duckdb_query_readonly
# Identificador SQL valido. DuckDB SUMMARIZE no admite parametros posicionales
# para el nombre de la tabla, asi que hay que validar e interpolar citado.
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Umbral de filas por debajo del cual calculamos COUNT(DISTINCT) EXACTO en una
# sola query combinada (barato). Por encima usamos el approx_unique de SUMMARIZE
# (HyperLogLog), capado a n_rows para que distinct_count nunca exceda las filas.
_EXACT_DISTINCT_MAX_ROWS = 200_000
# Tipos fisicos DuckDB que mapean a "numeric".
_NUMERIC_TYPES = {
"TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT",
"UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "UHUGEINT",
"FLOAT", "REAL", "DOUBLE", "DECIMAL", "NUMERIC",
}
# Tipos fisicos DuckDB que mapean a "datetime".
_DATETIME_TYPES = {
"DATE", "TIME", "TIMESTAMP", "DATETIME",
"TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS", "TIMESTAMP_US",
"TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ", "TIMETZ",
}
# Claves del sub-dict numeric. summarize solo rellena unas pocas; el resto
# quedan en None hasta que una funcion de muestreo (describe_numeric) las complete.
_NUMERIC_SUB_KEYS = (
"min", "max", "mean", "median", "mode", "std", "variance", "cv",
"p1", "p5", "p25", "p50", "p75", "p95", "p99", "iqr",
"skew", "kurtosis", "n_outliers", "outlier_pct", "zero_pct",
"negative_pct", "distribution_type", "histogram",
)
def _base_physical_type(column_type: str) -> str:
"""Normaliza un column_type fisico de DuckDB a su forma base en mayusculas.
Quita los parametros (DECIMAL(10,2) -> DECIMAL) y los modificadores de array
(INTEGER[] -> INTEGER) para poder compararlo contra los conjuntos de tipos.
"""
t = (column_type or "").strip().upper()
# Quitar sufijo de array/lista (INTEGER[], VARCHAR[3], etc.).
t = re.sub(r"\[.*\]$", "", t).strip()
# Quitar parametros: DECIMAL(10,2) -> DECIMAL, VARCHAR(50) -> VARCHAR.
t = re.sub(r"\(.*\)$", "", t).strip()
return t
def _infer_type(column_type: str, distinct_count, n_rows: int) -> str:
"""Mapea el tipo fisico DuckDB al inferred_type del contrato.
numeric / datetime / boolean salen directos del tipo fisico. Para VARCHAR/TEXT
se decide entre categorical y text con una heuristica de cardinalidad:
categorical si distinct_count <= 50 o distinct_count/n_rows < 0.5; si no text.
"""
base = _base_physical_type(column_type)
if base in _NUMERIC_TYPES:
return "numeric"
if base in _DATETIME_TYPES:
return "datetime"
if base in ("BOOLEAN", "BOOL"):
return "boolean"
if base in ("VARCHAR", "TEXT", "STRING", "CHAR", "BPCHAR"):
au = distinct_count if distinct_count is not None else 0
if n_rows <= 0:
return "categorical"
if au <= 50 or (au / n_rows) < 0.5:
return "categorical"
return "text"
# Tipos complejos (STRUCT, MAP, LIST, BLOB, UUID, ...): tratamos como text.
return "text"
def _to_float(value):
"""Convierte a float un valor que SUMMARIZE devuelve como string/Decimal.
SUMMARIZE entrega min/max/avg/std/q25/q50/q75 como cadenas (o None). Para
columnas no numericas (o fechas) la conversion fallara y devolvemos None.
"""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def summarize_table_duckdb(
db_path: str, table: str, high_card_ratio: float = 0.9
) -> dict:
"""Perfila una tabla DuckDB en una sola pasada SQL (push-down, sin traer filas).
Args:
db_path: ruta al archivo DuckDB. Debe existir (lectura read-only, no se crea).
table: nombre de la tabla a perfilar. Se valida contra
^[A-Za-z_][A-Za-z0-9_]*$ y se cita en el SQL (SUMMARIZE no admite
parametros posicionales para el identificador).
high_card_ratio: umbral de unicidad (unique_pct) a partir del cual una
columna categorical se marca con el flag "high_cardinality". Default 0.9.
Returns:
dict. En exito: {status:'ok', profile: <TableProfile>}. En error (sin
lanzar): {status:'error', error:str}.
"""
try:
if not _IDENT_RE.match(table or ""):
return {
"status": "error",
"error": (
f"nombre de tabla invalido: {table!r} "
"(debe casar con ^[A-Za-z_][A-Za-z0-9_]*$)"
),
}
quoted = f'"{table}"'
# 1) Numero total de filas.
count_res = duckdb_query_readonly(db_path, f"SELECT count(*) AS n FROM {quoted}")
if count_res["status"] != "ok":
return {"status": "error", "error": count_res["error"]}
n_rows = int(count_res["rows"][0]["n"]) if count_res["rows"] else 0
# 2) SUMMARIZE: perfil base por columna en el motor.
summ_res = duckdb_query_readonly(db_path, f"SUMMARIZE {quoted}")
if summ_res["status"] != "ok":
return {"status": "error", "error": summ_res["error"]}
# 3) distinct_count EXACTO para tablas pequenas/medianas. SUMMARIZE usa
# approx_unique (HyperLogLog), que SOBREESTIMA: en tablas pequenas puede
# reportar mas distintos que filas, inflando unique_pct por encima de 1.0
# y disparando flags possible_id falsos. Para n_rows <= umbral calculamos
# COUNT(DISTINCT) EXACTO en UNA sola query combinada (barato). Por encima
# del umbral nos quedamos con approx_unique, pero capado a n_rows en
# _build_column_profile. Mapea column_name -> distinct exacto.
exact_distinct = {}
col_names = [r.get("column_name") for r in summ_res["rows"]]
if n_rows > 0 and n_rows <= _EXACT_DISTINCT_MAX_ROWS and col_names:
select_parts = [
f'count(DISTINCT "{name}") AS c{i}'
for i, name in enumerate(col_names)
]
distinct_sql = f"SELECT {', '.join(select_parts)} FROM {quoted}"
distinct_res = duckdb_query_readonly(db_path, distinct_sql)
if distinct_res["status"] != "ok":
return {"status": "error", "error": distinct_res["error"]}
if distinct_res["rows"]:
drow = distinct_res["rows"][0]
for i, name in enumerate(col_names):
val = drow.get(f"c{i}")
if val is not None:
exact_distinct[name] = int(val)
columns = []
for row in summ_res["rows"]:
columns.append(
_build_column_profile(row, n_rows, high_card_ratio, exact_distinct)
)
type_breakdown = {
"numeric": 0,
"categorical": 0,
"datetime": 0,
"text": 0,
"boolean": 0,
}
for col in columns:
it = col["inferred_type"]
if it in type_breakdown:
type_breakdown[it] += 1
constant_cols = [c["name"] for c in columns if "constant" in c["flags"]]
all_null_cols = [c["name"] for c in columns if c["null_pct"] == 1.0]
null_cell_pct = (
sum(c["null_pct"] for c in columns) / len(columns) if columns else 0.0
)
profile = {
"table": table,
"source": "duckdb",
"profiled_at": datetime.now(timezone.utc).isoformat(),
"n_rows": n_rows,
"n_cols": len(columns),
"size_bytes": None,
"duplicate_rows": None,
"duplicate_pct": None,
"constant_cols": constant_cols,
"all_null_cols": all_null_cols,
"null_cell_pct": null_cell_pct,
"type_breakdown": type_breakdown,
"columns": columns,
"correlations": None,
"key_candidates": [],
"quality_score": None,
"llm": None,
"models": None,
}
return {"status": "ok", "profile": profile}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)}
def _build_column_profile(
row: dict, n_rows: int, high_card_ratio: float, exact_distinct: dict = None
) -> dict:
"""Convierte una fila de SUMMARIZE en un ColumnProfile del contrato eda.
distinct_count: si la columna tiene un valor en `exact_distinct` (tablas
pequenas/medianas perfiladas con COUNT(DISTINCT) exacto), se usa ese valor.
Si no (tablas grandes), se usa approx_unique de SUMMARIZE CAPADO a n_rows
para que nunca supere el numero de filas. unique_pct queda limitado a 1.0.
"""
name = row.get("column_name")
physical_type = row.get("column_type")
approx_unique = row.get("approx_unique")
# null_percentage viene en escala 0-100 (Decimal). Lo pasamos a fraccion 0-1.
null_pct_raw = row.get("null_percentage")
null_pct = float(null_pct_raw) / 100.0 if null_pct_raw is not None else 0.0
# distinct_count corregido (exacto si disponible; si no approx capado a n_rows).
exact_distinct = exact_distinct or {}
if name in exact_distinct:
distinct_count = exact_distinct[name]
else:
approx = int(approx_unique) if approx_unique is not None else 0
distinct_count = min(approx, n_rows) if n_rows > 0 else approx
# Inferencia categorical/text con la cardinalidad ya corregida.
inferred_type = _infer_type(physical_type, distinct_count, n_rows)
null_count = round(null_pct * n_rows)
non_null_count = n_rows - null_count # SUMMARIZE.count es el total, no el no-nulo.
unique_pct = min(distinct_count / n_rows, 1.0) if n_rows > 0 else 0.0
numeric = None
if inferred_type == "numeric":
numeric = {k: None for k in _NUMERIC_SUB_KEYS}
numeric["min"] = _to_float(row.get("min"))
numeric["max"] = _to_float(row.get("max"))
numeric["mean"] = _to_float(row.get("avg"))
numeric["std"] = _to_float(row.get("std"))
numeric["p25"] = _to_float(row.get("q25"))
numeric["p50"] = _to_float(row.get("q50"))
numeric["p75"] = _to_float(row.get("q75"))
flags = []
if distinct_count <= 1:
flags.append("constant")
if unique_pct >= 0.99 and null_pct == 0:
flags.append("possible_id")
if inferred_type == "categorical" and unique_pct >= high_card_ratio:
flags.append("high_cardinality")
if null_pct > 0.5:
flags.append("mostly_null")
return {
"name": name,
"physical_type": physical_type,
"inferred_type": inferred_type,
"semantic_type": "",
"count": non_null_count,
"n_rows": n_rows,
"null_count": null_count,
"null_pct": null_pct,
"empty_count": None,
"empty_pct": None,
"distinct_count": distinct_count,
"unique_pct": unique_pct,
"flags": flags,
"quality_score": None,
"numeric": numeric,
"categorical": None,
"datetime": None,
}
@@ -0,0 +1,150 @@
"""Tests para summarize_table_duckdb."""
import duckdb
import pytest
from .summarize_table_duckdb import summarize_table_duckdb
@pytest.fixture
def db(tmp_path):
"""Crea una DuckDB temporal con numerica + categorica + nulls + id unico."""
path = str(tmp_path / "eda_test.duckdb")
con = duckdb.connect(path)
con.execute(
"CREATE TABLE ventas ("
" id INTEGER," # unico, sin nulls -> possible_id
" region VARCHAR," # categorica baja cardinalidad
" total DOUBLE," # numerica con un null
" pais VARCHAR" # constante
")"
)
con.execute(
"INSERT INTO ventas VALUES "
"(1, 'norte', 120.5, 'ES'), "
"(2, 'sur', 80.0, 'ES'), "
"(3, 'norte', NULL, 'ES'), "
"(4, 'este', 45.25, 'ES')"
)
con.close()
return path
def test_shape_y_metadatos_tabla(db):
res = summarize_table_duckdb(db, "ventas")
assert res["status"] == "ok"
profile = res["profile"]
# Claves del TableProfile presentes.
for key in (
"table", "source", "profiled_at", "n_rows", "n_cols", "size_bytes",
"duplicate_rows", "duplicate_pct", "constant_cols", "all_null_cols",
"null_cell_pct", "type_breakdown", "columns", "correlations",
"key_candidates", "quality_score", "llm", "models",
):
assert key in profile, f"falta clave {key} en TableProfile"
assert profile["table"] == "ventas"
assert profile["source"] == "duckdb"
assert profile["n_rows"] == 4
assert profile["n_cols"] == 4
assert len(profile["columns"]) == 4
assert profile["key_candidates"] == []
assert profile["quality_score"] is None
assert profile["correlations"] is None
def test_column_profile_shape(db):
profile = summarize_table_duckdb(db, "ventas")["profile"]
by_name = {c["name"]: c for c in profile["columns"]}
for col in profile["columns"]:
for key in (
"name", "physical_type", "inferred_type", "semantic_type", "count",
"n_rows", "null_count", "null_pct", "empty_count", "empty_pct",
"distinct_count", "unique_pct", "flags", "quality_score",
"numeric", "categorical", "datetime",
):
assert key in col, f"falta clave {key} en ColumnProfile {col['name']}"
# id: numerica, sin nulls, unica.
assert by_name["id"]["inferred_type"] == "numeric"
assert by_name["id"]["null_count"] == 0
assert by_name["id"]["count"] == 4
assert by_name["id"]["distinct_count"] == 4
assert "possible_id" in by_name["id"]["flags"]
# region: categorica baja cardinalidad.
assert by_name["region"]["inferred_type"] == "categorical"
assert by_name["region"]["distinct_count"] == 3
# total: numerica con un null. count no-nulo = 3.
total = by_name["total"]
assert total["inferred_type"] == "numeric"
assert total["null_count"] == 1
assert total["count"] == 3
assert total["numeric"] is not None
# SUMMARIZE rellena min/max/mean/std/p25/p50/p75; el resto queda en None.
assert total["numeric"]["min"] == pytest.approx(45.25)
assert total["numeric"]["max"] == pytest.approx(120.5)
assert total["numeric"]["mean"] is not None
assert total["numeric"]["skew"] is None
assert total["numeric"]["histogram"] is None
assert total["numeric"]["p99"] is None
# pais: constante -> flag constant + aparece en constant_cols.
assert "constant" in by_name["pais"]["flags"]
assert "pais" in profile["constant_cols"]
def test_distinct_no_excede_filas(db):
"""distinct_count exacto: nunca supera n_rows ni unique_pct pasa de 1.0.
Regresion: SUMMARIZE.approx_unique (HyperLogLog) sobreestimaba y reportaba
mas distintos que filas en tablas pequenas, inflando unique_pct > 1.0 y
disparando flags possible_id falsos.
"""
profile = summarize_table_duckdb(db, "ventas")["profile"]
n_rows = profile["n_rows"]
for col in profile["columns"]:
assert col["distinct_count"] <= n_rows, (
f"{col['name']}: distinct_count {col['distinct_count']} > n_rows {n_rows}"
)
assert col["unique_pct"] <= 1.0, (
f"{col['name']}: unique_pct {col['unique_pct']} > 1.0"
)
def test_columna_unica_da_possible_id(db):
"""Una columna con todos los valores unicos -> unique_pct == 1.0 + possible_id."""
profile = summarize_table_duckdb(db, "ventas")["profile"]
by_name = {c["name"]: c for c in profile["columns"]}
# id: 4 valores distintos sobre 4 filas, sin nulls.
idc = by_name["id"]
assert idc["distinct_count"] == 4
assert idc["unique_pct"] == 1.0
assert "possible_id" in idc["flags"]
def test_type_breakdown(db):
profile = summarize_table_duckdb(db, "ventas")["profile"]
tb = profile["type_breakdown"]
assert set(tb.keys()) == {
"numeric", "categorical", "datetime", "text", "boolean"
}
assert tb["numeric"] == 2 # id, total
assert tb["categorical"] == 2 # region, pais
assert tb["datetime"] == 0
assert tb["boolean"] == 0
def test_tabla_invalida_devuelve_error(db):
res = summarize_table_duckdb(db, "ventas; DROP TABLE ventas")
assert res["status"] == "error"
assert "invalido" in res["error"]
def test_tabla_inexistente_devuelve_error(db):
res = summarize_table_duckdb(db, "no_existe")
assert res["status"] == "error"
+102
View File
@@ -0,0 +1,102 @@
---
id: theils_u_py_datascience
name: theils_u
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def theils_u(a: list, b: list) -> float"
description: "Theil's U (coeficiente de incertidumbre) DIRECCIONAL entre dos columnas categoricas: U(a|b) = fraccion de la incertidumbre de `a` que se elimina conociendo `b`, en [0,1]. ASIMETRICO (theils_u(a,b) != theils_u(b,a)), a diferencia de Cramer's V, lo que permite detectar dependencias direccionales (p.ej. ciudad->pais). Funcion pura."
tags: [eda, correlation, association, categorical, entropy, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from datascience import theils_u
ciudad = ["Madrid", "Madrid", "Paris", "Paris", "Roma", "Roma"]
pais = ["ES", "ES", "FR", "FR", "IT", "IT"]
theils_u(ciudad, pais) # ~1.0 — saber el pais determina mucho la ciudad? no.
theils_u(pais, ciudad) # 1.0 — la ciudad determina el pais (N:1)
tested: true
tests:
- "test_b_determines_a_gives_one"
- "test_asymmetry_n_to_one_relation"
- "test_independence_gives_zero"
- "test_fewer_than_two_pairs_returns_zero"
- "test_constant_a_returns_zero"
- "test_none_pairs_discarded"
- "test_result_in_unit_interval"
test_file_path: "python/functions/datascience/theils_u_test.py"
file_path: "python/functions/datascience/theils_u.py"
params:
- name: a
desc: >
Columna categorica objetivo, cuya incertidumbre se mide. Lista de valores
(str, int, etc.). Se empareja por indice con `b`. Los pares en los que `a`
o `b` sean None se descartan antes de calcular.
- name: b
desc: >
Columna categorica condicionante: el conocimiento cuyo poder explicativo
sobre `a` se evalua. Misma longitud y emparejamiento por indice que `a`.
output: >
Theil's U(a|b) como float en [0.0, 1.0]. 1.0 = conocer `b` determina `a` por
completo; 0.0 = `b` no aporta informacion sobre `a` (independencia). Devuelve
0.0 (nunca None ni excepcion) si hay menos de 2 pares validos o si `a` es
constante (H(a)==0). El valor es DIRECCIONAL: U(a|b) generalmente difiere de
U(b|a).
---
## Ejemplo
```python
from datascience import theils_u
# Relacion N:1 — varias ciudades por pais. La ciudad determina el pais,
# pero el pais NO determina la ciudad.
ciudad = ["Madrid", "Barcelona", "Paris", "Lyon", "Roma", "Milan"]
pais = ["ES", "ES", "FR", "FR", "IT", "IT"]
# Conocer la ciudad elimina TODA la incertidumbre del pais (cada ciudad
# pertenece a un unico pais) -> ~1.0
theils_u(pais, ciudad) # 1.0 (U(pais | ciudad))
# Conocer el pais solo reduce parte de la incertidumbre de la ciudad
# (cada pais tiene 2 ciudades posibles) -> < 1.0
theils_u(ciudad, pais) # ~0.5 (U(ciudad | pais))
# La ASIMETRIA es la gracia: theils_u(a, b) != theils_u(b, a).
# Una medida simetrica como Cramer's V daria el mismo numero en ambos sentidos
# y ocultaria la direccion de la dependencia.
```
## Cuando usarla
Cuando exploras un dataset (grupo `eda`) y quieres detectar **dependencias
direccionales** entre columnas categoricas: que columna determina a otra, no solo
si estan asociadas. Casos tipicos: jerarquias geograficas (ciudad -> pais,
codigo_postal -> provincia), claves derivadas (sku -> categoria), o cualquier
relacion N:1 donde te interesa saber el sentido. Usa Theil's U en vez de
Cramer's V precisamente cuando la simetria de Cramer's V te impediria ver que
`a` explica a `b` pero no al reves. Tambien sirve para construir una matriz de
asociacion asimetrica que revele la estructura causal/jerarquica candidata antes
de modelar.
## Gotchas
Funcion pura, sin I/O ni dependencias externas (solo `math` y `collections`).
Notas de uso:
- Es **direccional**: `theils_u(a, b)` mide U(a|b) (incertidumbre de `a`
explicada por `b`). No asumas simetria.
- Pensada para columnas **categoricas**. Si pasas numerica continua de alta
cardinalidad, cada valor sera casi unico y el resultado tendera a inflar la
asociacion (cuidado al interpretar).
- Empareja por indice y **descarta** pares con algun None; con menos de 2 pares
validos devuelve 0.0.
- Si `a` es constante (H(a)==0), devuelve 0.0: no hay incertidumbre que eliminar.
- El resultado se clampa a [0, 1] para absorber error de coma flotante; nunca
lanza excepcion ni devuelve None.
+88
View File
@@ -0,0 +1,88 @@
"""Theil's U (uncertainty coefficient) direccional entre dos columnas categoricas.
U(a|b) mide cuanta incertidumbre de `a` se elimina conociendo `b`, normalizado a
[0, 1]. Es ASIMETRICO: theils_u(a, b) != theils_u(b, a) en general, lo que lo
distingue de medidas simetricas como Cramer's V y permite detectar dependencias
direccionales (p.ej. ciudad -> pais).
"""
import math
from collections import Counter
def _entropy(counts: list) -> float:
"""Entropia de Shannon (base natural) de una lista de conteos.
Args:
counts: conteos por categoria (enteros >= 0).
Returns:
entropia en nats; 0.0 si no hay observaciones.
"""
total = sum(counts)
if total == 0:
return 0.0
h = 0.0
for c in counts:
if c > 0:
p = c / total
h -= p * math.log(p)
return h
def theils_u(a: list, b: list) -> float:
"""Theil's U direccional U(a|b): incertidumbre de `a` explicada por `b`.
Calcula la fraccion de la entropia de la distribucion marginal de `a` que se
elimina al condicionar sobre los valores de `b`. Es una medida de asociacion
ASIMETRICA en [0, 1]:
- U(a|b) = 1.0 -> conocer `b` determina por completo `a`.
- U(a|b) = 0.0 -> `b` no aporta nada sobre `a` (independencia).
Las entropias usan la misma base (logaritmo natural), por lo que la base se
cancela en el cociente y el resultado es independiente de ella.
Args:
a: columna categorica objetivo (cuya incertidumbre se mide).
b: columna categorica condicionante (el conocimiento que se aporta).
Ambas listas se emparejan por indice; los pares con algun None se
descartan antes de calcular.
Returns:
Theil's U(a|b) como float en [0.0, 1.0]. Devuelve 0.0 (nunca None ni
excepcion) si hay menos de 2 pares validos o si H(a) == 0 (es decir, `a`
ya es constante y no hay incertidumbre que eliminar).
"""
# Empareja por indice y descarta pares con algun None.
pairs = [
(av, bv)
for av, bv in zip(a, b)
if av is not None and bv is not None
]
if len(pairs) < 2:
return 0.0
# H(a): entropia de la distribucion marginal de a.
a_counts = Counter(av for av, _ in pairs)
h_a = _entropy(list(a_counts.values()))
if h_a == 0.0:
return 0.0
# H(a|b) = suma_b p(b) * H(a | b=valor).
by_b: dict = {}
for av, bv in pairs:
by_b.setdefault(bv, Counter())[av] += 1
total = len(pairs)
h_a_given_b = 0.0
for bv, a_sub in by_b.items():
p_b = sum(a_sub.values()) / total
h_a_given_b += p_b * _entropy(list(a_sub.values()))
u = (h_a - h_a_given_b) / h_a
# Clampa a [0, 1] para absorber errores de redondeo en coma flotante.
if u < 0.0:
return 0.0
if u > 1.0:
return 1.0
return u
@@ -0,0 +1,74 @@
"""Tests para theils_u."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from theils_u import theils_u
def test_b_determines_a_gives_one():
"""Si b determina por completo a, U(a|b) debe ser ~1.0."""
# Cada valor de b mapea a un unico valor de a (relacion funcional b -> a).
a = ["x", "x", "y", "y", "z", "z"]
b = ["1", "1", "2", "2", "3", "3"]
u = theils_u(a, b)
assert abs(u - 1.0) < 1e-9
def test_asymmetry_n_to_one_relation():
"""Relacion N:1: la ciudad determina el pais pero no al reves.
theils_u(a, b) != theils_u(b, a) cuando la relacion es N:1.
"""
ciudad = ["Madrid", "Barcelona", "Paris", "Lyon", "Roma", "Milan"]
pais = ["ES", "ES", "FR", "FR", "IT", "IT"]
# Conocer la ciudad elimina toda la incertidumbre del pais.
u_pais_given_ciudad = theils_u(pais, ciudad)
# Conocer el pais solo reduce parcialmente la incertidumbre de la ciudad.
u_ciudad_given_pais = theils_u(ciudad, pais)
assert abs(u_pais_given_ciudad - 1.0) < 1e-9
assert u_ciudad_given_pais < 1.0
# Asimetria explicita.
assert u_pais_given_ciudad != u_ciudad_given_pais
def test_independence_gives_zero():
"""Si a y b son independientes, U(a|b) debe ser ~0.0."""
# a alterna x/y; b alterna 1/2 de forma cruzada -> b no informa sobre a.
a = ["x", "y", "x", "y", "x", "y", "x", "y"]
b = ["1", "1", "2", "2", "1", "1", "2", "2"]
u = theils_u(a, b)
assert abs(u) < 1e-9
def test_fewer_than_two_pairs_returns_zero():
"""Con menos de 2 pares validos devuelve 0.0, no None ni excepcion."""
assert theils_u([], []) == 0.0
assert theils_u(["x"], ["1"]) == 0.0
def test_constant_a_returns_zero():
"""Si a es constante (H(a)==0) no hay incertidumbre que eliminar -> 0.0."""
a = ["x", "x", "x", "x"]
b = ["1", "2", "3", "4"]
assert theils_u(a, b) == 0.0
def test_none_pairs_discarded():
"""Los pares con algun None se descartan antes de calcular."""
a = ["x", "x", "y", "y", None, "z"]
b = ["1", "1", "2", "2", "3", None]
# Tras descartar los pares con None quedan 4 pares con b->a funcional.
u = theils_u(a, b)
assert abs(u - 1.0) < 1e-9
def test_result_in_unit_interval():
"""El resultado siempre cae en [0.0, 1.0]."""
a = ["x", "y", "x", "z", "y", "z", "x", "y"]
b = ["1", "2", "1", "3", "2", "3", "2", "1"]
u = theils_u(a, b)
assert 0.0 <= u <= 1.0
@@ -0,0 +1,98 @@
---
id: trend_slope_py_datascience
name: trend_slope
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def trend_slope(values: list, x: list = None) -> dict"
description: "Detecta la tendencia (sube/baja/plana) de una serie via regresion lineal simple del grupo eda y su significancia estadistica. Devuelve slope, r, r_squared, p_value, direction y significant. Descarta pares con None/NaN. Funcion pura, determinista, no muta el input."
tags: [eda, models, trend, regression, timeseries, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["scipy"]
example: |
from datascience import trend_slope
trend_slope([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
# {"slope": 1.0, "intercept": 1.0, "r": 1.0, "r_squared": 1.0,
# "p_value": 0.0, "std_err": 0.0, "direction": "up",
# "significant": True, "n": 10}
tested: true
tests:
- "test_increasing_series_slope_positive_up_significant"
- "test_decreasing_series_slope_negative_down_significant"
- "test_flat_constant_series_not_significant"
- "test_random_series_flat_not_significant"
- "test_custom_x_axis"
- "test_too_few_pairs_returns_none_slope"
- "test_drops_none_and_nan_pairs"
- "test_too_few_valid_pairs_after_dropping"
test_file_path: "python/functions/datascience/trend_slope_test.py"
file_path: "python/functions/datascience/trend_slope.py"
params:
- name: values
desc: >
Serie de valores numericos (variable dependiente, eje Y). Acepta huecos:
los elementos None o NaN se descartan, emparejados con su x correspondiente,
antes del ajuste.
- name: x
desc: >
Posiciones de cada valor (variable independiente, eje X). Si es None se
usa el indice posicional 0..n-1. Cuando se proporciona debe tener la misma
longitud que values; los pares con x None/NaN tambien se descartan.
output: >
dict con slope (float|None), intercept (float), r (float), r_squared (float),
p_value (float), std_err (float), direction ("up"|"down"|"flat"|"unknown"),
significant (bool, True si p_value<0.05) y n (int, pares validos usados). Con
menos de 3 pares validos devuelve {slope:None, direction:"unknown",
significant:False, n:<n>}.
---
## Ejemplo
```python
from datascience import trend_slope
# Serie creciente: tendencia al alza, significativa.
trend_slope([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
# {
# "slope": 1.0, "intercept": 1.0, "r": 1.0, "r_squared": 1.0,
# "p_value": 0.0, "std_err": 0.0,
# "direction": "up", "significant": True, "n": 10,
# }
# Serie plana (constante): no hay tendencia significativa.
trend_slope([5.0] * 12)
# {... "slope": 0.0, "direction": "flat", "significant": False, "n": 12}
# Eje x explicito (no equiespaciado) y serie con hueco.
trend_slope([1, None, 3, float("nan"), 5], x=[0, 1, 2, 3, 4])
# {... "direction": "up", "significant": True, "n": 3}
# Menos de 3 pares validos -> sin ajuste.
trend_slope([1, 2])
# {"slope": None, "direction": "unknown", "significant": False, "n": 2}
```
## Cuando usarla
Cuando tengas una serie (ventas por dia, precio en el tiempo, una metrica del
grupo `eda`) y necesites saber rapido si sube, baja o esta plana, y si ese
movimiento es estadisticamente real o ruido. Util para semaforos de tendencia
en un dashboard, alertas ("esta metrica cae de forma significativa"), o como
feature barata antes de un modelo mas caro. Pasa `x` cuando los puntos no estan
equiespaciados (fechas con huecos); deja `x=None` para tratar la serie como
secuencia ordenada.
## Gotchas
Funcion pura sin I/O, pero depende de `scipy.stats.linregress`. La direccion
solo es `"up"`/`"down"` cuando ademas hay significancia (`p_value < 0.05`); una
pendiente no nula pero ruidosa se reporta como `"flat"`. El umbral 0.05 es fijo
(no parametrizable, KISS). Con menos de 3 pares validos tras descartar None/NaN
no se ajusta nada y `slope` es `None` — comprobar ese caso antes de usar el
valor numerico. Series constantes dan `r_squared` 0 y `direction` `"flat"`.
@@ -0,0 +1,91 @@
"""Deteccion de tendencia en una serie via regresion lineal simple (grupo eda)."""
from __future__ import annotations
import math
from scipy.stats import linregress
def trend_slope(values: list, x: list = None) -> dict:
"""Detecta la tendencia (sube/baja/plana) de una serie y su significancia.
Ajusta una regresion lineal simple (minimos cuadrados) de ``values`` sobre
``x`` y resume el resultado en una direccion legible mas estadisticos. Si
``x`` es ``None`` se usa el indice posicional ``0..n-1``. Los pares cuyo
valor (en ``values`` o ``x``) sea ``None`` o ``NaN`` se descartan antes del
ajuste, de modo que series con huecos se manejan sin fallar.
Funcion pura y determinista: no hace I/O, no muta los inputs.
Args:
values: serie de valores numericos (la variable dependiente, eje Y).
x: posiciones de cada valor (la variable independiente, eje X). Si es
``None`` se usa ``range(len(values))``. Debe tener la misma longitud
que ``values`` cuando se proporciona.
Returns:
dict con la pendiente y el resumen de la tendencia:
- ``slope``: pendiente de la recta ajustada (float) o ``None`` si no
hay suficientes pares validos.
- ``intercept``: ordenada en el origen (float).
- ``r``: coeficiente de correlacion de Pearson (float).
- ``r_squared``: ``r**2``, fraccion de varianza explicada (float).
- ``p_value``: p-valor del test de pendiente nula (float).
- ``std_err``: error estandar de la pendiente (float).
- ``direction``: ``"up"`` (slope > 0 y significativa), ``"down"``
(slope < 0 y significativa), ``"flat"`` (no significativa) o
``"unknown"`` (menos de 3 pares validos).
- ``significant``: ``True`` si ``p_value < 0.05``.
- ``n``: numero de pares validos usados en el ajuste.
Con menos de 3 pares validos devuelve
``{"slope": None, "direction": "unknown", "significant": False,
"n": <n>}``.
"""
xs_raw = list(range(len(values))) if x is None else list(x)
ys_raw = list(values)
xs: list[float] = []
ys: list[float] = []
for xi, yi in zip(xs_raw, ys_raw):
if xi is None or yi is None:
continue
if isinstance(xi, float) and math.isnan(xi):
continue
if isinstance(yi, float) and math.isnan(yi):
continue
xs.append(float(xi))
ys.append(float(yi))
n = len(xs)
if n < 3:
return {"slope": None, "direction": "unknown", "significant": False, "n": n}
result = linregress(xs, ys)
slope = float(result.slope)
p_value = float(result.pvalue)
r = float(result.rvalue)
significant = p_value < 0.05
if not significant:
direction = "flat"
elif slope > 0:
direction = "up"
elif slope < 0:
direction = "down"
else:
direction = "flat"
return {
"slope": slope,
"intercept": float(result.intercept),
"r": r,
"r_squared": r * r,
"p_value": p_value,
"std_err": float(result.stderr),
"direction": direction,
"significant": significant,
"n": n,
}
@@ -0,0 +1,71 @@
"""Tests para trend_slope."""
import random
from trend_slope import trend_slope
def test_increasing_series_slope_positive_up_significant():
values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
result = trend_slope(values)
assert result["slope"] is not None
assert result["slope"] > 0
assert result["direction"] == "up"
assert result["significant"] is True
assert result["n"] == 10
def test_decreasing_series_slope_negative_down_significant():
values = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
result = trend_slope(values)
assert result["slope"] < 0
assert result["direction"] == "down"
assert result["significant"] is True
def test_flat_constant_series_not_significant():
values = [5.0] * 12
result = trend_slope(values)
assert result["direction"] == "flat"
assert result["significant"] is False
def test_random_series_flat_not_significant():
rng = random.Random(42)
values = [rng.gauss(0, 1) for _ in range(60)]
result = trend_slope(values)
assert result["direction"] == "flat"
assert result["significant"] is False
def test_custom_x_axis():
x = [0, 10, 20, 30, 40]
values = [1, 3, 5, 7, 9]
result = trend_slope(values, x)
assert result["slope"] > 0
assert result["direction"] == "up"
assert abs(result["r_squared"] - 1.0) < 1e-9
def test_too_few_pairs_returns_none_slope():
result = trend_slope([1, 2])
assert result["slope"] is None
assert result["direction"] == "unknown"
assert result["significant"] is False
assert result["n"] == 2
def test_drops_none_and_nan_pairs():
values = [1, None, 3, float("nan"), 5, 6, 7]
result = trend_slope(values)
assert result["n"] == 5
assert result["slope"] > 0
assert result["direction"] == "up"
def test_too_few_valid_pairs_after_dropping():
values = [1, None, None, float("nan"), 5]
result = trend_slope(values)
assert result["slope"] is None
assert result["direction"] == "unknown"
assert result["n"] == 2