Reconocimiento de Imágenes con CNN — ¿De qué TCG es esta carta?

Curso: CC3084 · Ciencia de Datos · Semestre 02, 2026 · UVG

En este cuaderno entrenamos una red neuronal convolucional (CNN) para una tarea concreta y divertida: dada la imagen de una carta, predecir de qué juego de cartas coleccionables (TCG) es:

Los tres tienen marcos, tipografías y estilos de arte muy distintos, así que es un caso ideal para ver cómo una CNN aprende a distinguir patrones visuales. De paso tocamos cada concepto de la clase de Reconocimiento de Imágenes:

Los datos vienen de las APIs oficiales de Scryfall (Magic), pokemontcg.io (Pokémon) y YGOPRODeck (Yu-Gi-Oh!).

1. Imports y reproducibilidad

Fijamos las semillas de NumPy y TensorFlow para que el cuaderno sea reproducible: cada corrida da resultados comparables.

Código
import sys
from pathlib import Path

import numpy as np
import matplotlib.pyplot as plt
from PIL import Image

from sklearn.model_selection import train_test_split
from tensorflow.keras import layers, models
from tensorflow.keras.callbacks import EarlyStopping
import tensorflow as tf

# Reproducibilidad: misma corrida -> resultados comparables
np.random.seed(42)
tf.random.set_seed(42)

print("TensorFlow", tf.__version__)
TensorFlow 2.21.0

2. Conjunto de datos: cartas de tres TCG

Usamos el script src/download_dataset.py, que baja imágenes de cartas de las APIs oficiales y las normaliza a cuadrados RGB de 128×128 en data/raw/<juego>/.

La primera vez que corras esta celda se descargan las cartas (necesitas internet); las siguientes veces usa la caché local. Ajusta per_class si quieres más o menos cartas por juego.

Si estás detrás de una red que bloquea Scryfall o YGOPRODeck, corre el script en una red abierta con python src/download_dataset.py y vuelve a ejecutar.

Código
# Hacemos visible el paquete src/ para reusar la logica de descarga
sys.path.append(str(Path.cwd().parent / "src"))
import download_dataset as dl

# Descarga si hace falta; si ya hay suficientes imagenes, usa la cache
conteos = dl.ensure_dataset(per_class=120)
print("imagenes por juego:", conteos)
[magic] ya hay 120 imagenes (>= 120); se omite
[pokemon] ya hay 120 imagenes (>= 120); se omite
[yugioh] ya hay 120 imagenes (>= 120); se omite
imagenes por juego: {'magic': 120, 'pokemon': 120, 'yugioh': 120}

Cargar las imágenes en memoria

Leemos las imágenes de disco, las redimensionamos a 96×96 (el tamaño de entrada de la red) y armamos:

  • X: tensor (n_imágenes, 96, 96, 3) — cada imagen es alto × ancho × 3 canales.
  • y: la etiqueta entera de cada imagen (0 = Magic, 1 = Pokémon, 2 = Yu-Gi-Oh!).
Código
IMG = 96  # tamano de entrada de la red

carpetas = [("magic", "Magic"), ("pokemon", "Pokémon"), ("yugioh", "Yu-Gi-Oh!")]
clases_es = [nombre for _, nombre in carpetas]

raw = Path("..") / "data" / "raw"
X, y = [], []
for idx, (carpeta, _) in enumerate(carpetas):
    for p in sorted((raw / carpeta).glob("*.jpg")):
        img = Image.open(p).convert("RGB").resize((IMG, IMG))
        X.append(np.asarray(img))
        y.append(idx)

X = np.array(X, dtype="float32")
y = np.array(y)

print(f"X: {X.shape}  (imágenes, alto, ancho, canales)")
for idx, nombre in enumerate(clases_es):
    print(f"  {nombre}: {(y == idx).sum()} cartas")
X: (360, 96, 96, 3)  (imágenes, alto, ancho, canales)
  Magic: 120 cartas
  Pokémon: 120 cartas
  Yu-Gi-Oh!: 120 cartas
