Cerramos la lección anterior con una tabla en la que la regresión logística acertaba el 87,1 % de los pedidos y la línea base "nunca se devuelve" el 83,6 %. Diego lo resumió así: "tres puntos y medio; no me parece que compense montar todo esto". Tenía razón en desconfiar de la cifra, pero por el motivo contrario al que pensaba: la exactitud es una medida tosca que, con clases desequilibradas, esconde lo que de verdad importa. En esta lección aprenderemos a evaluar modelos como profesionales: la matriz de confusión y los cuatro tipos de resultado, cada uno con su coste en euros; precisión, sensibilidad, especificidad y F1; las curvas ROC y precisión-recall y el AUC; la elección del umbral según el coste (recuperando la banda de revisión humana de 02-04); las métricas de regresión (MAE, RMSE, R²) para la previsión de demanda; y las estrategias de validación (hold-out, validación cruzada, estratificada y temporal) que evitan que una única división afortunada nos engañe. Es importante porque la evaluación es lo que convierte un experimento en una decisión de negocio: sin métricas adecuadas y validación honesta no se puede saber si un modelo merece desplegarse.

Contenido

  1. Por qué la exactitud engaña
  2. La matriz de confusión y el coste de cada error
  3. Precisión, sensibilidad, especificidad y F1
  4. Curva ROC, AUC y curva precisión-recall
  5. Elegir el umbral según el coste
  6. Métricas de regresión: MAE, RMSE y R²
  7. Validación: hold-out, validación cruzada, estratificada y temporal
  8. Línea base obligatoria y los tres conjuntos: entrenamiento, validación y test
  9. Comparación correcta de los modelos de 04-04
  10. Errores Comunes y Consejos
  11. Ejercicios
  12. Conclusión

  1. Por qué la exactitud engaña

La exactitud (accuracy) es la proporción de aciertos. Es lo que devuelve score en clasificación y lo que hemos usado hasta ahora. Su problema aparece en cuanto las clases están desequilibradas: en NovaMarket se devuelve el 16,4 % de los pedidos, así que un "modelo" que diga siempre "no se devuelve" acierta el 83,6 %. Si la tasa fuera del 8 %, acertaría el 92 %; en detección de fraude con tarjeta, donde el fraude es el 0,1 %, un modelo inútil tendría un 99,9 % de exactitud. La cifra es alta y el modelo no sirve para nada, porque no detecta ni un solo caso de lo que se busca.

La lección de fondo: la exactitud trata igual todos los errores, pero para NovaMarket no es lo mismo no anticipar una devolución que revisar un pedido que iba bien. Para razonar hay que separar los tipos de acierto y de error.

  1. La matriz de confusión y el coste de cada error

La matriz de confusión cruza lo que predijo el modelo con lo que ocurrió realmente. Para el problema de devoluciones (clase positiva = "devuelto"):

Predicho: no devuelto Predicho: devuelto
Real: no devuelto Verdadero negativo (VN): pedido normal tratado como normal Falso positivo (FP): falsa alarma; se revisa o retiene un pedido correcto
Real: devuelto Falso negativo (FN): devolución no anticipada Verdadero positivo (VP): devolución detectada a tiempo

Cada casilla tiene un coste de negocio distinto, y ponerle números es la conversación más importante que Marta y Diego tienen en todo el proyecto:

  • Un FN (devolución que no se anticipó) cuesta a NovaMarket unos 30 €: transporte de vuelta, reacondicionamiento, gestión, y el descuento con el que se revende el producto abierto.
  • Un FP (falsa alarma) cuesta unos 5 €: el tiempo del equipo de Diego en revisar el pedido y la pequeña fricción con un cliente que no había hecho nada raro (recuerda 02-04: si esas fricciones se concentran en un grupo, el coste es también ético y legal).
  • Un VP permite actuar (confirmar el pedido con el cliente, reforzar el embalaje, no enviar hasta verificar) y evita buena parte de esos 30 €; un VN no cuesta nada.

Con scikit-learn, sobre el pipeline de 04-03 y la regresión logística de 04-04:

