feat(eda): funciones de agregación/OLAP para AutomaticEDA (groupby/pivot push-down + selección LLM)

Cuatro funciones nuevas del grupo eda que nutren el capítulo AGREGACION:
- select_groupby_keys (pure): elige categóricas agrupables + numéricas medida desde el TableProfile.
- groupby_stats_duckdb (impure): GROUP BY push-down en DuckDB (count/mean/median/std/min/max por grupo).
- pivot_table_duckdb (impure): pivot A×B push-down, limitado a top filas/cols para no cortar.
- suggest_aggregations_llm (impure): el LLM elige las agregaciones interesantes con fallback determinista.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 15:33:55 +02:00
parent 415154d9a3
commit 96da9e3015
13 changed files with 2146 additions and 0 deletions
@@ -0,0 +1,158 @@
---
id: select_groupby_keys_py_datascience
name: select_groupby_keys
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def select_groupby_keys(profile: dict, max_keys: int = 3, max_card: int = 20, max_measures: int = 4) -> dict"
description: "Elige deterministicamente las columnas categoricas mas interesantes para GROUP BY, las numericas medida y pares pivote a partir de un TableProfile del grupo eda. Respaldo cuantitativo para el capitulo de agregacion/OLAP de un EDA. Funcion pura, no muta el input, nunca lanza."
tags: [eda, aggregation, groupby, olap, profiling, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
from datascience import select_groupby_keys
profile = {
"n_rows": 891,
"key_candidates": ["passenger_id"],
"columns": [
{"name": "sex", "inferred_type": "categorical", "distinct_count": 2,
"unique_pct": 0.002, "null_pct": 0.0, "flags": [],
"categorical": {"imbalance": 1.8}, "numeric": None},
{"name": "pclass", "inferred_type": "categorical", "distinct_count": 3,
"unique_pct": 0.003, "null_pct": 0.0, "flags": [],
"categorical": {"imbalance": 2.5}, "numeric": None},
{"name": "fare", "inferred_type": "numeric", "distinct_count": 200,
"unique_pct": 0.2, "null_pct": 0.0, "flags": [],
"numeric": {"std": 49.7, "cv": 1.54}, "categorical": None},
],
}
select_groupby_keys(profile)
# {"group_keys": [{"col": "sex", ...}, {"col": "pclass", ...}],
# "measures": ["fare"],
# "pivots": [{"index": "sex", "columns": "pclass", "value": "fare"}],
# "note": "2 clave(s) de grupo: sex, pclass; 1 medida(s): fare; 1 pivot(s)."}
tested: true
tests:
- "test_titanic_picks_good_cats_excludes_id_and_constant"
- "test_titanic_measures_exclude_id_constant_and_keep_numerics"
- "test_titanic_generates_one_pivot"
- "test_empty_profile_returns_all_empty_and_does_not_crash"
- "test_none_profile_does_not_crash"
- "test_only_numerics_yields_empty_group_keys_and_no_pivots"
- "test_high_cardinality_and_max_card_are_excluded"
- "test_max_keys_limits_group_keys"
- "test_three_keys_cap_pivots_to_two"
- "test_does_not_mutate_input"
test_file_path: "python/functions/datascience/select_groupby_keys_test.py"
file_path: "python/functions/datascience/select_groupby_keys.py"
params:
- name: profile
desc: >
TableProfile dict del grupo eda (p.ej. salida de summarize_table_duckdb).
Se lee de forma defensiva (.get / or [] / isinstance). Claves usadas:
columns (list[ColumnProfile]), key_candidates (list de nombres de columna
o dicts {name}), n_rows. Cada ColumnProfile usa: name, inferred_type
("numeric"|"categorical"|"datetime"|"text"|"boolean"), distinct_count,
unique_pct (0..1), null_pct (0..1), flags (list[str], reconoce
"possible_id"/"high_cardinality"/"constant"), numeric ({std, cv, ...}|None)
y categorical ({imbalance, mode_pct, ...}|None).
- name: max_keys
desc: "Numero maximo de claves de grupo (group_keys) a devolver. Default 3."
- name: max_card
desc: >
Cardinalidad maxima (distinct_count) que una columna categorica puede
tener para seguir siendo candidata a clave de grupo. Default 20.
- name: max_measures
desc: "Numero maximo de columnas medida (nombres) a devolver. Default 4."
output: >
dict con group_keys (list de {col, cardinality, score} ordenada por score
desc), measures (list[str] de nombres de columnas numericas ordenadas por
dispersion), pivots (list de {index, columns, value}, hasta 2 pares
categorica x categorica con la primera measure como valor) y note (str,
resumen corto en espanol de lo elegido). Ante profile vacio/None devuelve
todas las listas vacias y una note descriptiva; nunca lanza.
---
## Ejemplo
```python
from datascience import select_groupby_keys
# TableProfile estilo titanic: 2 categoricas buenas, 1 numerica medida,
# 1 id secuencial (descartado) y un key_candidate declarado.
profile = {
"n_rows": 891,
"key_candidates": ["passenger_id"],
"columns": [
{"name": "sex", "inferred_type": "categorical", "distinct_count": 2,
"unique_pct": 0.002, "null_pct": 0.0, "flags": [],
"categorical": {"imbalance": 1.8}, "numeric": None},
{"name": "pclass", "inferred_type": "categorical", "distinct_count": 3,
"unique_pct": 0.003, "null_pct": 0.0, "flags": [],
"categorical": {"imbalance": 2.5}, "numeric": None},
{"name": "fare", "inferred_type": "numeric", "distinct_count": 200,
"unique_pct": 0.2, "null_pct": 0.0, "flags": [],
"numeric": {"std": 49.7, "cv": 1.54}, "categorical": None},
{"name": "passenger_id", "inferred_type": "numeric", "distinct_count": 891,
"unique_pct": 1.0, "null_pct": 0.0, "flags": ["possible_id"],
"numeric": {"std": 257.4, "cv": 0.58}, "categorical": None},
],
}
select_groupby_keys(profile)
# {
# "group_keys": [
# {"col": "sex", "cardinality": 2, "score": 0.5556},
# {"col": "pclass", "cardinality": 3, "score": 0.4},
# ],
# "measures": ["fare"], # passenger_id excluido (id secuencial)
# "pivots": [{"index": "sex", "columns": "pclass", "value": "fare"}],
# "note": "2 clave(s) de grupo: sex, pclass; 1 medida(s): fare; 1 pivot(s).",
# }
```
## Cuando usarla
Cuando hayas perfilado una tabla con el grupo `eda` (p.ej.
`summarize_table_duckdb`) y necesites decidir, sin mirar los datos, por qué
columnas merece la pena agrupar (GROUP BY) y qué métricas numéricas agregar:
el respaldo cuantitativo del capítulo de agregación/OLAP de un AutomaticEDA, o
para proponer pivotes en un dashboard. Es la capa de selección sobre el
TableProfile crudo: lee el perfil, ordena candidatos de forma determinista y
no toca los datos.
## Notas
Función pura, sin I/O ni dependencias externas (solo stdlib), no muta
`profile`. Lectura defensiva total (`.get`, `or []`, `isinstance`): un `{}` o
`None` produce `{"group_keys": [], "measures": [], "pivots": [], "note": ...}`
y nunca lanza.
Criterios de selección (deterministas):
- **group_keys** — candidatas con `inferred_type` en `("categorical","boolean")`.
Se descartan las que estén en `key_candidates`, con flag
`possible_id`/`high_cardinality`/`constant`, con `distinct_count` fuera de
`[2, max_card]`, o all-null (`null_pct >= 0.999`). `score = card_score *
balance_score`: `card_score` mantiene un plateau para cardinalidad moderada
(2..12) y decae hacia `max_card`; `balance_score = 1/imbalance` usando
`categorical.imbalance` si está, aproximando con `mode_pct` si no, o un valor
neutro (0.5) en último caso. Devuelve hasta `max_keys`, ordenadas por score
desc (empates por orden de columna).
- **measures** — candidatas con `inferred_type` en
`("numeric","integer","float")`. Se descartan id-like (flag `possible_id` y
`unique_pct >= 0.99`) y constantes (`numeric.std` == 0 o None). Se rankean por
dispersión informativa: `abs(cv)` si está, si no `abs(std)`. Devuelve hasta
`max_measures` **nombres** (strings).
- **pivots** — hasta 2 pares `(group_keys[i].col, group_keys[j].col)` con i<j y
la primera measure como valor. Vacío si hay menos de 2 group_keys.
Caveat de ranking de measures: mezclar `cv` (adimensional) con `std` (en
unidades de la columna) cuando una columna carece de `cv` puede dar órdenes
poco comparables entre columnas; se prefiere `cv` siempre que esté disponible.