Código
# Una carta de ejemplo por juego
fig, axes = plt.subplots(1, 3, figsize=(9, 3.5))
for idx, ax in enumerate(axes):
    i = np.where(y == idx)[0][0]
    ax.imshow(X[i].astype("uint8"))
    ax.set_title(clases_es[idx])
    ax.axis("off")
fig.suptitle("Una carta de ejemplo por juego")
plt.tight_layout()
plt.show()

2.1 Exploración: ¿en qué se diferencian visualmente los tres juegos?

Antes de entrenar, vale la pena mirar los datos. Una forma rápida de intuir qué va a aprender la red es promediar todas las cartas de cada juego pixel a pixel. Esa “carta promedio” borra el arte particular de cada carta y deja a la vista lo que tienen en común: la estructura del marco (bordes, franjas de título y de texto) y la paleta de color típica de cada juego.

Código
fig, axes = plt.subplots(1, 3, figsize=(9, 3.5))
color_promedio = []
for idx, ax in enumerate(axes):
    media = X[y == idx].mean(axis=0)                 # promedio pixel a pixel
    color_promedio.append(media.reshape(-1, 3).mean(axis=0))  # color RGB medio
    ax.imshow(media.astype("uint8"))
    ax.set_title(clases_es[idx])
    ax.axis("off")
fig.suptitle("Carta 'promedio' de cada juego (revela el marco típico)")
plt.tight_layout()
plt.show()

color_promedio = np.array(color_promedio)
print("Color RGB promedio por juego (0-255):")
for idx, nombre in enumerate(clases_es):
    r, g, b = color_promedio[idx]
    print(f"  {nombre:10s} R={r:5.1f}  G={g:5.1f}  B={b:5.1f}")

Color RGB promedio por juego (0-255):
  Magic      R=122.8  G=117.4  B=112.2
  Pokémon    R=162.1  G=161.1  B=119.5
  Yu-Gi-Oh!  R=122.6  G=118.8  B=121.7

Interpretación. Las cartas promedio salen borrosas —cada juego mezcla miles de artes distintos— pero el marco sobrevive al promedio porque está casi en la misma posición en todas las cartas: se alcanzan a distinguir la franja del título arriba y la caja de texto abajo, con proporciones y colores propios de cada juego. Además, el color promedio ya separa bastante a los tres (cada uno tiene una paleta dominante distinta).

Esto adelanta dos cosas: (1) la tarea es relativamente fácil porque las clases son muy separables, y (2) la red no necesita “entender” la carta, le basta con aprender esas regularidades del marco y el color. Más adelante (sección 14) lo cuantificamos con una línea base trivial que solo usa el color promedio.

3. La imagen es números

El punto de partida: para la red, una carta no es un dibujo, son números. Tomamos una carta y miramos un recorte de 8×8 pixeles de uno de sus canales. Cada celda es la intensidad de ese pixel (0 = oscuro, 255 = brillante).

Código
carta = X[0].astype("uint8")     # una carta de Magic
recorte = carta[:8, :8, 0]       # esquina 8x8 del canal Rojo

fig, axes = plt.subplots(1, 2, figsize=(11, 5))
axes[0].imshow(carta)
axes[0].set_title(f"Carta completa — {clases_es[y[0]]}")
axes[0].axis("off")

axes[1].imshow(recorte, cmap="viridis")
axes[1].set_title("Recorte 8×8 del canal Rojo (valores de pixel)")
for i in range(8):
    for j in range(8):
        val = recorte[i, j]
        axes[1].text(j, i, int(val), ha="center", va="center",
                     color="white" if val < 128 else "black", fontsize=8)
axes[1].set_xticks([]); axes[1].set_yticks([])
plt.tight_layout()
plt.show()

Código
# Los 3 canales RGB por separado: la imagen es un tensor alto x ancho x 3
fig, axes = plt.subplots(1, 4, figsize=(12, 3.5))
axes[0].imshow(carta); axes[0].set_title("RGB (todos)")
for c, (nombre, cmap) in enumerate([("Rojo", "Reds"), ("Verde", "Greens"),
                                    ("Azul", "Blues")]):
    axes[c + 1].imshow(carta[:, :, c], cmap=cmap)
    axes[c + 1].set_title(f"canal {nombre}")
