# Machine learning en producción: Optuna, FT-Transformer y CQL

> Tres técnicas avanzadas para llevar un modelo a producción: optimización bayesiana con poda, Transformers para datos tabulares y aprendizaje por refuerzo offline con CQL. Con su matemática, código que ejecuté de verdad y lo que cada una no resuelve.

- Fuente canónica: https://www.ricardovelit.com/blog/p/machine-learning-en-produccion
- Nivel: avanzado · Lectura: 17 min · Publicado: 2026-09-28
- Etiquetas: machine-learning, optuna, transformers, aprendizaje-por-refuerzo, mlops
- Conviene leer antes: [atencion-en-transformers](https://www.ricardovelit.com/blog/p/atencion-en-transformers.md)
- Relacionados: [que-son-los-embeddings](https://www.ricardovelit.com/blog/p/que-son-los-embeddings.md), [temperatura-y-muestreo](https://www.ricardovelit.com/blog/p/temperatura-y-muestreo.md), [agentes-de-inteligencia-artificial](https://www.ricardovelit.com/blog/p/agentes-de-inteligencia-artificial.md)
- Contiene componentes interactivos que solo funcionan en la versión web.

---
## Lo que separa un prototipo de un sistema

Un modelo que funciona en un notebook y uno que funciona en producción se parecen poco.
El segundo tiene que responder en milisegundos, aguantar clases muy desbalanceadas,
decidir sin poder experimentar con clientes reales y seguir sirviendo cuando los datos
cambian. Una búsqueda en rejilla sobre un gradient boosting sin ajustar no llega ahí.

Este artículo cubre tres técnicas que atacan tres de esos problemas:

- **Optimización bayesiana con poda** (TPE en Optuna): encontrar buenos hiperparámetros
  cuando cada evaluación cuesta minutos y el presupuesto de cómputo es finito.
- **Transformers para datos tabulares** (FT-Transformer): cuándo tiene sentido
  reemplazar los árboles por atención, y cuándo no.
- **Aprendizaje por refuerzo offline** (Conservative Q-Learning): aprender una política
  de decisión solo con el histórico, sin que el modelo se invente acciones milagrosas.

Cierro con despliegue: exportación a ONNX y monitoreo de drift.

Todo el código lo ejecuté, y las salidas están copiadas de la terminal. Versiones: Python
3.13, Optuna 5.0, CatBoost 1.2.10, PyTorch 2.14 y ONNX Runtime 1.30, en CPU.

## 1. Optimización bayesiana con TPE

La búsqueda en rejilla prueba todas las combinaciones y la aleatoria prueba puntos al
azar. Las dos ignoran lo que ya aprendieron: el trial número 40 se elige igual de a
ciegas que el primero. Cuando entrenar un modelo cuesta minutos y el espacio tiene seis o
siete dimensiones, eso sale caro.

La optimización bayesiana usa los resultados anteriores para elegir el siguiente punto.
Mantiene un **modelo sustituto** de la función objetivo $f(x)$ —la métrica de validación
en función de los hiperparámetros— y elige el siguiente $x$ donde ese modelo promete más.
El sustituto clásico es un proceso gaussiano, que modela $p(y \mid x)$ directamente, pero
su costo crece con el cubo del número de observaciones: $O(N^3)$.

### Cómo decide TPE

El *Tree-structured Parzen Estimator* (Bergstra et al., 2011) le da la vuelta al
problema. En lugar de modelar $p(y \mid x)$, modela $p(x \mid y)$. Ordena las
observaciones por su pérdida $y$, elige un umbral $y^*$ tal que una fracción $\gamma$
quede por debajo, y ajusta dos densidades:

$$
p(x \mid y) =
\begin{cases}
\ell(x) & \text{si } y \lt y^* \\
g(x) & \text{si } y \ge y^*
\end{cases}
$$

$\ell(x)$ es la densidad de los hiperparámetros que dieron los mejores resultados, y
$g(x)$ la del resto. Con eso, la mejora esperada (*Expected Improvement*) sobre el umbral
queda así:

$$
\mathrm{EI}_{y^*}(x)
= \int_{-\infty}^{y^*} (y^* - y)\, p(y \mid x)\, dy
= \frac{\gamma\, y^* \ell(x) - \ell(x) \int_{-\infty}^{y^*} y\, p(y)\, dy}{\gamma\, \ell(x) + (1 - \gamma)\, g(x)}
\;\propto\; \left( \gamma + \frac{g(x)}{\ell(x)}\,(1 - \gamma) \right)^{-1}
$$

La última expresión es la que importa: maximizar la mejora esperada equivale a maximizar
el cociente $\ell(x) / g(x)$. En la práctica, TPE saca muchos candidatos de $\ell(x)$ y se
queda con el que tiene el cociente más alto: un punto que se parece a los buenos y no se
parece a los malos. Como $\ell$ y $g$ son estimadores de densidad por kernels, evaluarlos
cuesta un tiempo lineal en el número de observaciones, y no cúbico como en un proceso
gaussiano.

Un matiz que cambia resultados: por defecto, TPE modela cada hiperparámetro por separado
y no ve interacciones, como que un `learning_rate` bajo necesite más árboles. Optuna tiene
`TPESampler(multivariate=True)` para modelarlas juntas.

### La poda es otra cosa

TPE decide **qué** probar. La poda decide **cuándo dejar de probar** algo que va mal. Son
mecanismos independientes, y en Optuna se configuran por separado: el *sampler* (TPE,
CMA-ES, un proceso gaussiano) y el *pruner* (mediana, Hyperband, *successive halving*).

El `MedianPruner` compara el valor intermedio de un trial en un paso con la mediana de los
trials anteriores en ese mismo paso. Si queda por debajo, lo corta. Para que funcione, el
trial tiene que poder informar valores intermedios comparables: por ejemplo, la métrica
después de cada fold de la validación cruzada.

## 2. Transformers para datos tabulares: FT-Transformer

En datos tabulares, los árboles con gradient boosting (XGBoost, LightGBM, CatBoost) siguen
siendo la opción por defecto, y con razón. La comparación más citada, de Grinsztajn y
colaboradores (2022), mostró que siguen ganando a las redes neuronales en la mayoría de
datasets medianos. El propio artículo del FT-Transformer (Gorishniy et al., 2021)
concluye que no hay un ganador universal.

Entonces, ¿para qué un Transformer? Para los casos donde la red neuronal aporta algo que
un árbol no tiene: cuando el modelo tabular tiene que entrenarse junto con otras partes
neuronales (texto, imágenes), cuando necesitas las [representaciones
vectoriales](/p/que-son-los-embeddings) de las filas para otra tarea, o cuando quieres
seguir entrenando con gradiente a medida que llegan datos.

### La arquitectura

La idea es convertir cada columna en un token. Para una columna numérica $x_j$, el
tokenizador aprende un vector de pesos y uno de sesgo propios de esa columna:

$$
\mathbf{e}_j = x_j \cdot \mathbf{w}_j + \mathbf{b}_j, \qquad \mathbf{w}_j, \mathbf{b}_j \in \mathbb{R}^d
$$

Las columnas categóricas pasan por una tabla de embeddings, también con un sesgo por
columna. A la secuencia de tokens se le añade un token especial `[CLS]`, y todo pasa por
bloques Transformer con normalización previa (*pre-norm*):

$$
\mathbf{Z}^{(0)} = \left[\, \mathbf{e}_{\text{CLS}};\ \mathbf{e}_1;\ \dots;\ \mathbf{e}_m \,\right]
$$

$$
\mathbf{Z}' = \mathbf{Z}^{(l-1)} + \mathrm{MHSA}\!\left(\mathrm{LayerNorm}\big(\mathbf{Z}^{(l-1)}\big)\right),
\qquad
\mathbf{Z}^{(l)} = \mathbf{Z}' + \mathrm{FFN}\!\left(\mathrm{LayerNorm}\big(\mathbf{Z}'\big)\right)
$$