from novamarket_ml import generar_pedidos_ml, ensuciar_pedidos, preparar_pedidos, crear_preparacion
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix

X, y = preparar_pedidos(ensuciar_pedidos(generar_pedidos_ml(3000, 42), 42))
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)

log = Pipeline([("prep", crear_preparacion()),
                ("modelo", LogisticRegression(max_iter=1000))]).fit(Xtr, ytr)
pred = log.predict(Xte)                     # clases con el umbral por defecto (0,5)
prob = log.predict_proba(Xte)[:, 1]         # probabilidad de devolución

matriz = confusion_matrix(yte, pred)        # filas: real; columnas: predicho
print(matriz)
vn, fp, fn, vp = matriz.ravel()
print(f"VN={vn}  FP={fp}  FN={fn}  VP={vp}")

def coste_negocio(matriz, coste_fp=5, coste_fn=30):
    """Traduce la matriz de confusión a euros: cada FP y cada FN tienen su coste."""
    vn, fp, fn, vp = matriz.ravel()
    return fp * coste_fp + fn * coste_fn

print("Coste del modelo:", coste_negocio(matriz), "€")

Salida:

[[610  17]
 [ 80  43]]
VN=610  FP=17  FN=80  VP=43
Coste del modelo: 2485 €

Lectura: de los 750 pedidos de test, 123 se devolvieron. El modelo detectó 43 (VP), se le escaparon 80 (FN) y dio 17 falsas alarmas (FP). En euros: 17 × 5 + 80 × 30 = 2.485 €. La misma función con la línea base "siempre no" (VP = 0, FN = 123) da 3.690 €, y con la regla de Diego (importe > 300: 19 VP, 104 FN, 15 FP) da 3.195 €. Ahora la comparación tiene un lenguaje que Diego entiende: el modelo ahorra 1.205 € por cada 750 pedidos respecto a no hacer nada, y 710 € respecto a su regla; con 3.000 pedidos diarios, unos 4.800 € al día frente a la línea base. Y aún no hemos ajustado el umbral (sección 5).

  1. Precisión, sensibilidad, especificidad y F1

De la matriz de confusión se derivan las métricas estándar. Con nuestros números (VN 610, FP 17, FN 80, VP 43):

Métrica Fórmula Pregunta que responde Valor
Exactitud (accuracy) (VP + VN) / total ¿Qué proporción de pedidos clasifica bien? 653/750 = 0,871
Precisión (precision) VP / (VP + FP) De los que marca como devolución, ¿cuántos lo son? (calidad de la alarma) 43/60 = 0,717
Sensibilidad / recall / exhaustividad VP / (VP + FN) De las devoluciones reales, ¿cuántas detecta? (cobertura) 43/123 = 0,350
Especificidad VN / (VN + FP) De los pedidos normales, ¿cuántos deja en paz? 610/627 = 0,973
F1 2 · precisión · recall / (precisión + recall) Media armónica de precisión y recall: alta solo si ambas lo son 0,470

Ahora la historia real del modelo es visible: cuando marca un pedido acierta el 72 % de las veces (buena precisión), pero solo detecta el 35 % de las devoluciones (recall bajo). Con la exactitud no había forma de saberlo. Precisión y recall están en tensión: para detectar más devoluciones hay que marcar más pedidos, y entre ellos habrá más falsas alarmas; el F1 resume el compromiso en un número y es la métrica más habitual para comparar clasificadores con clases desequilibradas cuando no se dispone de costes.

classification_report imprime todo esto por clase:

from sklearn.metrics import classification_report
print(classification_report(yte, pred, target_names=["no devuelto", "devuelto"], digits=3))
              precision    recall  f1-score   support
 no devuelto      0.884     0.973     0.926       627
    devuelto      0.717     0.350     0.470       123
    accuracy                          0.871       750
   macro avg      0.800     0.661     0.698       750
weighted avg      0.857     0.871     0.851       750