for ax in axes:
    ax.axis("off")
plt.tight_layout()
plt.show()

4. Normalización

Las redes neuronales son sensibles a la escala: pixeles en el rango 0-255 hacen que el entrenamiento sea inestable. Escalamos todo a [0, 1] dividiendo entre 255.

Código
X = X / 255.0
print(f"nuevo rango de pixel: [{X.min():.1f}, {X.max():.1f}]")
nuevo rango de pixel: [0.0, 1.0]

5. La convolución

El concepto central: un filtro (kernel) pequeño se desliza por la imagen y, en cada posición, calcula una suma ponderada. El resultado es un mapa de rasgos que marca dónde aparece el patrón que el filtro detecta.

Antes de entrenar nada, apliquemos filtros clásicos a mano para ver qué hace una convolución sobre una carta: un detector de bordes (Sobel) y un desenfoque.

Código
def a_grises(imagen):
    # promedio ponderado de los canales RGB
    return imagen @ np.array([0.299, 0.587, 0.114], dtype="float32")

def convolucion2d(plano, kernel):
    # convolucion 'valid' de un plano 2D con un kernel pequeno
    kh, kw = kernel.shape
    h, w = plano.shape
    salida = np.zeros((h - kh + 1, w - kw + 1), dtype="float32")
    for i in range(salida.shape[0]):
        for j in range(salida.shape[1]):
            region = plano[i:i + kh, j:j + kw]
            salida[i, j] = np.sum(region * kernel)
    return salida

# Kernels 3x3 clasicos
sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype="float32")
blur = np.ones((3, 3), dtype="float32") / 9.0

gris = a_grises(X[0])
mapa_bordes = convolucion2d(gris, sobel_x)
mapa_blur = convolucion2d(gris, blur)

fig, axes = plt.subplots(1, 3, figsize=(12, 4))
axes[0].imshow(gris, cmap="gray");        axes[0].set_title("Entrada (grises)")
axes[1].imshow(mapa_bordes, cmap="gray"); axes[1].set_title("Filtro de bordes (Sobel)")
axes[2].imshow(mapa_blur, cmap="gray");   axes[2].set_title("Filtro de desenfoque")
for ax in axes:
    ax.axis("off")
fig.suptitle("El filtro se desliza y marca dónde aparece su patrón")
plt.tight_layout()
plt.show()

6. Pooling

Después de una convolución, el max pooling reduce la resolución del mapa de rasgos tomando el máximo de cada bloque (aquí, 2×2). Esto reduce el tamaño y aporta invariancia a pequeñas traslaciones: si el patrón se mueve un pixel, el resultado casi no cambia.

