Capítulo 8 de 37 · básico
Cuando el accuracy miente: clases desbalanceadas
Qué cubre este capítulo
El capítulo anterior mostró al accuracy escondiendo un modelo mediocre con 35% de prevalencia. Este lo muestra mintiendo descaradamente. Nuestro test set es 4.5% positivo, y un modelo que predice la clase mayoritaria para las 600 filas saca 0.955 de accuracy sin encontrar ni uno solo de los casos para los que existe. Eso no es un modelo roto. Es la aritmética de una clase rara, y ninguna cantidad de entrenamiento lo arregla si sigues leyendo el accuracy.
Así que este capítulo trata de qué hacer en su lugar. Construimos a mano en NumPy los tres rebalanceadores clásicos — oversampling aleatorio de la minoría, undersampling aleatorio de la mayoría, y los pesos de clase balanceados que reponderan la loss sin tocar los datos — y luego le entregamos cada uno a la misma regresión logística para ver qué cambia. Lo que cambia es la frontera de decisión, y la historia que cuenta es todo el punto: conforme aplicamos cada arreglo, la frontera se despega de la mayoría y envuelve al pequeño clúster minoritario, el recall sube de atrapar un caso de cada cinco a atrapar casi todos, y el accuracy apenas se inmuta porque nunca hubo mucho accuracy real que perder. Cada número en esta página se juzga con la precision, el recall, el F1 y el AUC del capítulo de métricas, porque en estos datos esos son los únicos números que significan algo.
Un poco de historia
El problema es viejo y su nombre es directo. Para finales de los noventa ya se había quemado suficiente gente como para que la comunidad de machine learning empezara a organizar talleres específicamente sobre aprender de datos desbalanceados — el taller de AAAI en el 2000, uno de ICML en 2003 — porque los algoritmos estándar, todos afinados para minimizar la tasa de error, silenciosamente optimizaban para la mayoría y se declaraban victoriosos. La breve nota de Foster Provost del 2000, "Machine Learning from Imbalanced Data Sets", planteó la queja sin rodeos: el accuracy asume costos de error iguales y clases balanceadas, y cuando ninguna de las dos cosas se cumple es peor que inútil, porque premia activamente al modelo equivocado. En algún punto de esa época se pegó la frase "paradoja del accuracy" — la paradoja de que tirar el clasificador a la basura y adivinar siempre la clase mayoritaria puede subir tu accuracy.
Los arreglos tienen su propio linaje. Reponderar las clases es tan viejo como el aprendizaje sensible al costo. El undersampling y el oversampling fueron las primeras jugadas obvias, y ya en 1997 Miroslav Kubat y Stan Matwin señalaban que descartar ejemplos de la mayoría a lo tonto tira información, y que copiar los de la minoría a lo tonto invita al overfitting. Esa tensión es exactamente de la que el paper de SMOTE de Nitesh Chawla en 2002 intentó escapar, sintetizando nuevos puntos minoritarios entre los existentes en lugar de duplicarlos, y SMOTE se convirtió en uno de los papers más citados del campo. Aquí nos detenemos antes de SMOTE — necesita su propio capítulo — pero el oversampling aleatorio es su ancestro honesto y tosco, y al ancestro hay que conocerlo primero.
La intuición
Aquí están los datos, y toda la dificultad se ve en una sola imagen: una nube cian densa de la clase mayoritaria y, a un lado, una pequeña mancha morada de la minoría.
Cuenta los puntos morados. Hay 107 puntos minoritarios entre 2000, alrededor del 5%, y están medio enterrados en el borde de la nube mayoritaria en lugar de limpiamente separados. Ahora imagina un clasificador al que solo le importa acertar lo más seguido posible. La forma más barata de acertar en estos datos es pintar todo el plano de cian. Te cuesta cada punto morado, pero lo morado es el 5% del plano, así que sigues acertando el 95% de las veces — y un modelo entrenado para minimizar el error va a derivar exactamente hacia eso, porque los 100 errores minoritarios apenas se registran contra los 1900 puntos mayoritarios que se lleva gratis. La frontera que minimiza el error total y la frontera que de verdad encuentra a la minoría son dos líneas distintas, y el capítulo entero se trata de arrastrar al modelo de la primera a la segunda.
Las matemáticas
Empieza con la línea base que tienes que vencer. Separa las etiquetas en clases, cuenta cada una, y predice siempre la más grande. Su accuracy es la fracción mayoritaria:
donde es el número de filas en la clase y el total. Ahí está toda la trampa en una línea. El número es grande exactamente cuando los datos están desbalanceados, así que en un split 95/5 el modelo que no hace nada arranca en 0.95, y cualquier modelo real cuyo accuracy cites tiene que leerse contra ese piso, no contra cero. Reporta 0.96 sin mencionar que un tabique saca 0.955 y no has dicho nada.
Dos de los arreglos cambian los datos; el tercero cambia la loss. La regresión logística ordinaria minimiza la cross-entropy promedio, que trata cada fila como igual de importante:
La ponderación por clase mete un multiplicador por clase delante de cada término, de modo que un error en una fila de la clase rara cuesta más que un error en una de la clase común:
La opción balanceada fija el peso de cada clase inversamente a su tamaño:
con el número de clases. En nuestro split de entrenamiento eso deja el peso de la minoría en 8.75 y el de la mayoría en 0.53 — un error minoritario le duele a la loss unas dieciséis veces más que un error mayoritario, que es más o menos la razón de desbalance, y ese es el punto. El oversampling llega al mismo lugar copiando filas minoritarias hasta que son tan numerosas como la mayoría; una fila que aparece dieciséis veces aporta dieciséis términos idénticos a la suma, lo cual es aritméticamente casi lo mismo que multiplicar su único término por dieciséis. Los tres arreglos son tres rutas hacia la misma idea: hacer que la minoría cuente.
En qué es bueno cada arreglo, y en qué no
El undersampling es lo más barato que puedes hacer y muchas veces lo primero que pruebo, porque un training set balanceado que es una décima parte del tamaño entrena en una décima parte del tiempo y averiguas rápido si la señal siquiera existe. Su defecto es que tira datos mayoritarios reales — en nuestros datos descarta más de mil filas para empatar ochenta — y todo lo que descartas es información que el modelo ya no ve. El oversampling conserva todos los datos pero paga en la dirección opuesta: las filas minoritarias duplicadas son literalmente los mismos puntos, así que un modelo flexible puede memorizarlas, y has inflado tu confianza en un puñado de ejemplos sin agregar variedad real alguna. Ninguno de los dos inventa información que no estuviera en las ochenta filas minoritarias desde el principio.
Los pesos de clase son a lo que recurro por default, porque es un solo argumento, no toca datos, conserva cada fila, y para un modelo lineal es matemáticamente casi lo mismo que el oversampling balanceado sin el riesgo de memorización ni la contabilidad extra. Lo que ninguno de los tres hace es volver al modelo mejor para distinguir las clases. Mueven el punto de operación — dónde queda la frontera — no la capacidad subyacente del modelo de rankear un punto minoritario por encima de uno mayoritario. Lo vas a ver en un momento, cuando el AUC se niegue a moverse mientras todo lo demás se mueve, y es lo más importante de la página.
Los datos
El dataset es sintético a propósito, generado una sola vez con
make_classification de scikit-learn con weights=[0.95, 0.05] y guardado
como snapshot para que cada número de aquí sea reproducible. Dos features, para
poder ver la frontera en el plano; 2000 filas, de las cuales 107 son la
minoría; un class_sep lo bastante bajo para que la minoría quede parcialmente
dentro de la mayoría en lugar de limpiamente aparte, porque una minoría bien
separada la atraparía el modelo naive y no habría nada que enseñar. Lo
partimos 70/30 en 1400 filas de entrenamiento y 600 de test. El split de
entrenamiento trae 80 filas minoritarias, el de test 27.
Todo lo que sigue obedece una regla que este capítulo va a repetir hasta el cansancio: resampleamos y reponderamos solo el training set. El test set conserva su prevalencia real de 4.5% de principio a fin, porque el test set existe para estimar el desempeño en el mundo, y el mundo está desbalanceado. Rebalancea tu test set y estarás calificando al modelo sobre una distribución que nunca va a ver.
Constrúyelo, una función a la vez
Empieza con la línea base, porque es el número contra el que se mide cada arreglo. El accuracy de la clase mayoritaria es simplemente el conteo de la clase más grande sobre el total — el accuracy de un modelo que no aprendió nada:
def majority_class(y):
"""The label that appears most often — what a lazy model would always guess."""
y = np.asarray(y).astype(int)
return int(np.argmax(np.bincount(y)))
def majority_baseline_accuracy(y):
"""Accuracy of the model that predicts the majority class for everyone.
It equals the majority-class fraction: if 95% of rows are class 0, always
answering 0 is right 95% of the time while learning nothing. Unitless, in
[0, 1]. This is the bar any classifier has to clear before its accuracy
means a single thing, and on imbalanced data it sits embarrassingly high.
"""
y = np.asarray(y).astype(int)
counts = np.bincount(y)
return float(counts.max() / counts.sum())
Ahora el primer arreglo de verdad. El oversampling aleatorio conserva cada fila mayoritaria y saca filas minoritarias con reemplazo hasta que las dos clases tienen el mismo tamaño. El generador con semilla no es decoración: resamplear es aleatoriedad dentro de tu pipeline de entrenamiento, y un pipeline sin semilla te da un modelo distinto en cada corrida y ninguna forma de reproducir un resultado:
def random_oversample(X, y, seed=0):
"""Random oversampling: duplicate minority rows until the classes match.
Keep every majority row; draw minority rows WITH replacement until there
are as many of them as the majority class. Nothing new is invented — the
same minority points just show up several times, so they weigh more in the
fit. Seeded, so the draw is identical every run. Returns the balanced,
shuffled (X, y). Resample the TRAINING set only — never the test set.
"""
X = np.asarray(X, dtype=float)
y = np.asarray(y).astype(int)
rng = np.random.default_rng(seed)
counts = np.bincount(y)
n_target = counts.max()
parts_X, parts_y = [], []
for c in range(len(counts)):
idx = np.flatnonzero(y == c)
if len(idx) == 0:
continue
take = idx if len(idx) >= n_target else rng.choice(idx, size=n_target, replace=True)
parts_X.append(X[take])
parts_y.append(y[take])
Xb = np.vstack(parts_X)
yb = np.concatenate(parts_y)
perm = rng.permutation(len(yb))
return Xb[perm], yb[perm]
El undersampling es la imagen en espejo: conserva cada fila minoritaria, y saca filas mayoritarias sin reemplazo hasta bajar al conteo de la minoría. La misma forma de código, el sacrificio opuesto — este balancea por eliminación en lugar de duplicación:
def random_undersample(X, y, seed=0):
"""Random undersampling: drop majority rows until the classes match.
Keep every minority row; draw majority rows WITHOUT replacement down to the
minority count. Fast and it fixes the balance, but it throws real data on
the floor — the discarded majority rows are gone from the fit. Seeded and
repeatable. Returns the balanced, shuffled (X, y). Training set only.
"""
X = np.asarray(X, dtype=float)
y = np.asarray(y).astype(int)
rng = np.random.default_rng(seed)
counts = np.bincount(y)
n_target = counts[counts > 0].min()
parts_X, parts_y = [], []
for c in range(len(counts)):
idx = np.flatnonzero(y == c)
if len(idx) == 0:
continue
take = idx if len(idx) <= n_target else rng.choice(idx, size=n_target, replace=False)
parts_X.append(X[take])
parts_y.append(y[take])
Xb = np.vstack(parts_X)
yb = np.concatenate(parts_y)
perm = rng.permutation(len(yb))
return Xb[perm], yb[perm]
El tercer arreglo nunca toca los datos. Calcula un peso por clase, grande para
la rara y pequeño para la común, usando la regla balanceada de la sección de
matemáticas. Esto regresa los mismos números que calcula scikit-learn con
class_weight='balanced', cosa que el script de trazas verifica con un assert
en cada corrida:
def balanced_class_weights(y):
"""Per-class weights that make the loss treat the classes as equal.
w_c = n / (k * n_c)
total rows n, over the number of classes k times the count of class c. The
rare class gets a large weight, the common class a small one, so a mistake
on a minority row costs proportionally more in the loss than a mistake on a
majority row. No data is copied or dropped — only the penalty changes. This
is exactly scikit-learn's class_weight='balanced'. Returns {class: weight}.
"""
y = np.asarray(y).astype(int)
counts = np.bincount(y)
k = int(np.sum(counts > 0))
n = int(counts.sum())
return {c: float(n / (k * counts[c])) for c in range(len(counts)) if counts[c] > 0}
Y como un clasificador quiere un peso por fila en lugar de por clase, un
pequeño helper reparte los pesos de clase entre las filas — el puente entre la
fórmula y el argumento que fit de verdad recibe:
def sample_weights(y, class_weight):
"""Spread a per-class weight dict into a per-row weight vector.
Feed this to a classifier's sample_weight to reproduce class weighting by
hand: every row inherits its class's weight. It's the bridge that turns the
balanced_class_weights dict into the argument sklearn's fit() wants.
"""
y = np.asarray(y).astype(int)
return np.array([class_weight[int(c)] for c in y], dtype=float)
Ese es el toolkit completo: una línea base que vencer y tres formas de hacer que la minoría cuente. Nada de esto entrena un modelo. El modelo es una regresión logística de fábrica; estas funciones solo deciden qué ve y cuánto pesa cada fila.
Míralo trabajar
Esta es la animación que resume el capítulo. Cada frame aplica un arreglo y reajusta, luego dibuja la frontera de decisión resultante sobre los mismos 2000 puntos — la línea naranja, con todo lo que queda debajo predicho como minoría — y reporta abajo las cuatro métricas sobre el test set apartado. Los datos nunca cambian; solo la frontera y las barras.
El frame uno es el ajuste naive, y es la paradoja del accuracy dibujada a escala. La frontera se para casi vertical en el borde lejano del plano, rebanando una tira delgada y llamando mayoría a todo lo demás. El accuracy marca 0.96, que parece un modelo funcional hasta que encuentras el recall: 0.19. De los 27 casos minoritarios reales del test set atrapa cinco. Ese 0.96 de accuracy te está comprando un modelo que se pierde más de cuatro de cada cinco de los casos que fue construido para encontrar, y si solo imprimieras el accuracy, lo mandarías a producción.
Avanza y mira trabajar a los arreglos. El oversampling rota la frontera hasta dejarla plana y la empuja sobre el clúster minoritario; el undersampling y los pesos de clase aterrizan en casi la misma línea. El recall brinca a 0.96, luego a 1.0 — el modelo ahora atrapa cada caso minoritario del test set. ¿Y el accuracy? Se acomoda alrededor de 0.82. Se movió, pero mira la escala de los dos movimientos: el recall se multiplicó por cinco mientras el accuracy resbaló cuatro puntos, y esos cuatro puntos nunca valieron nada porque eran cuatro puntos por encima de un piso de 0.955. Ese es el trade que hacen estos arreglos, dicho con honestidad. Hay un costo, y es la precision, que se desploma de 0.71 a 0.20 conforme la frontera aplanada se lleva de paso una multitud de puntos mayoritarios junto con la minoría. El F1, que tiene que responder por ambas, apenas sube de 0.29 a 0.33 en lugar de dispararse, porque compraste recall pagando con precision.
Ahora mira el único número que se queda quieto. El AUC está en 0.93 en el frame naive y en 0.94 en cada frame arreglado — no se mueve, porque ninguno de estos arreglos volvió al modelo mejor para rankear un punto minoritario por encima de uno mayoritario. Movieron el umbral, en la práctica, no el modelo. Esa es la lección más profunda de la animación: el modelo naive nunca fue malo separando las clases, fue malo en dónde trazó la línea, y resamplear y reponderar son maneras elaboradas de redibujar la línea. El último frame lo demuestra — el mismo modelo naive, sin reentrenar, solo el umbral de decisión movido de 0.50 a 0.25, y la frontera se desliza en paralelo hacia un punto de operación mucho mejor con F1 de 0.39, el mejor de la página, gratis.
La implementación completa
El toolkit entero, NumPy puro, sin ninguna librería de resampling. Este es el archivo que corrió la animación:
"""Fixing imbalanced classes, from scratch.
When one class is rare, accuracy stops meaning anything and the usual fixes
are all about putting weight back on the minority. This file is the three
classic rebalancers, built in pure NumPy so nothing is hidden:
- the majority-class baseline (the number every real model must beat),
- random OVERSAMPLING of the minority,
- random UNDERSAMPLING of the majority,
- the balanced CLASS WEIGHTS that reweight the loss instead of the data.
No imbalanced-learn, no resampling library — just index juggling with a
seeded NumPy generator so every draw is repeatable. The classifier itself is
scikit-learn's LogisticRegression (see library.py); these functions produce
the training sets and weights we hand it. Convention: label 1 is the minority,
the positive class we are trying to catch; label 0 is the majority.
"""
import numpy as np
# region: majority_baseline
def majority_class(y):
"""The label that appears most often — what a lazy model would always guess."""
y = np.asarray(y).astype(int)
return int(np.argmax(np.bincount(y)))
def majority_baseline_accuracy(y):
"""Accuracy of the model that predicts the majority class for everyone.
It equals the majority-class fraction: if 95% of rows are class 0, always
answering 0 is right 95% of the time while learning nothing. Unitless, in
[0, 1]. This is the bar any classifier has to clear before its accuracy
means a single thing, and on imbalanced data it sits embarrassingly high.
"""
y = np.asarray(y).astype(int)
counts = np.bincount(y)
return float(counts.max() / counts.sum())
# endregion
# region: oversample
def random_oversample(X, y, seed=0):
"""Random oversampling: duplicate minority rows until the classes match.
Keep every majority row; draw minority rows WITH replacement until there
are as many of them as the majority class. Nothing new is invented — the
same minority points just show up several times, so they weigh more in the
fit. Seeded, so the draw is identical every run. Returns the balanced,
shuffled (X, y). Resample the TRAINING set only — never the test set.
"""
X = np.asarray(X, dtype=float)
y = np.asarray(y).astype(int)
rng = np.random.default_rng(seed)
counts = np.bincount(y)
n_target = counts.max()
parts_X, parts_y = [], []
for c in range(len(counts)):
idx = np.flatnonzero(y == c)
if len(idx) == 0:
continue
take = idx if len(idx) >= n_target else rng.choice(idx, size=n_target, replace=True)
parts_X.append(X[take])
parts_y.append(y[take])
Xb = np.vstack(parts_X)
yb = np.concatenate(parts_y)
perm = rng.permutation(len(yb))
return Xb[perm], yb[perm]
# endregion
# region: undersample
def random_undersample(X, y, seed=0):
"""Random undersampling: drop majority rows until the classes match.
Keep every minority row; draw majority rows WITHOUT replacement down to the
minority count. Fast and it fixes the balance, but it throws real data on
the floor — the discarded majority rows are gone from the fit. Seeded and
repeatable. Returns the balanced, shuffled (X, y). Training set only.
"""
X = np.asarray(X, dtype=float)
y = np.asarray(y).astype(int)
rng = np.random.default_rng(seed)
counts = np.bincount(y)
n_target = counts[counts > 0].min()
parts_X, parts_y = [], []
for c in range(len(counts)):
idx = np.flatnonzero(y == c)
if len(idx) == 0:
continue
take = idx if len(idx) <= n_target else rng.choice(idx, size=n_target, replace=False)
parts_X.append(X[take])
parts_y.append(y[take])
Xb = np.vstack(parts_X)
yb = np.concatenate(parts_y)
perm = rng.permutation(len(yb))
return Xb[perm], yb[perm]
# endregion
# region: class_weights
def balanced_class_weights(y):
"""Per-class weights that make the loss treat the classes as equal.
w_c = n / (k * n_c)
total rows n, over the number of classes k times the count of class c. The
rare class gets a large weight, the common class a small one, so a mistake
on a minority row costs proportionally more in the loss than a mistake on a
majority row. No data is copied or dropped — only the penalty changes. This
is exactly scikit-learn's class_weight='balanced'. Returns {class: weight}.
"""
y = np.asarray(y).astype(int)
counts = np.bincount(y)
k = int(np.sum(counts > 0))
n = int(counts.sum())
return {c: float(n / (k * counts[c])) for c in range(len(counts)) if counts[c] > 0}
# endregion
# region: sample_weights
def sample_weights(y, class_weight):
"""Spread a per-class weight dict into a per-row weight vector.
Feed this to a classifier's sample_weight to reproduce class weighting by
hand: every row inherits its class's weight. It's the bridge that turns the
balanced_class_weights dict into the argument sklearn's fit() wants.
"""
y = np.asarray(y).astype(int)
return np.array([class_weight[int(c)] for c in y], dtype=float)
# endregion
La versión de librería
Los resampleadores y la fórmula de pesos son nuestros; el clasificador y la evaluación son de scikit-learn. El modelo se ajusta de la misma manera cada vez — lo único que cambia es qué datos o qué pesos recibe — así que los cuatro métodos son una comparación justa y no cuatro modelos distintos:
def fit_logreg(X, y, class_weight=None, sample_weight=None):
"""Plain logistic regression, optionally reweighted.
class_weight passes straight through to sklearn (e.g. 'balanced');
sample_weight lets us feed the per-row weights from impl.sample_weights.
Leave both None for the naive fit on whatever (X, y) you pass — that same
call trains the oversampled and undersampled models, just on rebalanced
data. random_state pins the solver so the boundary is reproducible.
"""
clf = LogisticRegression(max_iter=2000, class_weight=class_weight, random_state=0)
clf.fit(X, y, sample_weight=sample_weight)
return clf
La evaluación sale directo de sklearn.metrics: accuracy sobre todas las
filas, precision, recall y F1 sobre la minoría, y el AUC libre de umbral a
partir de los scores crudos. Estos son los números de la librería, y ese es el
punto — nosotros implementamos el resampling, no las métricas, así que dejamos
que la librería juzgue:
def predict_scores(clf, X):
"""The model's P(y = 1) — its minority score — for each row."""
return clf.predict_proba(X)[:, 1]
def score_all(y_true, y_score, threshold=0.5):
"""Every headline metric at one threshold, from sklearn.metrics.
accuracy is over all rows; precision, recall and F1 are on the minority
(positive) class; AUC is threshold-free, read from the raw scores. All five
are unitless in [0, 1]. On imbalanced data accuracy and AUC lean on the
easy majority, while precision/recall/F1 report on the class you care about.
"""
y_pred = (np.asarray(y_score) >= threshold).astype(int)
return {
"accuracy": float(accuracy_score(y_true, y_pred)),
"precision": float(precision_score(y_true, y_pred, zero_division=0)),
"recall": float(recall_score(y_true, y_pred, zero_division=0)),
"f1": float(f1_score(y_true, y_pred, zero_division=0)),
"auc": float(roc_auc_score(y_true, y_score)),
}
El almuerzo gratis del último frame viene de un helper más: barre los umbrales sobre los scores de entrenamiento y quédate con el que da el mejor F1. Ajustado en train, reportado en test — la misma disciplina que con el resampling, por la misma razón:
def best_f1_threshold(y_true, y_score):
"""The cutoff on the minority score that maximizes F1.
Choose it on the data you pass and always pass TRAIN scores — picking a
threshold on the test set is the same leakage sin as resampling it. Sweeps
the distinct score values and keeps the F1 winner. Returns the threshold.
"""
y_true = np.asarray(y_true).astype(int)
y_score = np.asarray(y_score, dtype=float)
best_t, best_f1 = 0.5, -1.0
for t in np.unique(y_score):
f = f1_score(y_true, (y_score >= t).astype(int), zero_division=0)
if f > best_f1:
best_f1, best_t = f, float(t)
return best_t
Los cuatro arreglos, lado a lado
Aquí están los cuatro métodos a través de las cinco métricas, cada valor sin
unidades en una escala de 0 a 1, directo de results.json. Léelo columna por
columna y la forma del capítulo entero queda en una sola gráfica:
El grupo del accuracy apenas se mueve — 0.96 para el modelo naive, alrededor de 0.82 para los tres arreglos — y si el accuracy fuera tu vara de medir, concluirías que los arreglos empeoraron ligeramente el modelo y te irías caminando. El grupo del recall cuenta la historia opuesta: 0.19 naive, luego 0.96, 1.0, 1.0. Los mismos cuatro modelos, y las dos métricas no se ponen de acuerdo sobre cuál es mejor porque están midiendo cosas distintas — al accuracy lo domina el 95% mayoritario que el modelo ya se sabía de memoria, el recall reporta solo sobre el 5% que de verdad te importa. La precision se mueve en sentido contrario, de 0.71 a alrededor de 0.20, la factura de ese recall. Y el AUC se queda plano cerca de 0.94 en los cuatro, confirmando lo que mostró la animación: son el mismo ranker en distintos puntos de operación, no cuatro calidades distintas de modelo.
Una advertencia honesta que esta gráfica hace concreta. El F1 casi no mejora, y
en estos datos la receta burda de "balancéalo y predice en 0.50" se pasa de la
raya — maximiza el recall con una precision tan baja que el F1 se estanca. El
punto de operación con umbral ajustado del último frame de la animación, que
results.json registra con accuracy 0.94, recall 0.41, precision 0.38 y F1
0.39, le gana en F1 a cada barra de aquí sin reentrenar nada. El resampling no
es la única palanca, y rara vez es la más afilada.
Conclusiones
Nunca reportes un accuracy a secas sobre datos desbalanceados. Lo primero que hay que calcular en cualquier problema de clasificación es el balance de clases, y lo segundo es la línea base mayoritaria, porque hasta que no sepas que un tabique saca 0.955 no puedes leer un 0.96 como nada. Si tu accuracy está cerca de la fracción mayoritaria, puede que tu modelo no haya aprendido nada, y el accuracy es el único número que jamás te lo va a decir.
Elige la métrica que corresponda al costo de la clase rara antes de tocar cualquier arreglo. Si el error caro es dejar pasar un caso — fraude, enfermedad, una pieza que falla — estás optimizando recall y vas a aceptar el golpe a la precision que cuestan estos arreglos. Si lo caro es una falsa alarma, no puedes simplemente balancear y olvidarte, porque eso es exactamente lo que hunde la precision aquí. F1 cuando de verdad necesitas ambas, y siempre junto a la matriz de confusión, nunca en su lugar.
Resamplea solo el training set, nunca el test set, y ponle semilla al resampling. El test set es tu estimación del mundo real, desbalanceado; rebalancearlo califica al modelo sobre una fantasía. Y resamplear es aleatoriedad dentro de tu pipeline, así que una corrida sin semilla no es reproducible — un resultado equivocado que no puedes recrear es peor que ningún resultado.
Ve primero por los pesos de clase. Es un solo argumento, conserva todos tus datos, evita el riesgo de memorización del oversampling y la pérdida de información del undersampling, y para un modelo lineal aterriza casi en el mismo lugar que el oversampling balanceado. Luego recuerda la palanca más barata de todas: el umbral. El AUC que no quiso moverse a través de los tres métodos de resampling te está diciendo que el modelo ya rankea bien las clases, y el arreglo honesto muchas veces no es reconstruir el training set sino mover el umbral de decisión al punto que corresponda a lo que de verdad te cuestan un caso perdido y una falsa alarma. Calibra el umbral al negocio, no a 0.50, y deja que la métrica que corresponde al costo de tu clase rara decida dónde queda.