Fíjate en que la clase mayoritaria tiene un F1 de 0,93 y la minoritaria de 0,47: los promedios "weighted" (ponderados por tamaño) esconden lo mal que va la clase que importa; el "macro" (media simple) lo refleja mejor.

  1. Curva ROC, AUC y curva precisión-recall

Todas las métricas anteriores dependen del umbral con el que convertimos la probabilidad en clase (0,5 por defecto). Las curvas evalúan el modelo para todos los umbrales a la vez:

  • La curva ROC dibuja, para cada umbral, la tasa de verdaderos positivos (recall) frente a la tasa de falsos positivos (1 − especificidad). Un modelo aleatorio da la diagonal; uno perfecto sube por la izquierda hasta la esquina superior. El AUC (área bajo la curva) resume la curva en un número entre 0,5 (azar) y 1 (perfecto), y tiene una interpretación intuitiva: es la probabilidad de que el modelo dé más puntuación a una devolución real que a un pedido normal elegidos al azar. No depende del umbral ni del desequilibrio de clases, lo que la hace ideal para comparar modelos.
  • La curva precisión-recall dibuja la precisión frente al recall para cada umbral. Es más informativa que la ROC cuando la clase positiva es rara, porque se centra en lo que pasa con esa clase; su resumen es la precisión media (average precision, AP). La línea base de un modelo aleatorio no es 0,5, sino la tasa de positivos (0,164 aquí).
from sklearn.metrics import roc_auc_score, roc_curve, precision_recall_curve, average_precision_score
import numpy as np

print("AUC ROC:", round(roc_auc_score(yte, prob), 3))
print("Precisión media (AP):", round(average_precision_score(yte, prob), 3))

fpr, tpr, umbrales = roc_curve(yte, prob)           # puntos de la curva ROC
for u in (0.2, 0.3, 0.5):
    i = np.argmin(np.abs(umbrales - u))
    print(f"umbral {u}: tasa FP {fpr[i]:.3f}  recall {tpr[i]:.3f}")
precision, recall, umbrales_pr = precision_recall_curve(yte, prob)
# Para dibujar: plt.plot(fpr, tpr) y plt.plot(recall, precision) con Matplotlib

Salida:

AUC ROC: 0.844
Precisión media (AP): 0.598
umbral 0.2: tasa FP 0.175  recall 0.715
umbral 0.3: tasa FP 0.099  recall 0.545
umbral 0.5: tasa FP 0.030  recall 0.350

Un AUC de 0,84 es un modelo claramente útil (en el 84 % de las parejas devolución/no devolución ordena bien). Los tres puntos muestran el intercambio: bajando el umbral de 0,5 a 0,2, el recall sube del 35 % al 72 % a cambio de molestar al 17,5 % de los pedidos normales. Cuál conviene no lo dice la curva: lo dicen los costes.

  1. Elegir el umbral según el coste

Con la función coste_negocio podemos recorrer umbrales y elegir el que minimiza los euros:

print("umbral  precisión  recall  FP   FN   coste €  marcados")
for u in (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7):
    pred_u = (prob >= u).astype(int)
    m = confusion_matrix(yte, pred_u)
    prec = m[1, 1] / max(m[1, 1] + m[0, 1], 1)
    rec = m[1, 1] / (m[1, 1] + m[1, 0])
    print(f"{u:5.1f}   {prec:8.3f}  {rec:6.3f}  {m[0,1]:3d}  {m[1,0]:3d}  {coste_negocio(m):6d}   {pred_u.sum():4d}")

Salida:

umbral  precisión  recall  FP   FN   coste €  marcados
  0.1      0.325   0.854  218   18    1630    323
  0.2      0.440   0.715  112   35    1610    200
  0.3      0.528   0.545   60   56    1980    127
  0.4      0.596   0.455   38   67    2200     94
  0.5      0.717   0.350   17   80    2485     60
  0.6      0.886   0.252    4   92    2780     35
  0.7      0.842   0.130    3  107    3225     19

