feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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">€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">€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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user