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:
Magic: The Gathering
Pokémon
Yu-Gi-Oh!
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:
Una imagen es un tensor de números (alto × ancho × 3).
La convolución: un filtro que se desliza y produce un mapa de rasgos.
El pooling: reducir la resolución y ganar invariancia.
Armar y entrenar una CNN y clasificar con softmax.
La jerarquía de características (mapas de activación).
Evaluar con curvas de aprendizaje y matriz de confusión.
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 sysfrom pathlib import Pathimport numpy as npimport matplotlib.pyplot as pltfrom PIL import Imagefrom sklearn.model_selection import train_test_splitfrom tensorflow.keras import layers, modelsfrom tensorflow.keras.callbacks import EarlyStoppingimport tensorflow as tf# Reproducibilidad: misma corrida -> resultados comparablesnp.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 descargasys.path.append(str(Path.cwd().parent /"src"))import download_dataset as dl# Descarga si hace falta; si ya hay suficientes imagenes, usa la cacheconteos = 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 redcarpetas = [("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, _) inenumerate(carpetas):for p insorted((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 inenumerate(clases_es):print(f" {nombre}: {(y == idx).sum()} cartas")
# Una carta de ejemplo por juegofig, axes = plt.subplots(1, 3, figsize=(9, 3.5))for idx, ax inenumerate(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 inenumerate(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 inenumerate(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 Magicrecorte = carta[:8, :8, 0] # esquina 8x8 del canal Rojofig, 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 inrange(8):for j inrange(8): val = recorte[i, j] axes[1].text(j, i, int(val), ha="center", va="center", color="white"if val <128else"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 3fig, axes = plt.subplots(1, 4, figsize=(12, 3.5))axes[0].imshow(carta); axes[0].set_title("RGB (todos)")for c, (nombre, cmap) inenumerate([("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.0print(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 RGBreturn 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 inrange(salida.shape[0]):for j inrange(salida.shape[1]): region = plano[i:i + kh, j:j + kw] salida[i, j] = np.sum(region * kernel)return salida# Kernels 3x3 clasicossobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype="float32")blur = np.ones((3, 3), dtype="float32") /9.0gris = 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 inrange(salida.shape[0]):for j inrange(salida.shape[1]): region = plano[i * tam:(i +1) * tam, j * tam:(j +1) * tam] salida[i, j] = np.max(region)return salidareducido = 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.
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).
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()
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}")
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 isnotNoneelse"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 inenumerate(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 juegofor idx inrange(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.
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.
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_reportprint(classification_report(y_test, y_pred, target_names=clases_es, digits=3))
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 confianzafig, axes = plt.subplots(1, 4, figsize=(12, 3.6))for ax, i inzip(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 cartacolor_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 entrenamientocentroides = np.array([color_train[y_train == idx].mean(axis=0) for idx inrange(3)])# Cada carta de prueba se asigna al centroide de color mas cercanodist = 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.