Con costes 5 €/30 €, el umbral óptimo es 0,2: coste 1.610 € frente a los 2.485 € del umbral por defecto, un 35 % menos. El precio es marcar 200 pedidos de 750 (el 27 %), algo que a Diego le parece excesivo operativamente. Aquí es donde encaja la banda de revisión humana de 02-04: en lugar de una única frontera, tres zonas. Con probabilidad ≥ 0,5 se actúa automáticamente (60 pedidos, 43 devoluciones reales); entre 0,2 y 0,5 se pasa a revisión humana (140 pedidos, de los que 45 se devolvieron: la revisora encuentra casi una devolución de cada tres); por debajo de 0,2 se deja pasar (550 pedidos, 35 devoluciones que se escapan). Si una revisión cuesta 3 € y la revisora acierta, el coste total ronda los 1.555 €, y además el sistema genera datos etiquetados a mano para mejorar el modelo. Fíjate en el orden lógico: primero se mide el modelo con AUC y curvas, después se decide el umbral con costes y capacidad operativa; nunca se ajusta el umbral para "que la exactitud quede bonita".

  1. Métricas de regresión: MAE, RMSE y R²

Para la previsión de demanda no hay clases: el error es una distancia entre lo previsto y lo real. Tres métricas:

Métrica Fórmula Interpretación Cuándo
MAE (error absoluto medio) media de |real − previsto| Error típico en las unidades del problema (unidades vendidas) Comunicar a negocio; robusta a valores extremos
RMSE (raíz del error cuadrático medio) √(media de (real − previsto)²) Como el MAE pero penaliza más los errores grandes; siempre ≥ MAE Cuando un error grande es mucho peor que varios pequeños
(coeficiente de determinación) 1 − (error cuadrático del modelo / error cuadrático de predecir la media) Fracción de la variabilidad explicada: 1 perfecto, 0 igual que la media, negativo peor que la media Comparar modelos entre sí; no dice el error en unidades

Sobre el modelo lineal con estacionalidad de 04-04 (entrenado con las semanas 1-78, evaluado en 79-104):

from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.linear_model import LinearRegression
from novamarket_ml import generar_demanda_semanal

d = generar_demanda_semanal(104, 42)
d["sen"] = np.sin(2 * np.pi * d["semana"] / 52); d["cos"] = np.cos(2 * np.pi * d["semana"] / 52)
d["black_friday"] = ((d["semana"] - 1) % 52 == 47).astype(int)
feats = ["semana", "sen", "cos", "black_friday"]
train, test = d[d["semana"] <= 78], d[d["semana"] > 78]

reg = LinearRegression().fit(train[feats], train["unidades"])
prev = reg.predict(test[feats])
print("MAE :", round(mean_absolute_error(test["unidades"], prev), 1))
print("RMSE:", round(np.sqrt(mean_squared_error(test["unidades"], prev)), 1))
print("R²  :", round(r2_score(test["unidades"], prev), 3))

base = np.full(len(test), train["unidades"].iloc[-1])      # línea base: repetir la última semana
print("Línea base MAE:", round(mean_absolute_error(test["unidades"], base), 1),
      " R²:", round(r2_score(test["unidades"], base), 3))

Salida:

MAE : 16.8
RMSE: 20.2
R²  : 0.829
Línea base MAE: 39.0  R²: -0.224

El modelo se equivoca en unas 17 unidades por semana de media (sobre ventas de 550-670), el RMSE algo mayor (20) indica que hay alguna semana con error grande (la máxima desviación es de 43 unidades, en la semana 95), y explica el 83 % de la variabilidad. La línea base ingenua ("la semana que viene venderemos lo mismo que esta") tiene un MAE de 39 y un R² negativo: peor que predecir la media del periodo. La recta simple de 04-02 tenía MAE 47 y R² −0,44. Sin línea base, un MAE de 17 no significa nada; con ella, sabemos que el modelo reduce el error a menos de la mitad.

  1. Validación: hold-out, validación cruzada, estratificada y temporal