Código
def max_pool2d(plano, tam=2):
    h, w = plano.shape
    salida = np.zeros((h // tam, w // tam), dtype="float32")
    for i in range(salida.shape[0]):
        for j in range(salida.shape[1]):
            region = plano[i * tam:(i + 1) * tam, j * tam:(j + 1) * tam]
            salida[i, j] = np.max(region)
    return salida

reducido = max_pool2d(mapa_bordes, tam=2)

fig, axes = plt.subplots(1, 2, figsize=(10, 4))
axes[0].imshow(mapa_bordes, cmap="gray")
axes[0].set_title(f"Mapa de rasgos — {mapa_bordes.shape[0]}×{mapa_bordes.shape[1]}")
axes[1].imshow(reducido, cmap="gray")
axes[1].set_title(f"Después de MaxPool 2×2 — {reducido.shape[0]}×{reducido.shape[1]}")
for ax in axes:
    ax.axis("off")
fig.suptitle("El pooling reduce la resolución y da invariancia a traslaciones")
plt.tight_layout()
plt.show()

7. Partición entrenamiento / prueba

Separamos las cartas en entrenamiento (80 %) y prueba (20 %). Usamos stratify=y para que cada juego quede representado en la misma proporción en ambos conjuntos, y una semilla fija para que la partición sea reproducible.

Código
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)
print(f"entrenamiento: {len(X_train)} cartas")
print(f"prueba:        {len(X_test)} cartas")
entrenamiento: 288 cartas
prueba:        72 cartas

8. Arquitectura de la CNN

Armamos la red que vimos en clase. La idea: apilar bloques Conv2D + MaxPooling2D que extraen características cada vez más abstractas, y al final una cabeza densa que clasifica en las 3 clases.

  • Dos bloques convolucionales (32 y 64 filtros de 3×3, activación relu).
  • Flatten para pasar de mapas 2D a un vector.
  • Dense(128) + Dropout(0.5) (regularización) + Dense(3, softmax).
  • Al frente, aumento de datos ligero (volteo y rotación pequeña): solo actúa en entrenamiento y hace al modelo más robusto ante fotos ligeramente distintas.
Código
model = models.Sequential([
    tf.keras.Input(shape=(IMG, IMG, 3)),
    # Aumento de datos: solo activo durante el entrenamiento
    layers.RandomFlip("horizontal"),
    layers.RandomRotation(0.05),
    # Bloque 1: filtros que detectan patrones simples (bordes, colores del marco)
    layers.Conv2D(32, (3, 3), activation="relu", name="conv2d_1"),
    layers.MaxPooling2D((2, 2)),
    # Bloque 2: combina los anteriores en patrones mas ricos
    layers.Conv2D(64, (3, 3), activation="relu", name="conv2d_2"),
    layers.MaxPooling2D((2, 2)),
    # Cabeza clasificadora
    layers.Flatten(),
    layers.Dense(128, activation="relu"),
    layers.Dropout(0.5),
    layers.Dense(3, activation="softmax"),   # 3 clases: Magic, Pokemon, Yu-Gi-Oh!
])

model.compile(optimizer="adam",
              loss="sparse_categorical_crossentropy",  # etiquetas enteras 0,1,2
              metrics=["accuracy"])
model.summary()
Model: "sequential_1"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                     Output Shape                  Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ random_flip_1 (RandomFlip)      │ (None, 96, 96, 3)      │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ random_rotation_1               │ (None, 96, 96, 3)      │             0 │
│ (RandomRotation)                │                        │               │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ conv2d_1 (Conv2D)               │ (None, 94, 94, 32)     │           896 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d_2 (MaxPooling2D)  │ (None, 47, 47, 32)     │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ conv2d_2 (Conv2D)               │ (None, 45, 45, 64)     │        18,496 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d_3 (MaxPooling2D)  │ (None, 22, 22, 64)     │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ flatten_1 (Flatten)             │ (None, 30976)          │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_2 (Dense)                 │ (None, 128)            │     3,965,056 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dropout_1 (Dropout)             │ (None, 128)            │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_3 (Dense)                 │ (None, 3)              │           387 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 3,984,835 (15.20 MB)
 Trainable params: 3,984,835 (15.20 MB)
 Non-trainable params: 0 (0.00 B)

9. Entrenamiento

Entrenamos con un conjunto de validación (validation_split=0.15) para vigilar el overfitting, y con early stopping que detiene el entrenamiento cuando la precisión de validación deja de mejorar y restaura los mejores pesos. Como las tres clases son visualmente muy distintas, la red aprende rápido.

Código
es = EarlyStopping(monitor="val_accuracy", patience=4, restore_best_weights=True)

hist = model.fit(
    X_train, y_train,
    validation_split=0.15,
    epochs=25,
    batch_size=32,
    callbacks=[es],
    verbose=2,
)

test_loss, test_acc = model.evaluate(X_test, y_test, verbose=0)
print(f"\nprecisión en el conjunto de prueba: {test_acc:.3f}")
Epoch 1/25
8/8 - 1s - 168ms/step - accuracy: 0.4918 - loss: 1.2031 - val_accuracy: 0.7273 - val_loss: 0.8026
Epoch 2/25
8/8 - 0s - 46ms/step - accuracy: 0.8566 - loss: 0.5635 - val_accuracy: 0.8182 - val_loss: 0.4702
Epoch 3/25
8/8 - 0s - 51ms/step - accuracy: 0.9467 - loss: 0.2080 - val_accuracy: 0.9545 - val_loss: 0.1189
Epoch 4/25
8/8 - 0s - 46ms/step - accuracy: 0.9754 - loss: 0.0640 - val_accuracy: 1.0000 - val_loss: 0.0127
Epoch 5/25
8/8 - 0s - 48ms/step - accuracy: 0.9877 - loss: 0.0498 - val_accuracy: 1.0000 - val_loss: 0.0119
Epoch 6/25
8/8 - 0s - 48ms/step - accuracy: 1.0000 - loss: 0.0120 - val_accuracy: 1.0000 - val_loss: 0.0190
Epoch 7/25
8/8 - 0s - 52ms/step - accuracy: 0.9877 - loss: 0.0246 - val_accuracy: 1.0000 - val_loss: 0.0045
Epoch 8/25
8/8 - 0s - 51ms/step - accuracy: 1.0000 - loss: 0.0074 - val_accuracy: 1.0000 - val_loss: 0.0049

precisión en el conjunto de prueba: 1.000

10. Curvas de aprendizaje

Graficamos precisión y pérdida de entrenamiento vs validación por época. Si la curva de validación se estanca o empeora mientras la de entrenamiento sigue mejorando, el modelo está memorizando (overfitting). El dropout y el aumento de datos ayudan a contenerlo.

Código
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].plot(hist.history["accuracy"], label="entrenamiento")
axes[0].plot(hist.history["val_accuracy"], label="validación")
axes[0].set_title("Precisión por época")
axes[0].set_xlabel("época"); axes[0].set_ylabel("precisión")
axes[0].legend(); axes[0].grid(alpha=0.3)

