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