Hasta ahora hemos usado una única división entrenamiento/test (hold-out). Es rápida, pero el resultado depende de qué pedidos cayeron en cada lado: repitiendo la división con diez semillas distintas, el AUC de la logística oscila entre 0,80 y 0,86. Con un solo test podríamos haber tenido suerte (o mala suerte) y sacar conclusiones equivocadas al comparar dos modelos que se diferencian en 0,02.

La validación cruzada k-fold resuelve el problema: se divide el conjunto en k trozos (folds), se entrena k veces usando cada trozo una vez como test y los k−1 restantes como entrenamiento, y se promedian las k medidas. Todos los datos se usan para evaluar y para entrenar, y la desviación entre folds indica la incertidumbre. Variantes:

  • Estratificada (StratifiedKFold): cada fold conserva la proporción de clases; imprescindible con clases desequilibradas.
  • Temporal (TimeSeriesSplit): para series, los folds respetan el orden: se entrena con el pasado y se evalúa con el bloque siguiente, nunca al revés.
  • Leave-one-out, grupos (GroupKFold, para que los pedidos de un mismo cliente no se repartan entre entrenamiento y test): otras variantes para casos concretos.
from sklearn.model_selection import cross_val_score, StratifiedKFold, TimeSeriesSplit

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
pipe = Pipeline([("prep", crear_preparacion()), ("modelo", LogisticRegression(max_iter=1000))])
aucs = cross_val_score(pipe, X, y, cv=cv, scoring="roc_auc")     # ajusta el pipeline en cada fold
print("AUC por fold:", aucs.round(3), " media:", round(aucs.mean(), 3), "±", round(aucs.std(), 3))

tscv = TimeSeriesSplit(n_splits=4, test_size=13)                  # bloques de 13 semanas (un trimestre)
for i, (tr_idx, te_idx) in enumerate(tscv.split(d)):
    print(f"fold {i}: entrena semanas 1-{d['semana'].iloc[tr_idx[-1]]}, "
          f"evalúa {d['semana'].iloc[te_idx[0]]}-{d['semana'].iloc[te_idx[-1]]}")
maes = -cross_val_score(LinearRegression(), d[feats], d["unidades"], cv=tscv,
                        scoring="neg_mean_absolute_error")
print("MAE por trimestre:", maes.round(1), " media:", round(maes.mean(), 1))

Salida:

AUC por fold: [0.842 0.815 0.848 0.821 0.857]  media: 0.836 ± 0.016
fold 0: entrena semanas 1-52, evalúa 53-65
fold 1: entrena semanas 1-65, evalúa 66-78
fold 2: entrena semanas 1-78, evalúa 79-91
fold 3: entrena semanas 1-91, evalúa 92-104
MAE por trimestre: [25.2 16.4 13.  20. ]  media: 18.6

Explicación:

  • cross_val_score recibe el pipeline completo, y en cada fold vuelve a ajustar la imputación, el escalado y el modelo solo con la parte de entrenamiento de ese fold: la validación cruzada hereda la protección contra fugas de 04-03. scoring="roc_auc" elige la métrica; hay decenas ("f1", "recall", "neg_mean_absolute_error"...; scikit-learn usa el criterio "más alto es mejor", de ahí el signo negativo en los errores).
  • El AUC medio de la logística es 0,836 ± 0,016. Cuando comparemos dos modelos, una diferencia menor que esa desviación no es concluyente.
  • En la serie, cada fold entrena con más historia y evalúa el trimestre siguiente, exactamente como se usará el modelo en producción. El primer fold, entrenado con un solo año, es el peor (25 unidades): con un año no se puede estimar bien la estacionalidad anual. Si en lugar de TimeSeriesSplit usáramos KFold barajado, el MAE medio bajaría a unas 16,7 unidades, una estimación optimista que en producción no se cumpliría.

  1. Línea base obligatoria y los tres conjuntos: entrenamiento, validación y test

Dos reglas de disciplina cierran la lección:

Siempre una línea base. Antes de celebrar cualquier métrica, calcula la misma métrica para el modelo más tonto razonable: DummyClassifier(strategy="most_frequent") en clasificación (siempre la clase mayoritaria), la media o el último valor en regresión, y la regla manual vigente (la de Diego). Un modelo se justifica por la distancia a la línea base, en la métrica y en euros.