axes[1].plot(hist.history["loss"], label="entrenamiento")
axes[1].plot(hist.history["val_loss"], label="validación")
axes[1].set_title("Pérdida por época")
axes[1].set_xlabel("época"); axes[1].set_ylabel("pérdida")
axes[1].legend(); axes[1].grid(alpha=0.3)
plt.tight_layout()
plt.show()

11. Clasificar en vivo: ¿de qué TCG es esta carta?

El momento estelar: pasamos una carta por la CNN y miramos las 3 probabilidades que produce la capa softmax. La clase con mayor probabilidad es la predicción del modelo.

Código
def clasificar_y_graficar(imagen, etiqueta_real=None):
    probs = model.predict(imagen[np.newaxis, ...], verbose=0)[0]
    top = int(np.argmax(probs))

    fig, axes = plt.subplots(1, 2, figsize=(10, 4))
    axes[0].imshow(imagen)
    titulo = f"real: {clases_es[etiqueta_real]}" if etiqueta_real is not None else "carta"
    axes[0].set_title(titulo)
    axes[0].axis("off")

    colores = ["#d0d0d0"] * 3
    colores[top] = "#2c7fb8"     # resalta la clase ganadora
    axes[1].barh(clases_es, probs, color=colores)
    axes[1].set_xlim(0, 1)
    axes[1].set_xlabel("probabilidad")
    axes[1].set_title(f"predicción: {clases_es[top]} ({probs[top]:.1%})")
    for i, v in enumerate(probs):
        axes[1].text(v + 0.01, i, f"{v:.1%}", va="center", fontsize=9)
    plt.tight_layout()
    plt.show()

# Una carta de prueba de cada juego
for idx in range(3):
    i = np.where(y_test == idx)[0][0]
    clasificar_y_graficar(X_test[i], y_test[i])
WARNING:tensorflow:5 out of the last 9 calls to <function TensorFlowTrainer.make_predict_function.<locals>.one_step_on_data_distributed at 0x1282ae980> triggered tf.function retracing. Tracing is expensive and the excessive number of tracings could be due to (1) creating @tf.function repeatedly in a loop, (2) passing tensors with different shapes, (3) passing Python objects instead of tensors. For (1), please define your @tf.function outside of the loop. For (2), @tf.function has reduce_retracing=True option that can avoid unnecessary retracing. For (3), please refer to https://www.tensorflow.org/guide/function#controlling_retracing and https://www.tensorflow.org/api_docs/python/tf/function for  more details.

12. Qué ve la red (mapas de activación)

Podemos asomarnos dentro de la red y ver las salidas de sus capas convolucionales para una carta. Esto revela la jerarquía de características: las primeras capas detectan bordes y colores (útiles para el marco de la carta); las profundas, patrones más complejos.