La predicción sale del estado final del token `[CLS]`. Si vienes de
[cómo funciona la atención](/p/atencion-en-transformers), aquí la atención compara
columnas entre sí en lugar de palabras: cada columna decide qué otras columnas mirar para
esta fila en particular.

Un detalle que dice mucho del diseño: el FT-Transformer no usa codificación posicional. En
un texto, el orden de las palabras importa y hay que decírselo al modelo. En una tabla,
las columnas no tienen orden, y cada una ya lleva su identidad en su propio
$\mathbf{w}_j$. Un modelo de texto necesita saber dónde está cada palabra; este solo
necesita saber qué columna es cada token, y eso ya lo sabe.

## 3. Aprendizaje por refuerzo offline y Conservative Q-Learning

En finanzas, salud, recomendación o precios, aprender una política probando en vivo
(el refuerzo online, como PPO o DQN) es caro o peligroso: nadie quiere que un modelo
aprenda a fijar precios experimentando con clientes reales. El refuerzo **offline**
aprende solo con un histórico fijo de decisiones pasadas:

$$
\mathcal{D} = \{(s_t, a_t, r_t, s_{t+1})\}
$$

### El problema: acciones que nunca viste

El Q-learning estima $Q(s, a)$, el valor de tomar la acción $a$ en el estado $s$, y en
cada actualización usa $\max_{a'} Q(s', a')$. Ese máximo recorre **todas** las acciones,
también las que el histórico nunca probó. Para esas, la red no tiene datos: su valor es una
extrapolación. Y el máximo elige justamente la extrapolación más optimista, que se vuelve a
usar como objetivo en la siguiente actualización. El error no se corrige, se acumula.

Online, el agente probaría esa acción, vería que era mala y corregiría. Offline, no hay
forma de probarla.

### La solución de CQL

Conservative Q-Learning (Kumar et al., 2020) añade un término que empuja hacia abajo los
valores $Q$ de todas las acciones y hacia arriba los de las acciones que sí están en el
histórico:

$$
\min_Q \;
\alpha\, \mathbb{E}_{s \sim \mathcal{D}} \left[ \log \sum_{a} \exp Q(s, a) \;-\; \mathbb{E}_{a \sim \hat{\pi}_\beta(\cdot \mid s)} \big[ Q(s, a) \big] \right]
\;+\; \frac{1}{2}\, \mathbb{E}_{(s, a, s') \sim \mathcal{D}} \left[ \Big( Q(s, a) - \hat{\mathcal{B}}^{\pi} \hat{Q}^{k}(s, a) \Big)^2 \right]
$$

$\hat{\pi}_\beta$ es la política que generó el histórico, y $\hat{\mathcal{B}}^{\pi}\hat{Q}^{k}$
el objetivo de Bellman calculado con la red de la iteración anterior. El segundo término
es el Q-learning de siempre. El primero es el nuevo: el `log-sum-exp` es un máximo suave
sobre todas las acciones, y restarle el valor de las acciones del histórico penaliza a las
que la red valora alto sin evidencia.

El artículo original demuestra que, con $\alpha$ suficientemente grande y bajo sus
supuestos, el valor esperado de la política según la $Q$ aprendida es una **cota
inferior** de su valor real. Conviene leerlo con cuidado: con redes neuronales y datos
finitos esa garantía no se hereda tal cual. Lo que sí se hereda es el sesgo deliberado
hacia abajo, que en la práctica es lo que evita las decisiones temerarias. Y tiene un
costo: con $\alpha$ demasiado alto, la política se limita a copiar el histórico.

## Las librerías

| Librería | Para qué | Lo que aporta en producción | Cuándo usarla |
| --- | --- | --- | --- |
| Optuna | Búsqueda de hiperparámetros | Samplers (TPE, CMA-ES, GP) y pruners (mediana, Hyperband) separados; búsqueda distribuida con un almacenamiento compartido | Ajustar cualquier modelo con presupuesto de cómputo limitado |
| CatBoost / LightGBM | Gradient boosting | Categóricas nativas, inferencia rápida en C++ | La opción por defecto en datos tabulares |
| PyTorch | Redes neuronales y RL | Exportación a ONNX, CUDA, arquitecturas a medida | FT-Transformer, modelos multimodales, RL |
| d3rlpy | RL offline | Implementaciones en PyTorch de CQL (discreto y continuo), IQL, TD3+BC, BCQ, Cal-QL | Políticas de decisión aprendidas del histórico |

## Ejercicio 1: TPE con poda en CatBoost, con fraude desbalanceado

Un clasificador de fraude sobre 20.000 transacciones sintéticas, con un 3 % de positivos.
El 20 % se aparta como test antes de empezar y no se toca durante la búsqueda: es la única
cifra que no quedó optimizada a mano.

```python

from catboost import CatBoostClassifier
from sklearn.datasets import make_classification
from sklearn.metrics import average_precision_score, roc_auc_score
from sklearn.model_selection import StratifiedKFold, train_test_split

N_FOLDS = 3

def dataset_fraude(n=20_000, n_features=25, seed=42):
    X, y = make_classification(
        n_samples=n, n_features=n_features, n_informative=18, n_redundant=5,
        weights=[0.97, 0.03], flip_y=0.01, random_state=seed,
    )
    return pd.DataFrame(X, columns=[f"f{i}" for i in range(n_features)]), pd.Series(y)

def objetivo(X, y):
    folds = list(StratifiedKFold(N_FOLDS, shuffle=True, random_state=42).split(X, y))

    def evaluar(trial: optuna.Trial) -> float:
        params = {
            "iterations": 1000,
            "learning_rate": trial.suggest_float("learning_rate", 1e-2, 0.3, log=True),
            "depth": trial.suggest_int("depth", 4, 8),
            "l2_leaf_reg": trial.suggest_float("l2_leaf_reg", 0.1, 10.0, log=True),
            "bootstrap_type": "Bernoulli",
            "subsample": trial.suggest_float("subsample", 0.5, 1.0),
            "random_strength": trial.suggest_float("random_strength", 1e-2, 10.0, log=True),
            # Reponderar las clases es una decisión, no un dogma: que la busque también.
            "auto_class_weights": trial.suggest_categorical("auto_class_weights", ["None", "SqrtBalanced", "Balanced"]),
            "eval_metric": "PRAUC",
            "early_stopping_rounds": 50,
            "random_seed": 42,
            "verbose": False,
        }
        if params["auto_class_weights"] == "None":
            del params["auto_class_weights"]

        puntajes, iteraciones = [], []
        for k, (tr, va) in enumerate(folds):
            modelo = CatBoostClassifier(**params)
            modelo.fit(X.iloc[tr], y.iloc[tr], eval_set=(X.iloc[va], y.iloc[va]))
            puntajes.append(average_precision_score(y.iloc[va], modelo.predict_proba(X.iloc[va])[:, 1]))
            iteraciones.append(modelo.get_best_iteration() + 1)
            # Un valor por fold, con el fold como paso: la media acumulada es
            # comparable entre trials en el mismo punto de su evaluación.
            trial.report(float(np.mean(puntajes)), step=k)
            if trial.should_prune():
                raise optuna.TrialPruned()
        # El modelo final no tendrá conjunto de validación: guarda cuántos árboles necesitó.
        trial.set_user_attr("iteraciones", int(np.mean(iteraciones)))
        return float(np.mean(puntajes))

    return evaluar

if __name__ == "__main__":
    X, y = dataset_fraude()
    X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)

    base = CatBoostClassifier(random_seed=42, verbose=False).fit(X_tr, y_tr)
    p_base = base.predict_proba(X_te)[:, 1]

    optuna.logging.set_verbosity(optuna.logging.WARNING)
    study = optuna.create_study(
        direction="maximize",
        sampler=optuna.samplers.TPESampler(seed=42),
        pruner=optuna.pruners.MedianPruner(n_startup_trials=5, n_warmup_steps=0),
    )
    t0 = time.time()
    study.optimize(objetivo(X_tr, y_tr), n_trials=30)
    minutos = (time.time() - t0) / 60

    estados = [t.state for t in study.trials]
    completos = estados.count(optuna.trial.TrialState.COMPLETE)
    podados = estados.count(optuna.trial.TrialState.PRUNED)
    folds_usados = sum(len(t.intermediate_values) for t in study.trials)

    best = {**study.best_params}
    if best["auto_class_weights"] == "None":
        del best["auto_class_weights"]
    final = CatBoostClassifier(
        iterations=study.best_trial.user_attrs["iteraciones"], bootstrap_type="Bernoulli",
        random_seed=42, verbose=False, **best,
    ).fit(X_tr, y_tr)
    p_final = final.predict_proba(X_te)[:, 1]

    print(f"Trials: {completos} completos, {podados} podados · {minutos:.1f} min")
    print(f"Folds entrenados: {folds_usados} de {len(study.trials) * N_FOLDS} posibles")
    print(f"PR-AUC en validación cruzada (mejor trial): {study.best_value:.4f}")
    print(f"Test · CatBoost por defecto : PR-AUC {average_precision_score(y_te, p_base):.4f} · ROC-AUC {roc_auc_score(y_te, p_base):.4f}")
    print(f"Test · CatBoost optimizado  : PR-AUC {average_precision_score(y_te, p_final):.4f} · ROC-AUC {roc_auc_score(y_te, p_final):.4f}")
    print(f"Prevalencia en test: {y_te.mean():.2%} (PR-AUC de un modelo al azar)")
```

```text
Trials: 13 completos, 17 podados · 14.3 min
Folds entrenados: 56 de 90 posibles
PR-AUC en validación cruzada (mejor trial): 0.7866
Test · CatBoost por defecto : PR-AUC 0.7598 · ROC-AUC 0.9087
Test · CatBoost optimizado  : PR-AUC 0.7896 · ROC-AUC 0.9072
Prevalencia en test: 3.50% (PR-AUC de un modelo al azar)
```

### Qué muestra

**La métrica importa más que el optimizador.** La búsqueda subió la PR-AUC en test de
0,760 a 0,790, y la ROC-AUC ni se enteró: bajó de 0,909 a 0,907. Con un 3,5 % de
positivos, la ROC-AUC está dominada por los miles de negativos fáciles de ordenar y apenas
distingue un modelo de otro. La PR-AUC mide lo que le importa a quien revisa alertas de
fraude: de lo que el modelo marca, cuánto es fraude de verdad. Su línea base no es 0,5
sino la prevalencia: un modelo al azar saca 0,035.

**La poda ahorró un 38 % del cómputo.** De 90 folds posibles se entrenaron 56. Los
primeros cinco trials se completan siempre (`n_startup_trials=5`) para tener una mediana
con la que comparar; a partir de ahí, un trial cuya media tras el primer o segundo fold
queda por debajo de la mediana se corta.

**El número de árboles se hereda.** Cada fold para con *early stopping*, y el modelo final,
que se entrena sin conjunto de validación, usa la media de esas iteraciones. Entrenarlo
con las 1.000 iteraciones del espacio de búsqueda sería otro modelo, no el que se validó.

Un error frecuente al combinar poda con boosting es este bucle:

```python
for step in range(1, 501, 50):
    model.fit(train_pool, eval_set=val_pool, early_stopping_rounds=30)
    trial.report(score, step)
    if trial.should_prune():
        raise optuna.TrialPruned()
```

Parece entrenar por tramos de 50 árboles, pero `fit` vuelve a entrenar el modelo entero
desde cero en cada vuelta. Lo medí: el mismo modelo de 500 árboles, diez veces por fold,
con la misma métrica en los diez "pasos". Y como los folds reportan los mismos números de
paso, Optuna ignora los reportes del segundo y el tercero, con un aviso: *The reported
value is ignored because this step is already reported*. La poda no poda nada y el cómputo
se multiplica por diez. Además, el `timeout` de `study.optimize` no interrumpe un trial en
curso: en mi máquina, un solo trial así tardó 26 minutos con un `timeout=300`.

Si quieres poda dentro del entrenamiento, iteración a iteración, el camino es el callback
de la integración, `CatBoostPruningCallback` del paquete `optuna-integration`, sobre un
único conjunto de validación.

## Ejercicio 2: FT-Transformer contra CatBoost, en datos reales

El dataset es Adult, un clásico del aprendizaje supervisado tabular: 45.222 personas del
censo de Estados Unidos, con 6 columnas numéricas y 8 categóricas, y la pregunta de si
ganan más de 50.000 dólares al año. La implementación sigue el artículo original: pesos y
sesgos propios por columna, sesgo también en las categóricas, bloques *pre-norm* sin la
primera normalización en el primer bloque, FFN con activación ReGLU e inicialización
uniforme en $\pm 1/\sqrt{d}$.

```python

from catboost import CatBoostClassifier
from sklearn.datasets import fetch_openml
from sklearn.metrics import accuracy_score, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OrdinalEncoder, QuantileTransformer

# ——— 1. Tokenizador: cada columna se convierte en un vector de dimensión d ———
class FeatureTokenizer(nn.Module):
    def __init__(self, n_num: int, cardinalidades: list[int], d: int):
        super().__init__()
        s = 1 / math.sqrt(d)
        # e_j = x_j · w_j + b_j: un w y un b propios por columna numérica.
        self.w_num = nn.Parameter(torch.empty(n_num, d).uniform_(-s, s))
        self.b_num = nn.Parameter(torch.empty(n_num, d).uniform_(-s, s))
        # Categóricas: una tabla de embeddings compartida, con un desplazamiento por columna.
        self.register_buffer("offsets", torch.tensor([0, *np.cumsum(cardinalidades)[:-1]]))
        self.emb_cat = nn.Embedding(sum(cardinalidades), d)
        nn.init.uniform_(self.emb_cat.weight, -s, s)
        self.b_cat = nn.Parameter(torch.empty(len(cardinalidades), d).uniform_(-s, s))
        self.cls = nn.Parameter(torch.empty(1, 1, d).uniform_(-s, s))

    def forward(self, x_num, x_cat):
        num = x_num.unsqueeze(-1) * self.w_num + self.b_num          # [B, n_num, d]
        cat = self.emb_cat(x_cat + self.offsets) + self.b_cat        # [B, n_cat, d]
        cls = self.cls.expand(len(x_num), -1, -1)                    # [B, 1, d]
        # Sin codificación posicional: la identidad de cada columna ya está en su w_j.
        return torch.cat([cls, num, cat], dim=1)

# ——— 2. Bloque pre-norm con FFN ReGLU, como en el paper ———
class ReGLU(nn.Module):
    def forward(self, x):
        a, b = x.chunk(2, dim=-1)
        return a * F.relu(b)

class Bloque(nn.Module):
    def __init__(self, d, heads, primer_bloque: bool, d_ffn: int):
        super().__init__()
        # El paper quita la primera LayerNorm del primer bloque: en sus experimentos rendía mejor así.
        self.norm_attn = nn.Identity() if primer_bloque else nn.LayerNorm(d)
        self.attn = nn.MultiheadAttention(d, heads, dropout=0.2, batch_first=True)
        self.norm_ffn = nn.LayerNorm(d)
        self.ffn = nn.Sequential(nn.Linear(d, 2 * d_ffn), ReGLU(), nn.Dropout(0.1), nn.Linear(d_ffn, d))

    def forward(self, z):
        h = self.norm_attn(z)
        z = z + self.attn(h, h, h, need_weights=False)[0]
        return z + self.ffn(self.norm_ffn(z))

class FTTransformer(nn.Module):
    def __init__(self, n_num, cardinalidades, d=96, bloques=3, heads=8):
        super().__init__()
        self.tokenizer = FeatureTokenizer(n_num, cardinalidades, d)
        d_ffn = int(d * 4 / 3)
        self.bloques = nn.ModuleList(Bloque(d, heads, i == 0, d_ffn) for i in range(bloques))
        self.head = nn.Sequential(nn.LayerNorm(d), nn.ReLU(), nn.Linear(d, 1))

    def forward(self, x_num, x_cat):
        z = self.tokenizer(x_num, x_cat)
        for bloque in self.bloques:
            z = bloque(z)
        return self.head(z[:, 0]).squeeze(-1)  # la predicción sale del token [CLS]

# ——— 3. Datos: Adult (48.842 personas, ¿gana más de 50.000 dólares al año?) ———
def cargar_adult():
    df = fetch_openml("adult", version=2, as_frame=True).frame.dropna()
    y = (df.pop("class") == ">50K").astype(int).to_numpy()
    num_cols = df.select_dtypes("number").columns.tolist()
    cat_cols = [c for c in df.columns if c not in num_cols]
    return df, y, num_cols, cat_cols

if __name__ == "__main__":
    torch.manual_seed(0)
    df, y, num_cols, cat_cols = cargar_adult()
    idx_tr, idx_te = train_test_split(np.arange(len(df)), test_size=0.2, stratify=y, random_state=0)
    idx_tr, idx_va = train_test_split(idx_tr, test_size=0.15, stratify=y[idx_tr], random_state=0)

    # Numéricas a cuantiles normales (como en el paper); categóricas a enteros. Todo ajustado solo en train.
    qt = QuantileTransformer(output_distribution="normal", random_state=0).fit(df.iloc[idx_tr][num_cols])
    oe = OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=-1).fit(df.iloc[idx_tr][cat_cols])
    Xn = torch.tensor(qt.transform(df[num_cols]), dtype=torch.float32)
    Xc = torch.tensor(oe.transform(df[cat_cols]) + 1, dtype=torch.long)  # 0 = categoría no vista
    card = [len(c) + 1 for c in oe.categories_]
    Y = torch.tensor(y, dtype=torch.float32)

    modelo = FTTransformer(len(num_cols), card)
    opt = torch.optim.AdamW(modelo.parameters(), lr=1e-4, weight_decay=1e-5)
    print(f"FT-Transformer: {sum(p.numel() for p in modelo.parameters()):,} parámetros")

    def predecir(idx):
        modelo.eval()
        with torch.no_grad():
            return torch.sigmoid(modelo(Xn[idx], Xc[idx])).numpy()

    mejor, mejor_estado, paciencia, t0 = 0.0, None, 0, time.time()
    for epoca in range(1, 101):
        modelo.train()
        for lote in torch.randperm(len(idx_tr)).split(256):
            i = idx_tr[lote.numpy()]
            loss = F.binary_cross_entropy_with_logits(modelo(Xn[i], Xc[i]), Y[i])
            opt.zero_grad()
            loss.backward()
            opt.step()
        auc = roc_auc_score(y[idx_va], predecir(idx_va))
        if auc > mejor:
            mejor, mejor_estado, paciencia = auc, copy.deepcopy(modelo.state_dict()), 0
        else:
            paciencia += 1
        print(f"Época {epoca:2d} | ROC-AUC validación {auc:.4f}")
        if paciencia == 5:  # parada temprana
            break
    modelo.load_state_dict(mejor_estado)
    p_ft = predecir(idx_te)
    t_ft = time.time() - t0

    t0 = time.time()
    cb = CatBoostClassifier(iterations=2000, early_stopping_rounds=100, random_seed=0, verbose=False, cat_features=cat_cols)
    cb.fit(df.iloc[idx_tr], y[idx_tr], eval_set=(df.iloc[idx_va], y[idx_va]))
    p_cb = cb.predict_proba(df.iloc[idx_te])[:, 1]
    t_cb = time.time() - t0

    for nombre, p, t in [("FT-Transformer", p_ft, t_ft), ("CatBoost (por defecto)", p_cb, t_cb)]:
        print(f"Test · {nombre:<24} ROC-AUC {roc_auc_score(y[idx_te], p):.4f} · "
              f"accuracy {accuracy_score(y[idx_te], p > 0.5):.4f} · entrenamiento {t:.0f} s")
    torch.save({"estado": modelo.state_dict(), "n_num": len(num_cols), "card": card}, "ft_adult.pt")
```

```text
FT-Transformer: 236,833 parámetros
Época  1 | ROC-AUC validación 0.8962
Época  2 | ROC-AUC validación 0.8990
...
Época 21 | ROC-AUC validación 0.9102
...
Época 26 | ROC-AUC validación 0.9091
Test · FT-Transformer           ROC-AUC 0.9115 · accuracy 0.8515 · entrenamiento 670 s
Test · CatBoost (por defecto)   ROC-AUC 0.9248 · accuracy 0.8664 · entrenamiento 83 s
```

### Qué muestra

**CatBoost sin ajustar le gana al FT-Transformer.** Más ROC-AUC (0,925 frente a 0,912),
más accuracy (0,866 frente a 0,852) y ocho veces menos tiempo de entrenamiento en CPU. No
es un fallo de la implementación: es lo que dice la literatura para un dataset de este
tamaño, y es la razón para no reemplazar un gradient boosting que funciona. El
FT-Transformer que ves aquí tampoco está ajustado; con una búsqueda como la del ejercicio
1 la distancia se acorta, pero en Adult no es habitual que se invierta.

**Lo que sí te da el Transformer** es lo que un árbol no tiene: el estado del token
`[CLS]` es un vector de 96 dimensiones por fila, que puedes reutilizar como
representación en otro modelo, y la red entera se puede entrenar junto con un codificador
de texto o imágenes. Si tu problema es solo "predecir esta columna con estas otras", usa
árboles.

**Tres detalles que la implementación ingenua suele perder.** `nn.TransformerEncoderLayer`
usa *post-norm* por defecto, así que la fórmula de arriba solo se cumple con
`norm_first=True` o con un bloque propio como este. No acepta ReGLU como activación,
porque ReGLU parte en dos la salida de la primera capa lineal y cambia sus dimensiones:
`torch.nn.functional` no tiene una `reglu`, y comprobar si existe para elegirla deja la
red en ReLU sin avisar. Y el `[CLS]` no necesita posición: ninguna la necesita.

## Ejercicio 3: CQL contra Q-learning offline, en un simulador de precios

Para ver si la penalización conservadora sirve, hace falta algo que en producción no
existe: saber cuánto vale de verdad la política aprendida. Por eso este ejercicio usa un
simulador de precios con la verdad conocida. Entrenar con estados y recompensas
aleatorias no enseña nada: no hay nada que aprender, y no hay forma de ver si la política
es buena.

El escenario: una tienda decide cada día qué descuento ofrecer a cada cliente, entre 0,
10, 20 y 40 %. La demanda sube con el descuento, pero con un 40 % se vende a pérdida. Y un
descuento hoy adelanta compras: baja la demanda de mañana. El histórico lo generó un
equipo de precios que usó el 0, el 10 y el 20 %, y **nunca probó el 40 %**.

El descuento entra a la red como un número, igual que un precio en la práctica. Eso es lo
que permite extrapolar, y lo que hace real el problema.

```python

DESCUENTOS = np.array([0.00, 0.10, 0.20, 0.40])
PRECIO, COSTO = 100.0, 62.0
STATE_DIM, N_ACTIONS, GAMMA = 4, len(DESCUENTOS), 0.9
W_DEMANDA = np.array([0.8, -0.5, 0.3, 0.6])

# ——— 1. El mundo (en producción no lo conoces; aquí sí, para poder evaluar) ———
def prob_compra(s, a):
    """La demanda depende del contexto y sube con el descuento."""
    return 1 / (1 + np.exp(-(s @ W_DEMANDA - 2.5 + 15.0 * DESCUENTOS[a])))

def recompensa_esperada(s, a):
    margen = PRECIO * (1 - DESCUENTOS[a]) - COSTO  # con 40 % de descuento, se vende a pérdida
    return prob_compra(s, a) * margen

def paso(s, a, rng):
    compra = rng.random(len(s)) < prob_compra(s, a)
    r = compra * (PRECIO * (1 - DESCUENTOS[a]) - COSTO)
    # Un descuento hoy adelanta compras: baja la demanda de mañana (dimensión 3).
    s2 = 0.8 * s + rng.normal(0, 0.3, s.shape)
    s2[:, 3] -= 0.5 * DESCUENTOS[a]
    return r, s2

# ——— 2. El histórico: lo que decidió el equipo de precios durante un año ———
def politica_historica(n, rng):
    # Nunca se probó el 40 %: es la acción fuera de distribución.
    return rng.choice(N_ACTIONS, size=n, p=[0.40, 0.35, 0.25, 0.00])

def dataset_offline(n=50_000, seed=0):
    rng = np.random.default_rng(seed)
    s = rng.normal(0, 1, (n, STATE_DIM))
    a = politica_historica(n, rng)
    r, s2 = paso(s, a, rng)
    t = lambda x, dt=torch.float32: torch.as_tensor(x, dtype=dt)
    return t(s), t(a, torch.long), t(r).unsqueeze(1), t(s2)

# ——— 3. Red Q y agente ———
class QNetwork(nn.Module):
    """Q(s, descuento): el descuento entra como número, igual que un precio en la práctica."""

    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(STATE_DIM + 1, 128), nn.ReLU(),
            nn.Linear(128, 128), nn.ReLU(),
            nn.Linear(128, 1),
        )
        self.register_buffer("descuentos", torch.tensor(DESCUENTOS, dtype=torch.float32))

    def forward(self, s):
        """Devuelve Q para las cuatro acciones: [B, N_ACTIONS]."""
        s_rep = s.unsqueeze(1).expand(-1, N_ACTIONS, -1)
        d_rep = self.descuentos.view(1, -1, 1).expand(len(s), -1, -1)
        return self.net(torch.cat([s_rep, d_rep], dim=-1)).squeeze(-1)

def entrenar(datos, alpha: float, pasos=15_000, batch=256, seed=0):
    torch.manual_seed(seed)
    s, a, r, s2 = datos
    q, q_target = QNetwork(), QNetwork()
    q_target.load_state_dict(q.state_dict())
    opt = torch.optim.Adam(q.parameters(), lr=3e-4)
    for i in range(pasos):
        idx = torch.randint(0, len(s), (batch,))
        with torch.no_grad():
            target = r[idx] + GAMMA * q_target(s2[idx]).max(dim=1, keepdim=True).values
        q_all = q(s[idx])
        q_data = q_all.gather(1, a[idx].unsqueeze(1))
        td = F.mse_loss(q_data, target)
        # CQL(H): empuja hacia abajo todas las acciones (log-sum-exp) y hacia
        # arriba la que de verdad está en el dataset.
        conservador = (torch.logsumexp(q_all, dim=1, keepdim=True) - q_data).mean()
        loss = td + alpha * conservador
        opt.zero_grad()
        loss.backward()
        opt.step()
        if i % 500 == 0:
            q_target.load_state_dict(q.state_dict())
    return q

# ——— 4. Evaluación contra la verdad del simulador ———
def valor_real(politica, n=20_000, horizonte=40, seed=1):
    rng = np.random.default_rng(seed)
    s = rng.normal(0, 1, (n, STATE_DIM))
    total, desc = np.zeros(n), 1.0
    for _ in range(horizonte):
        a = politica(s)
        r, s = paso(s, a, rng)
        total += desc * r
        desc *= GAMMA
    return total.mean()

def greedy(q):
    def pol(s):
        with torch.no_grad():
            return q(torch.as_tensor(s, dtype=torch.float32)).argmax(1).numpy()
    return pol

if __name__ == "__main__":
    datos = dataset_offline()
    s0 = np.random.default_rng(2).normal(0, 1, (20_000, STATE_DIM))
    rng_hist = np.random.default_rng(3)
    hist = lambda s: politica_historica(len(s), rng_hist)
    print(f"Política histórica          valor real {valor_real(hist):7.2f}")
    r_media = [recompensa_esperada(s0, np.full(len(s0), k)).mean() for k in range(N_ACTIONS)]
    print("Recompensa inmediata real por acción:", " ".join(f"{d:.0%}={r:.2f}" for d, r in zip(DESCUENTOS, r_media)))
    for alpha in [0.0, 1.0, 5.0]:
        q = entrenar(datos, alpha)
        with torch.no_grad():
            q_s0 = q(torch.as_tensor(s0, dtype=torch.float32))
        acciones = q_s0.argmax(1).numpy()
        estimado = q_s0.max(1).values.mean().item()
        reparto = np.bincount(acciones, minlength=N_ACTIONS) / len(acciones)
        print(
            f"alpha={alpha:<4} Q estimada {estimado:7.2f} | valor real {valor_real(greedy(q)):7.2f} | "
            f"elige 0/10/20/40 %: {' '.join(f'{p:4.0%}' for p in reparto)} | Q(40 %) {q_s0[:, 3].mean().item():7.2f}"
        )
```

```text
Política histórica          valor real   65.76
Recompensa inmediata real por acción: 0%=4.34 10%=8.72 20%=10.76 40%=-1.90
alpha=0.0  Q estimada  162.07 | valor real  -18.65 | elige 0/10/20/40 %:   0%   0%   0% 100% | Q(40 %)  162.07
alpha=1.0  Q estimada   92.21 | valor real  101.50 | elige 0/10/20/40 %:   0%  19%  80%   0% | Q(40 %)   83.93
alpha=5.0  Q estimada   75.16 | valor real  100.20 | elige 0/10/20/40 %:   2%  32%  66%   0% | Q(40 %)   69.09
```

### Qué muestra

**Sin penalización, el agente elige lo único que nunca se probó.** Con `alpha=0`, que es
Q-learning offline sin más, la política pone el 40 % de descuento al 100 % de los clientes.
Cree que eso vale 162. En realidad vale −18,65: pierde dinero, y queda muy por debajo del
equipo de precios al que quería mejorar (65,76).

La causa es la de la teoría. En el histórico, pasar del 10 al 20 % de descuento siempre
dio más. La red aprendió esa pendiente y la prolongó hasta el 40 %, donde no tenía ni un
dato. El máximo de Bellman eligió esa extrapolación, la usó como objetivo y la infló en
cada actualización. Nada en los datos podía corregirla, porque nadie probó nunca el 40 %.

**Con CQL, la política mejora el histórico sin inventar.** Con `alpha=1` vale 101,50, un
54 % más que el equipo de precios, y usa sobre todo el 20 %, la mejor acción que sí
aparece en los datos. Nunca elige el 40 %.

**La estimación queda por debajo de la realidad.** Con `alpha=1` la red cree que su
política vale 92,21 y vale 101,50; con `alpha=5`, 75,16 frente a 100,20. Es la cota
inferior de la teoría, observada aquí en este problema, no garantizada en general. Y
con `alpha=5` se ve el costo del exceso de prudencia: la política se parece más al
histórico, usa más el 10 %, y rinde un poco menos.

La trampa de este ejercicio es la columna "valor real". En producción no la tienes: no
hay simulador que te diga cuánto vale una política que nunca se ejecutó. Lo que sí tienes
es la columna "Q estimada", y con `alpha=0` esa columna decía 162, la cifra más alta de
todas. Si eliges la política por lo que ella misma estima, eliges la peor. Por eso, antes
de desplegar una política aprendida offline, se evalúa con métodos de evaluación
*off-policy* sobre datos que no se usaron para entrenar, y se despliega de forma
gradual, sobre una fracción del tráfico.

## Despliegue: ONNX y latencia real

Entrenar en PyTorch no obliga a servir en PyTorch. Exportar el modelo a ONNX desacopla el
motor de entrenamiento del de ejecución: ONNX Runtime ejecuta el grafo en C++, sin el
intérprete de Python en cada operación. El script importa el modelo del ejercicio 2,
guardado como `ej2_ft.py`.

```python

from ej2_ft import FTTransformer, cargar_adult

# ——— 1. Exportar a ONNX ———
df, y, num_cols, cat_cols = cargar_adult()
ckpt = torch.load("ft_adult.pt")
modelo = FTTransformer(ckpt["n_num"], ckpt["card"])
modelo.load_state_dict(ckpt["estado"])
modelo.eval()

x_num = torch.randn(1, len(num_cols))
x_cat = torch.zeros(1, len(cat_cols), dtype=torch.long)
lote = torch.export.Dim("lote", min=1, max=4096)
torch.onnx.export(
    modelo, (x_num, x_cat), "ft_transformer.onnx",
    input_names=["x_num", "x_cat"], output_names=["logit"],
    dynamic_shapes={"x_num": {0: lote}, "x_cat": {0: lote}},
)

sesion = ort.InferenceSession("ft_transformer.onnx", providers=["CPUExecutionProvider"])
entrada = {"x_num": x_num.numpy(), "x_cat": x_cat.numpy()}
with torch.no_grad():
    ref = modelo(x_num, x_cat).numpy()
print(f"Diferencia máxima PyTorch vs ONNX: {np.abs(sesion.run(None, entrada)[0] - ref).max():.2e}")

def p50_p99(fn, n=2000):
    for _ in range(100):
        fn()
    t = []
    for _ in range(n):
        t0 = time.perf_counter()
        fn()
        t.append((time.perf_counter() - t0) * 1000)
    return np.percentile(t, 50), np.percentile(t, 99)

def torch_eager():
    with torch.no_grad():
        modelo(x_num, x_cat)

torch.set_num_threads(1)
so = ort.SessionOptions()
so.intra_op_num_threads = 1
sesion1 = ort.InferenceSession("ft_transformer.onnx", so, providers=["CPUExecutionProvider"])
for nombre, fn in [("PyTorch (eager)", torch_eager), ("ONNX Runtime", lambda: sesion1.run(None, entrada))]:
    p50, p99 = p50_p99(fn)
    print(f"Latencia de una predicción · {nombre:<16} p50 {p50:.3f} ms · p99 {p99:.3f} ms")
```

```text
Diferencia máxima PyTorch vs ONNX: 2.38e-07
Latencia de una predicción · PyTorch (eager)  p50 1.044 ms · p99 1.973 ms
Latencia de una predicción · ONNX Runtime     p50 0.286 ms · p99 0.598 ms
```

La salida es la misma hasta la séptima cifra decimal, y la latencia de una predicción baja
3,6 veces, con un solo hilo en ambos casos. Mide siempre en tu hardware y con tu tamaño de
lote: la ganancia depende del modelo, y en lotes grandes se reduce, porque ahí el costo ya
no está en el intérprete sino en las multiplicaciones de matrices.

Dos detalles de versión. En PyTorch 2.14 `torch.onnx.export` usa por defecto el
exportador basado en `torch.export`, y las dimensiones variables se declaran con
`dynamic_shapes` y `torch.export.Dim`; el viejo `dynamic_axes` pertenece al exportador
anterior. Y CatBoost también exporta a ONNX, pero **no con variables categóricas**: con
el modelo de Adult, `save_model(format="onnx")` falla con *ONNX-ML format export does yet
not support categorical features*. Si sirves CatBoost con categóricas, usa su propio
runtime en C++ o codifica las categóricas antes de entrenar.

## Monitoreo: drift con PSI y Kolmogorov-Smirnov

Un modelo en producción se degrada cuando cambian los datos. Hay dos tipos de cambio: el
**data drift**, cuando cambia la distribución de las entradas, y el **concept drift**,
cuando cambia la relación entre las entradas y lo que predices. Las métricas de abajo solo
ven el primero. El segundo solo se ve midiendo el rendimiento con etiquetas nuevas.

El **índice de estabilidad de la población** (PSI) compara la distribución de referencia
$P$ con la de producción $Q$, repartidas en los mismos $k$ intervalos:

$$
\mathrm{PSI} = \sum_{i=1}^{k} (P_i - Q_i)\, \ln \frac{P_i}{Q_i}
$$

Es una divergencia de Kullback-Leibler simetrizada, y tiene una trampa práctica: si un
intervalo queda vacío, el logaritmo explota. Por eso el código suma un $\varepsilon$ y
corta los intervalos por cuantiles de la referencia.

El **test de Kolmogorov-Smirnov** mide la máxima distancia vertical $D$ entre las dos
funciones de distribución acumulada, y da un valor $p$ para la hipótesis de que vienen de
la misma población.

```python

from scipy.stats import ks_2samp

from ej2_ft import cargar_adult

df, y, num_cols, cat_cols = cargar_adult()

def psi(referencia, actual, bins=10, eps=1e-6):
    """Population Stability Index con cortes por cuantiles de la referencia."""
    cortes = np.unique(np.quantile(referencia, np.linspace(0, 1, bins + 1)))
    cortes[0], cortes[-1] = -np.inf, np.inf
    p = np.histogram(referencia, cortes)[0] / len(referencia) + eps
    q = np.histogram(actual, cortes)[0] / len(actual) + eps
    return float(np.sum((p - q) * np.log(p / q)))

rng = np.random.default_rng(0)
edad = df["age"].to_numpy(dtype=float)
referencia = rng.choice(edad, 20_000, replace=False)
escenarios = {
    "misma población": rng.choice(edad, 5_000),
    "clientes 3 años mayores": rng.choice(edad, 5_000) + 3,
    "clientes 8 años mayores": rng.choice(edad, 5_000) + 8,
}
for nombre, actual in escenarios.items():
    ks = ks_2samp(referencia, actual)
    print(f"{nombre:<24} PSI {psi(referencia, actual):.3f} · K-S D={ks.statistic:.3f} p={ks.pvalue:.2g}")
```

```text
misma población          PSI 0.001 · K-S D=0.007 p=0.99
clientes 3 años mayores  PSI 0.103 · K-S D=0.084 p=2.9e-25
clientes 8 años mayores  PSI 1.296 · K-S D=0.217 p=2.2e-165
```

La fila del medio es la lección. Con 20.000 y 5.000 observaciones, K-S da un valor $p$ de
$10^{-25}$ para un cambio de tres años en la edad: con muestras grandes, K-S declara
significativo casi cualquier cambio, relevante o no. El PSI mide **cuánto** se movió la
distribución, y eso es lo que sirve para decidir. Las convenciones de la industria dicen
que por debajo de 0,1 no hay cambio relevante, entre 0,1 y 0,25 hay que vigilar, y por
encima hay un cambio importante. Son convenciones, no leyes: un PSI alto dice que hay que
investigar, no que haya que reentrenar. Lo que decide si reentrenar es si el rendimiento
del modelo cayó.

## Elegir la herramienta, no la complejidad

Ninguna de estas tres técnicas es una mejora automática. En los tres ejercicios, lo que
decidió el resultado fue una elección que no tenía que ver con la técnica en sí: optimizar
la métrica correcta, reconocer que un gradient boosting sin ajustar ya era mejor que el
Transformer, y no creerle a un modelo su propia estimación. La complejidad de un modelo
se justifica cuando resuelve un problema que el modelo simple no resuelve, y eso se mide,
no se supone: en latencia, en costo de entrenamiento y en estabilidad, antes de ponerlo en
producción.

## Para llevarte

- TPE elige el siguiente punto maximizando $\ell(x)/g(x)$, con un costo lineal en el
  número de observaciones. La poda es un mecanismo aparte, y solo funciona si cada trial
  informa valores intermedios comparables.
- Con clases muy desbalanceadas, optimiza PR-AUC: la ROC-AUC casi no distingue entre
  modelos, y la línea base de la PR-AUC es la prevalencia, no 0,5.
- El FT-Transformer convierte cada columna en un token y no necesita posiciones. En datos
  tabulares puros, un gradient boosting suele ganarle; úsalo cuando necesites lo que solo
  una red da.
- En refuerzo offline, el máximo de Bellman premia las acciones que nunca viste. CQL las
  penaliza y deja una estimación por debajo de la realidad, que es el error que conviene
  cometer.
- Exporta a ONNX y mide la latencia en tu hardware. Vigila el drift con PSI por magnitud,
  no con el valor $p$ de K-S, y decide si reentrenar por el rendimiento, no por el drift.