Tres conjuntos, no dos. Si usamos el test para elegir entre modelos, umbrales e hiperparámetros, dejamos de tener una medida honesta: cada decisión que tomamos mirándolo "filtra" información del test al modelo, y el resultado final será optimista. Por eso el flujo profesional separa tres conjuntos:

flowchart LR
    D[Datos históricos] --> T[Entrenamiento<br/>~60-70 %]
    D --> V[Validación<br/>~15-20 %]
    D --> S[Test<br/>~15-20 %]
    T -->|fit| M[Modelos candidatos]
    V -->|comparar modelos,<br/>umbrales, hiperparámetros| M
    M -->|el elegido| F[Modelo final]
    S -->|UNA sola vez:<br/>estimación honesta| F
  • Entrenamiento: para ajustar parámetros (fit).
  • Validación: para tomar decisiones (qué algoritmo, qué umbral, qué hiperparámetros). En la práctica, la validación cruzada sobre el conjunto de entrenamiento hace este papel sin necesidad de un trozo aparte.
  • Test: se toca una sola vez, al final, para estimar el rendimiento del modelo ya elegido. En esta lección hemos usado el test para explorar umbrales con fines didácticos; en un proyecto real esa exploración se haría con validación cruzada. La siguiente lección (04-06) desarrolla el ajuste de hiperparámetros con este esquema.

  1. Comparación correcta de los modelos de 04-04

Repetimos la comparación de 04-04, esta vez con las métricas adecuadas y el coste de negocio:

from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
import pandas as pd

modelos = {
    "Regresión logística": LogisticRegression(max_iter=1000),
    "k vecinos (k=15)": KNeighborsClassifier(n_neighbors=15),
    "Árbol (prof. 4)": DecisionTreeClassifier(max_depth=4, random_state=42),
    "Bosque aleatorio": RandomForestClassifier(n_estimators=200, min_samples_leaf=5, random_state=42),
    "Línea base (siempre no)": DummyClassifier(strategy="most_frequent"),
}
filas = []
for nombre, m in modelos.items():
    p = Pipeline([("prep", crear_preparacion()), ("modelo", m)]).fit(Xtr, ytr)
    pr, pb = p.predict(Xte), p.predict_proba(Xte)[:, 1]
    filas.append([nombre, accuracy_score(yte, pr), precision_score(yte, pr, zero_division=0),
                  recall_score(yte, pr), f1_score(yte, pr), roc_auc_score(yte, pb),
                  coste_negocio(confusion_matrix(yte, pr))])
tabla = pd.DataFrame(filas, columns=["modelo", "exactitud", "precisión", "recall", "F1", "AUC", "coste €"])
print(tabla.set_index("modelo").round(3))

Salida:

                         exactitud  precisión  recall     F1    AUC  coste €
modelo
Regresión logística          0.871      0.717   0.350  0.470  0.844     2485
k vecinos (k=15)             0.841      0.667   0.065  0.119  0.737     3470
Árbol (prof. 4)              0.855      0.592   0.366  0.452  0.792     2495
Bosque aleatorio             0.859      0.689   0.252  0.369  0.825     2830
Línea base (siempre no)      0.836      0.000   0.000  0.000  0.500     3690

La tabla cuenta una historia mucho más rica que la exactitud sola:

  • k vecinos parecía "solo" 3 puntos peor que la logística en exactitud; en realidad detecta el 6,5 % de las devoluciones (recall 0,065) y su AUC de 0,74 lo deja lejos: es casi la línea base con maquillaje.
  • El árbol de profundidad 4 tiene menos exactitud que el bosque, pero mejor recall y F1 y menor coste (2.495 € frente a 2.830 €) con el umbral por defecto. Con la exactitud habríamos elegido el bosque.
  • La logística gana en todo (AUC 0,844, coste 2.485 €) y, tras ajustar el umbral (sección 5), baja a 1.610 €: un 56 % menos que la línea base. En validación cruzada, la ventaja de la logística sobre el bosque (0,836 frente a 0,822 de AUC medio) es del orden de la desviación entre folds, así que ambos son candidatos razonables y en 04-06 veremos si el bosque mejora ajustando sus hiperparámetros.
  • La línea base tiene AUC 0,5 y coste 3.690 €: es la referencia contra la que se justifica todo lo demás.