Código
def mostrar_activaciones(imagen, nombre_capa, titulo, n=16):
    extractor = models.Model(inputs=model.inputs,
                             outputs=model.get_layer(nombre_capa).output)
    fmap = extractor.predict(imagen[np.newaxis, ...], verbose=0)[0]
    n = min(n, fmap.shape[-1])
    fig, axes = plt.subplots(4, 4, figsize=(8, 8))
    for k, ax in enumerate(axes.flat):
        if k < n:
            ax.imshow(fmap[..., k], cmap="viridis")
            ax.set_title(f"canal {k}", fontsize=8)
        ax.axis("off")
    fig.suptitle(titulo)
    plt.tight_layout()
    plt.show()

carta_demo = X_test[0]
mostrar_activaciones(carta_demo, "conv2d_1",
                     "Primera capa Conv2D — bordes y colores")
mostrar_activaciones(carta_demo, "conv2d_2",
                     "Segunda capa Conv2D — patrones más complejos")
WARNING:tensorflow:6 out of the last 12 calls to <function TensorFlowTrainer.make_predict_function.<locals>.one_step_on_data_distributed at 0x128483380> triggered tf.function retracing. Tracing is expensive and the excessive number of tracings could be due to (1) creating @tf.function repeatedly in a loop, (2) passing tensors with different shapes, (3) passing Python objects instead of tensors. For (1), please define your @tf.function outside of the loop. For (2), @tf.function has reduce_retracing=True option that can avoid unnecessary retracing. For (3), please refer to https://www.tensorflow.org/guide/function#controlling_retracing and https://www.tensorflow.org/api_docs/python/tf/function for  more details.

13. Matriz de confusión

Para ver dónde acierta y dónde falla el modelo, calculamos la matriz de confusión sobre el conjunto de prueba. Cada fila es el juego real; cada columna, el predicho. La diagonal son los aciertos.

Código
from sklearn.metrics import confusion_matrix

y_pred = model.predict(X_test, verbose=0).argmax(axis=1)
cm = confusion_matrix(y_test, y_pred, labels=[0, 1, 2])

fig, ax = plt.subplots(figsize=(6, 5))
im = ax.imshow(cm, cmap="Blues")
ax.set_xticks(range(3)); ax.set_yticks(range(3))
ax.set_xticklabels(clases_es); ax.set_yticklabels(clases_es)
ax.set_xlabel("predicción"); ax.set_ylabel("real")
ax.set_title("Matriz de confusión (conjunto de prueba)")
umbral = cm.max() / 2
for i in range(3):
    for j in range(3):
        ax.text(j, i, int(cm[i, j]), ha="center", va="center",
                color="white" if cm[i, j] > umbral else "black", fontsize=12)
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
plt.tight_layout()
plt.show()

Interpretación. La matriz es (casi) perfectamente diagonal: casi todos los aciertos caen donde el juego real coincide con el predicho, y hay muy pocos o ningún error fuera de la diagonal. Eso confirma lo que anticipamos en la exploración: los tres juegos son tan distintos que el problema es muy separable.

Cuidado con leer “100 %” como que el modelo es perfecto: el conjunto de prueba es pequeño y comparte el estilo de las mismas ediciones que vio en entrenamiento. Un número tan redondo es señal de una tarea fácil, no necesariamente de un modelo que generalizaría a cartas muy distintas (arte alternativo, fotos con reflejos, cartas nuevas). En la siguiente sección lo analizamos con más cuidado.

14. Análisis e interpretación de resultados

La precisión global no cuenta toda la historia. Aquí desglosamos el desempeño por clase, vemos de qué cartas dudó más el modelo y lo comparamos contra una línea base trivial para decidir si la CNN realmente aporta.

Código
from sklearn.metrics import classification_report

print(classification_report(y_test, y_pred, target_names=clases_es, digits=3))
              precision    recall  f1-score   support

       Magic      1.000     1.000     1.000        24
     Pokémon      1.000     1.000     1.000        24
   Yu-Gi-Oh!      1.000     1.000     1.000        24

    accuracy                          1.000        72
   macro avg      1.000     1.000     1.000        72
