--- 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