Marta ya puede responder a Diego con su lenguaje: "el modelo, con el umbral bien puesto, ahorra unos 2.000 € por cada 750 pedidos respecto a no hacer nada, y con la banda de revisión no os llegan más de 140 pedidos a revisar por cada 750".

Errores Comunes y Consejos

  • Reportar solo la exactitud. Con clases desequilibradas es casi siempre engañosa. Muestra la matriz de confusión, precisión, recall, F1 y AUC, y si puedes, euros.
  • Confundir precisión y recall. Precisión: de lo que marco, cuánto acierto. Recall: de lo que existe, cuánto encuentro. Cuál importa más depende del coste de FP frente a FN.
  • Dejar el umbral en 0,5 por defecto. El umbral es una decisión de negocio; recórrelo con la función de coste y considera una banda de revisión humana.
  • Usar el test para decidir. Cada mirada al test para elegir algo lo contamina. Decide con validación cruzada; el test, una vez.
  • Barajar series temporales. Usa TimeSeriesSplit; el KFold barajado da un optimismo que en producción se paga.
  • Comparar modelos por una única división. Diferencias pequeñas pueden ser ruido; usa validación cruzada y mira la desviación entre folds.
  • Olvidar la línea base. Un MAE de 17 o un AUC de 0,84 no significan nada sin saber qué hace el modelo tonto y la regla actual.
  • Ignorar los grupos. Si un mismo cliente tiene pedidos en entrenamiento y en test, el modelo puede "reconocerlo"; GroupKFold por id_cliente lo evita.

Ejercicios

Ejercicio 1. Diego revisa los costes: tras hablar con logística, una devolución no anticipada cuesta 20 € (no 30) y una falsa alarma 8 € (hay que llamar al cliente). Recalcula la tabla de umbrales con coste_negocio(m, coste_fp=8, coste_fn=20). ¿Cambia el umbral óptimo? ¿En qué dirección y por qué?

Ejercicio 2. Calcula con validación cruzada estratificada de 5 folds el F1 y el recall de la logística y del árbol de profundidad 4 (scoring="f1" y scoring="recall"). ¿Cuál de los dos tiene mejor recall medio? ¿La diferencia supera la desviación entre folds? Comenta si cambiaría tu decisión respecto a la tabla de la sección 9.

Ejercicio 3. En la previsión de demanda, sustituye la línea base "última semana" por la media de las 4 últimas semanas de entrenamiento y por "la misma semana del año anterior" (unidades de la semana −52). Calcula el MAE de cada una en las semanas 79-104. ¿Cuál es mejor? ¿Por qué la del año anterior sale tan mal en esta serie, y qué habría que corregir para que fuera una línea base razonable?

Soluciones

Solución 1. Con FP a 8 € y FN a 20 €, el coste de dejar escapar una devolución baja y el de una falsa alarma sube, así que compensa marcar menos. Los costes quedan: 0,1 → 2.104 €; 0,2 → 1.596 €; 0,3 → 1.600 €; 0,4 → 1.644 €; 0,5 → 1.736 €. El mínimo numérico sigue en 0,2, pero ahora 0,3 está a solo 4 € y 0,4 a 48 € (con los costes originales, 0,3 costaba 370 € más que 0,2): la curva se ha aplanado y desplazado hacia umbrales más altos. En general, cuanto más caro es el FP en relación con el FN, más alto conviene poner el umbral (más precisión, menos recall), y viceversa. Cuando la curva de coste es plana alrededor del óptimo, decide el criterio operativo: 0,3 marca 127 pedidos frente a los 200 de 0,2 por prácticamente el mismo coste, así que Diego elegiría 0,3.