weighted avg      1.000     1.000     1.000        72

El reporte de clasificación desglosa, por juego:

  • precision: de las cartas que el modelo llamó “Magic”, ¿qué fracción lo era?
  • recall: de las cartas que de verdad eran “Magic”, ¿cuántas encontró?
  • f1-score: la media armónica de ambas (resume el balance).

Con clases tan separables los tres valores salen muy altos y parejos; si un juego tuviera precision o recall más bajos, ahí estaría el punto débil del modelo.

14.1 ¿De qué cartas dudó más el modelo?

Aunque acierte, el modelo no está igual de seguro en todas las cartas. La confianza es la probabilidad softmax de la clase ganadora. Ordenando las cartas de prueba por confianza vemos las que más “le costaron”: suelen ser cartas atípicas para su juego (arte a página completa, promocionales, marcos raros).

Código
probs_test = model.predict(X_test, verbose=0)
confianza = probs_test.max(axis=1)
peores = np.argsort(confianza)[:4]   # las 4 de menor confianza

fig, axes = plt.subplots(1, 4, figsize=(12, 3.6))
for ax, i in zip(axes, peores):
    ax.imshow(X_test[i])
    pred = clases_es[int(probs_test[i].argmax())]
    correcto = "✓" if probs_test[i].argmax() == y_test[i] else "✗"
    ax.set_title(f"{pred} {correcto}\nconfianza {confianza[i]:.0%}", fontsize=9)
    ax.axis("off")
fig.suptitle("Las 4 cartas de prueba con MENOR confianza")
plt.tight_layout()
plt.show()

print(f"confianza  mínima: {confianza.min():.1%}")
print(f"confianza  media:  {confianza.mean():.1%}")

confianza  mínima: 81.5%
confianza  media:  98.4%

Interpretación. Si incluso las cartas de menor confianza superan el ~90 %, el modelo está clasificando con mucho margen. Si alguna baja bastante (por ejemplo por debajo del 60 %), esa carta es un buen ejemplo para mostrar en clase: se ve por qué el modelo dudó y se conecta con la idea de que la red mira el estilo visual, no el significado de la carta.

14.2 ¿Vale la pena la CNN? Una línea base trivial

En la exploración vimos que cada juego tiene un color dominante distinto. ¿Y si clasificáramos usando solo el color promedio de cada carta, sin ninguna red? Construimos esa línea base: calculamos el color medio de cada juego con el entrenamiento y asignamos cada carta de prueba al color de clase más cercano. Es la misma lógica del notebook de series de tiempo: una CNN solo se justifica si le gana a lo trivial.

Código
# Color promedio (vector RGB) de cada carta
color_train = X_train.reshape(len(X_train), -1, 3).mean(axis=1)
color_test = X_test.reshape(len(X_test), -1, 3).mean(axis=1)

# Centroide = color promedio de cada clase, calculado SOLO con entrenamiento
centroides = np.array([color_train[y_train == idx].mean(axis=0) for idx in range(3)])

# Cada carta de prueba se asigna al centroide de color mas cercano
dist = np.linalg.norm(color_test[:, None, :] - centroides[None, :, :], axis=2)
y_base = dist.argmin(axis=1)
acc_base = (y_base == y_test).mean()

print(f"Precisión línea base (solo color promedio): {acc_base:.1%}")
print(f"Precisión de la CNN:                        {test_acc:.1%}")
print(f"Mejora de la CNN sobre la línea base:       {test_acc - acc_base:+.1%}")
Precisión línea base (solo color promedio): 68.1%
Precisión de la CNN:                        100.0%
Mejora de la CNN sobre la línea base:       +31.9%

Interpretación. El color promedio por sí solo ya acierta bastante —confirma que gran parte de la señal está en la paleta del marco—, pero se equivoca con cartas cuyo arte domina el color (una carta de Magic con arte muy amarillo puede confundirse con Pokémon). Ahí es donde la CNN gana: no mira un promedio, sino patrones espaciales (la forma del marco, la franja del título, el ícono de esquina) gracias a la convolución. Esa diferencia —de un color plano a patrones con estructura— es exactamente la ventaja de las redes convolucionales.