Solución 2. Con StratifiedKFold(5, shuffle=True, random_state=42), la logística obtiene un F1 medio de 0,50 (± 0,04) y un recall medio de 0,39 (± 0,04); el árbol de profundidad 4, F1 0,44 (± 0,07) y recall 0,33 (± 0,07) (los valores pueden variar unas centésimas según la versión). La logística es mejor en media en ambas métricas, pero la diferencia de recall (0,05) es del orden de la desviación entre folds, y el árbol es además mucho más variable de un fold a otro (la inestabilidad de los árboles que comentamos en 04-04). La decisión no cambia respecto a la sección 9: la logística sigue siendo preferible por AUC, F1 y estabilidad, pero la validación cruzada nos obliga a decirlo con precisión: "detecta algo más de devoluciones y de forma más estable; con una sola división esa ventaja podría no verse".

Solución 3. La media de las 4 últimas semanas de entrenamiento (unas 649 unidades) da un MAE de unas 35 unidades, algo mejor que "última semana" (39) porque promedia el ruido semanal. La "misma semana del año anterior" da un MAE de unas 118 unidades: sale muy mal porque la serie tiene una tendencia de 2,5 unidades por semana, es decir, unas 130 unidades más que un año antes, y la línea base ignora ese crecimiento. Para hacerla razonable habría que sumar la tendencia (por ejemplo, el crecimiento medio observado entre los dos años) o expresarla como "año anterior × factor de crecimiento"; así se convierte en una línea base estacional muy difícil de batir, y es la que Marta debería usar para justificar el modelo ante Diego. La lección: la línea base también debe ser sensata; una línea base absurda hace que cualquier modelo parezca bueno.

Conclusión

En esta lección hemos aprendido a evaluar con honestidad. La exactitud engaña con clases desequilibradas (83,6 % sin detectar ninguna devolución); la matriz de confusión separa VP, FP, FN y VN, y la función coste_negocio los traduce a euros (30 € por devolución no anticipada, 5 € por falsa alarma), el idioma de Diego. De ahí salen la precisión, la sensibilidad/recall, la especificidad y el F1, y las curvas ROC (con el AUC, 0,84 para la logística) y precisión-recall, que evalúan todos los umbrales a la vez. Después hemos elegido el umbral por coste (0,2 en lugar de 0,5, un 35 % menos de coste) y lo hemos combinado con la banda de revisión humana de 02-04. Para la demanda hemos usado MAE, RMSE y R² frente a una línea base. Y hemos sustituido la división única por validación cruzada estratificada y temporal (TimeSeriesSplit), hemos exigido una línea base y hemos separado entrenamiento, validación y test, con el test reservado para una única mirada final. La comparación de los modelos de 04-04 con estas herramientas ha cambiado la clasificación: la logística sigue primera, el árbol adelanta al bosque en coste y k vecinos queda desenmascarado.

Queda una pregunta abierta desde 04-04: el bosque aleatorio y el árbol sin límite acertaban mucho más en entrenamiento que en test, y k vecinos con k = 1 acertaba el 100 % en entrenamiento y el 81 % en test. En la última lección del módulo, Sobreajuste, Regularización y Ajuste de Hiperparámetros, pondremos nombre a ese fenómeno, veremos cómo detectarlo con curvas de aprendizaje y validación, qué técnicas lo controlan (regularización, poda, ensamblados) y cómo elegir los hiperparámetros con GridSearchCV sin tocar el test, cerrando el flujo completo que Marta ya domina.

Fundamentos de Inteligencia Artificial (IA)

Módulo 1: Introducción a la Inteligencia Artificial

Módulo 2: Principios Básicos de la IA

Módulo 3: Algoritmos en IA

Módulo 4: Aprendizaje Automático (Machine Learning)

Módulo 5: Redes Neuronales y Deep Learning

Módulo 6: Lógica y Sistemas Expertos

Módulo 7: Herramientas y Lenguajes de Programación en IA

Módulo 8: Proyectos y Casos de Estudio

Módulo 9: Ejercicios y Prácticas

Módulo 10: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados