🏭 Caso de Uso

MLP vs CNN en Clasificación de Imágenes

Comparación práctica de MLP (capas densas) vs CNN (convoluciones) en clasificación binaria de gatos y perros.

🐍 Python 📓 Jupyter Notebook

MLP vs CNN en clasificación de imágenes pequeñas (gatos vs perros)

En este notebook vamos a comparar de forma práctica y didáctica dos familias de modelos para clasificación binaria de imágenes:

  • MLP sobre imágenes aplanadas (flatten + capas densas)
  • CNN con convoluciones y pooling

Usaremos un subconjunto de un dataset real de gatos y perros, con imágenes reescaladas a tamaño pequeño para que el experimento sea reproducible en entorno docente.

Objetivo didáctico

Queremos responder con evidencia experimental:

  1. ¿Qué ocurre cuando tratamos imágenes como vectores (MLP) frente a mapas espaciales (CNN)?
  2. ¿Cómo cambian accuracy, F1, tiempo de entrenamiento, tiempo de inferencia y número de parámetros?
  3. ¿Por qué las CNN suponen un avance para visión por computador?

Además, entrenaremos 4 modelos para una comparación más robusta:

  • MLP_A (pequeño)
  • MLP_B (más capacidad)
  • CNN_A (2 bloques conv)
  • CNN_B (3 bloques conv)

Fundamentos matemáticos/computacionales

1) Clasificación binaria

El modelo produce una probabilidad $\hat{p}(y=1\mid x)$ y decide clase por umbral (0.5).

La pérdida utilizada será Binary Cross-Entropy:

$$ \mathcal{L} = -\frac{1}{N}\sum_{i=1}^{N}\left[y_i\log(\hat{p}_i) + (1-y_i)\log(1-\hat{p}_i)\right] $$

2) MLP sobre imágenes

En un MLP, la imagen $H\times W\times C$ se aplana a un vector largo. Se pierde estructura espacial local.

$$ \mathbf{x}_{flat} \in \mathbb{R}^{H\cdot W\cdot C} $$

Luego se aplican capas densas:

$$ \mathbf{h}^{(l)} = \phi(\mathbf{W}^{(l)}\mathbf{h}^{(l-1)} + \mathbf{b}^{(l)}) $$

3) CNN

Una CNN mantiene la estructura 2D y aprende filtros locales:

$$ \mathbf{F}{k} = \sigma(\mathbf{K}{k} * \mathbf{X} + b_k) $$

donde $*$ es convolución. El pooling reduce dimensión y mejora robustez. Esta inductive bias suele funcionar mejor para imágenes.


Dataset y configuración experimental

  • Fuente: cats_vs_dogs desde tensorflow_datasets.
  • Tarea: clasificación binaria (gato/perro).
  • Preprocesado: resize a 64x64, normalización a $[0,1]$.
  • División: train / validación / test.
  • Métricas: Accuracy, Precision, Recall, F1, AUC.
  • Coste computacional: tiempo de entrenamiento, inferencia y número de parámetros.

Nota pedagógica: mantenemos modelos relativamente pequeños para resaltar conceptos y permitir ejecución en hardware modesto.

[1]
# Librerías y configuración global

import os
os.environ['XLA_FLAGS'] = '--xla_gpu_enable_triton_gemm=false'
os.environ['CUDA_VISIBLE_DEVICES'] = '-1'

import time
import warnings
warnings.filterwarnings('ignore')

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

import tensorflow_datasets as tfds

from sklearn.metrics import (
    accuracy_score, precision_score, recall_score, f1_score,
    roc_auc_score, confusion_matrix, ConfusionMatrixDisplay
)

sns.set_theme(style='whitegrid', context='notebook')
plt.rcParams['figure.figsize'] = (9, 5)

SEED = 42
np.random.seed(SEED)
tf.random.set_seed(SEED)
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1773748604.473794 3386189 port.cc:153] oneDNN custom operations are on. You may see slightly different numerical results due to floating-point round-off errors from different computation orders. To turn them off, set the environment variable `TF_ENABLE_ONEDNN_OPTS=0`.
I0000 00:00:1773748604.501880 3386189 cpu_feature_guard.cc:227] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 AVX_VNNI FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1773748605.200919 3386189 port.cc:153] oneDNN custom operations are on. You may see slightly different numerical results due to floating-point round-off errors from different computation orders. To turn them off, set the environment variable `TF_ENABLE_ONEDNN_OPTS=0`.

1) Carga de datos y EDA básico

Comenzamos con una exploración pequeña del dataset para entender su tamaño y visualizar muestras.

[2]
# Cargamos metadatos del dataset
builder = tfds.builder('cats_vs_dogs')
builder.download_and_prepare()
info = builder.info

print('Descripción:', info.description[:250], '...')
print('Número total de ejemplos:', info.splits['train'].num_examples)
print('Nombres de clases:', info.features['label'].names)
Descripción: A large set of images of cats and dogs. There are 1738 corrupted images that are dropped. ...
Número total de ejemplos: 23262
Nombres de clases: ['cat', 'dog']
[3]
# Configuración de tamaños y splits
IMG_SIZE = (64, 64)
BATCH_SIZE = 64
AUTOTUNE = tf.data.AUTOTUNE

# Cargamos el split principal y luego separamos train/val/test con porcentajes
raw_train = tfds.load('cats_vs_dogs', split='train[:70%]', as_supervised=True)
raw_val = tfds.load('cats_vs_dogs', split='train[70%:85%]', as_supervised=True)
raw_test = tfds.load('cats_vs_dogs', split='train[85%:100%]', as_supervised=True)

print('Splits definidos: 70/15/15')
Splits definidos: 70/15/15
E0000 00:00:1773748605.512156 3386189 cuda_platform.cc:52] failed call to cuInit: INTERNAL: CUDA error: Failed call to cuInit: CUDA_ERROR_NO_DEVICE: no CUDA-capable device is detected
I0000 00:00:1773748605.512174 3386189 cuda_diagnostics.cc:160] env: CUDA_VISIBLE_DEVICES="-1"
I0000 00:00:1773748605.512181 3386189 cuda_diagnostics.cc:163] CUDA_VISIBLE_DEVICES is set to -1 - this hides all GPUs from CUDA
I0000 00:00:1773748605.512187 3386189 cuda_diagnostics.cc:171] verbose logging is disabled. Rerun with verbose logging (usually --v=1 or --vmodule=cuda_diagnostics=1) to get more diagnostic output from this module
I0000 00:00:1773748605.512188 3386189 cuda_diagnostics.cc:176] retrieving CUDA diagnostic information for host: tnp01-4090
I0000 00:00:1773748605.512190 3386189 cuda_diagnostics.cc:183] hostname: tnp01-4090
I0000 00:00:1773748605.512284 3386189 cuda_diagnostics.cc:190] libcuda reported version is: 580.126.9
I0000 00:00:1773748605.512292 3386189 cuda_diagnostics.cc:194] kernel reported version is: 580.126.9
I0000 00:00:1773748605.512293 3386189 cuda_diagnostics.cc:284] kernel version seems to match DSO: 580.126.9
[4]
# Función de preprocesado

def preprocess(image, label):
    # Resize de imagen al tamaño objetivo
    image = tf.image.resize(image, IMG_SIZE)
    # Normalización [0,1]
    image = tf.cast(image, tf.float32) / 255.0
    return image, tf.cast(label, tf.float32)

train_ds = raw_train.map(preprocess, num_parallel_calls=AUTOTUNE)
val_ds = raw_val.map(preprocess, num_parallel_calls=AUTOTUNE)
test_ds = raw_test.map(preprocess, num_parallel_calls=AUTOTUNE)

# Pipeline de eficiencia
train_ds = train_ds.shuffle(3000, seed=SEED).batch(BATCH_SIZE).prefetch(AUTOTUNE)
val_ds = val_ds.batch(BATCH_SIZE).prefetch(AUTOTUNE)
test_ds = test_ds.batch(BATCH_SIZE).prefetch(AUTOTUNE)
[5]
# EDA visual: mostramos algunas imágenes y etiquetas
class_names = info.features['label'].names

plt.figure(figsize=(10, 7))
for images, labels in train_ds.take(1):
    for i in range(12):
        ax = plt.subplot(3, 4, i + 1)
        plt.imshow(images[i])
        plt.title(class_names[int(labels[i].numpy())])
        plt.axis('off')
plt.suptitle('Muestras de entrenamiento (gato/perro)')
plt.tight_layout()
plt.show()
I0000 00:00:1773748605.658277 3386375 tf_record_dataset_op.cc:396] The default buffer size is 262144, which is overridden by the user specified `buffer_size` of 8388608
Output
[6]
# Conteo aproximado de clases en train (para detectar desbalance)
cat_count, dog_count = 0, 0
for _, yb in train_ds:
    y_np = yb.numpy().astype(int)
    cat_count += np.sum(y_np == 0)
    dog_count += np.sum(y_np == 1)

counts = pd.DataFrame({'Clase': ['cat', 'dog'], 'Count': [cat_count, dog_count]})
print(counts)

plt.figure(figsize=(5, 4))
sns.barplot(data=counts, x='Clase', y='Count', palette='viridis')
plt.title('Distribución de clases en train')
plt.tight_layout()
plt.show()
  Clase  Count
0   cat   8195
1   dog   8088
Output

2) Modelos a comparar

Definimos 4 arquitecturas: 2 MLP y 2 CNN.

[7]
# Funciones constructoras de modelos

def build_mlp_a(input_shape=(64, 64, 3)):
    model = keras.Sequential([
        layers.Input(shape=input_shape),
        layers.Flatten(),
        layers.Dense(256, activation='relu'),
        layers.Dropout(0.2),
        layers.Dense(128, activation='relu'),
        layers.Dense(64, activation='relu'),
        layers.Dense(1, activation='sigmoid')
    ], name='MLP_A')
    return model


def build_mlp_b(input_shape=(64, 64, 3)):
    model = keras.Sequential([
        layers.Input(shape=input_shape),
        layers.Flatten(),
        layers.Dense(512, activation='relu'),
        layers.Dropout(0.3),
        layers.Dense(256, activation='relu'),
        layers.Dropout(0.2),
        layers.Dense(128, activation='relu'),
        layers.Dense(1, activation='sigmoid')
    ], name='MLP_B')
    return model


def build_cnn_a(input_shape=(64, 64, 3)):
    model = keras.Sequential([
        layers.Input(shape=input_shape),
        layers.Conv2D(32, 3, padding='same', activation='relu'),
        layers.MaxPooling2D(),
        layers.Conv2D(64, 3, padding='same', activation='relu'),
        layers.MaxPooling2D(),
        layers.Flatten(),
        layers.Dense(128, activation='relu'),
        layers.Dropout(0.3),
        layers.Dense(1, activation='sigmoid')
    ], name='CNN_A')
    return model


def build_cnn_b(input_shape=(64, 64, 3)):
    model = keras.Sequential([
        layers.Input(shape=input_shape),
        layers.Conv2D(32, 3, padding='same', activation='relu'),
        layers.MaxPooling2D(),
        layers.Conv2D(64, 3, padding='same', activation='relu'),
        layers.MaxPooling2D(),
        layers.Conv2D(128, 3, padding='same', activation='relu'),
        layers.MaxPooling2D(),
        layers.Flatten(),
        layers.Dense(128, activation='relu'),
        layers.Dropout(0.4),
        layers.Dense(1, activation='sigmoid')
    ], name='CNN_B')
    return model
[8]
# Utilidades de entrenamiento y evaluación

def compile_model(model, lr=1e-3):
    model.compile(
        optimizer=keras.optimizers.Adam(learning_rate=lr),
        loss='binary_crossentropy',
        metrics=['accuracy', keras.metrics.AUC(name='auc')]
    )
    return model


def plot_history(history, model_name):
    # Curvas loss
    plt.figure(figsize=(8, 4.5))
    plt.plot(history.history['loss'], label='Train')
    plt.plot(history.history['val_loss'], label='Validación')
    plt.title(f'{model_name} - Loss')
    plt.xlabel('Epoch')
    plt.ylabel('Binary Cross-Entropy')
    plt.legend()
    plt.tight_layout()
    plt.show()

    # Curvas accuracy
    plt.figure(figsize=(8, 4.5))
    plt.plot(history.history['accuracy'], label='Train')
    plt.plot(history.history['val_accuracy'], label='Validación')
    plt.title(f'{model_name} - Accuracy')
    plt.xlabel('Epoch')
    plt.ylabel('Accuracy')
    plt.legend()
    plt.tight_layout()
    plt.show()


def evaluate_model(model, test_ds):
    # Tiempo de inferencia sobre todo test
    t0 = time.perf_counter()
    y_prob = model.predict(test_ds, verbose=0).ravel()
    infer_time = time.perf_counter() - t0

    y_true = np.concatenate([y.numpy() for _, y in test_ds]).astype(int)
    y_pred = (y_prob >= 0.5).astype(int)

    metrics = {
        'Accuracy': accuracy_score(y_true, y_pred),
        'Precision': precision_score(y_true, y_pred, zero_division=0),
        'Recall': recall_score(y_true, y_pred, zero_division=0),
        'F1': f1_score(y_true, y_pred, zero_division=0),
        'AUC': roc_auc_score(y_true, y_prob),
        'Inference_time_s': infer_time,
    }
    return metrics, y_true, y_pred

3) Entrenamiento de los 4 modelos

Entrenaremos cada modelo con los mismos callbacks para mantener una comparación consistente.

[9]
EPOCHS = 8

callbacks = [
    keras.callbacks.EarlyStopping(monitor='val_loss', patience=2, restore_best_weights=True)
]

model_builders = {
    'MLP_A': build_mlp_a,
    'MLP_B': build_mlp_b,
    'CNN_A': build_cnn_a,
    'CNN_B': build_cnn_b,
}

histories = {}
trained_models = {}
summary_rows = []
pred_store = {}

for name, build_fn in model_builders.items():
    print(f'\n===== Entrenando {name} =====')
    model = compile_model(build_fn())

    # Conteo de parámetros
    n_params = model.count_params()

    # Tiempo de entrenamiento
    t0 = time.perf_counter()
    history = model.fit(
        train_ds,
        validation_data=val_ds,
        epochs=EPOCHS,
        callbacks=callbacks,
        verbose=0
    )
    train_time = time.perf_counter() - t0

    # Curvas por modelo
    plot_history(history, name)

    # Evaluación final
    metrics, y_true, y_pred = evaluate_model(model, test_ds)

    # Matriz de confusión
    fig, ax = plt.subplots(figsize=(4.5, 4))
    ConfusionMatrixDisplay(confusion_matrix(y_true, y_pred), display_labels=['cat', 'dog']).plot(
        ax=ax, cmap='Blues', colorbar=False
    )
    plt.title(f'{name} - Matriz de confusión')
    plt.tight_layout()
    plt.show()

    row = {
        'Modelo': name,
        'Familia': 'MLP' if 'MLP' in name else 'CNN',
        'Params': n_params,
        'Train_time_s': train_time,
        **metrics,
    }

    summary_rows.append(row)
    histories[name] = history.history
    trained_models[name] = model
    pred_store[name] = (y_true, y_pred)

    print('Resumen:', row)
===== Entrenando MLP_A =====
Output
Output
Output
Resumen: {'Modelo': 'MLP_A', 'Familia': 'MLP', 'Params': 3187201, 'Train_time_s': 20.707209581974894, 'Accuracy': 0.6253940957294354, 'Precision': 0.6270627062706271, 'Recall': 0.644431882419446, 'F1': 0.635628659046557, 'AUC': 0.6739478025950807, 'Inference_time_s': 0.2510470459237695}

===== Entrenando MLP_B =====
Output
Output
Output
Resumen: {'Modelo': 'MLP_B', 'Familia': 'MLP', 'Params': 6456321, 'Train_time_s': 10.301433794898912, 'Accuracy': 0.5021496130696474, 'Precision': 0.5144144144144144, 'Recall': 0.32278123233465233, 'F1': 0.3966655088572421, 'AUC': 0.5272388157808248, 'Inference_time_s': 0.3045721191447228}

===== Entrenando CNN_A =====
Output
Output
Output
Resumen: {'Modelo': 'CNN_A', 'Familia': 'CNN', 'Params': 2116801, 'Train_time_s': 42.77290658908896, 'Accuracy': 0.7962166809974205, 'Precision': 0.7740932642487046, 'Recall': 0.8445449406444319, 'F1': 0.8077858880778589, 'AUC': 0.8852965149144831, 'Inference_time_s': 0.5450958621222526}

===== Entrenando CNN_B =====
Output
Output
Output
Resumen: {'Modelo': 'CNN_B', 'Familia': 'CNN', 'Params': 1142081, 'Train_time_s': 14.864715082803741, 'Accuracy': 0.7139581541989108, 'Precision': 0.8349261511728931, 'Recall': 0.5432447710570945, 'F1': 0.6582191780821918, 'AUC': 0.8179624541522605, 'Inference_time_s': 0.5928626810200512}

4) Comparativa global: rendimiento, coste y complejidad

[10]
results = pd.DataFrame(summary_rows).sort_values('Accuracy', ascending=False).reset_index(drop=True)
results
Modelo Familia Params Train_time_s Accuracy Precision Recall F1 AUC Inference_time_s
0 CNN_A CNN 2116801 42.772907 0.796217 0.774093 0.844545 0.807786 0.885297 0.545096
1 CNN_B CNN 1142081 14.864715 0.713958 0.834926 0.543245 0.658219 0.817962 0.592863
2 MLP_A MLP 3187201 20.707210 0.625394 0.627063 0.644432 0.635629 0.673948 0.251047
3 MLP_B MLP 6456321 10.301434 0.502150 0.514414 0.322781 0.396666 0.527239 0.304572
[11]
# Comparativa de métricas
fig, axes = plt.subplots(1, 3, figsize=(18, 4.5))

sns.barplot(data=results, x='Accuracy', y='Modelo', hue='Familia', dodge=False, ax=axes[0], palette='Set2')
axes[0].set_title('Accuracy (mayor es mejor)')

sns.barplot(data=results, x='F1', y='Modelo', hue='Familia', dodge=False, ax=axes[1], palette='Set2')
axes[1].set_title('F1 (mayor es mejor)')

sns.barplot(data=results, x='AUC', y='Modelo', hue='Familia', dodge=False, ax=axes[2], palette='Set2')
axes[2].set_title('AUC (mayor es mejor)')

for ax in axes:
    if ax.get_legend() is not None:
        ax.get_legend().remove()

plt.tight_layout()
plt.show()
Output
[12]
# Comparativa de costes: parámetros y tiempos
fig, axes = plt.subplots(1, 3, figsize=(18, 4.5))

sns.barplot(data=results, x='Params', y='Modelo', hue='Familia', dodge=False, ax=axes[0], palette='coolwarm')
axes[0].set_title('Número de parámetros')

sns.barplot(data=results, x='Train_time_s', y='Modelo', hue='Familia', dodge=False, ax=axes[1], palette='coolwarm')
axes[1].set_title('Tiempo de entrenamiento (s)')

sns.barplot(data=results, x='Inference_time_s', y='Modelo', hue='Familia', dodge=False, ax=axes[2], palette='coolwarm')
axes[2].set_title('Tiempo de inferencia (s)')

for ax in axes:
    if ax.get_legend() is not None:
        ax.get_legend().remove()

plt.tight_layout()
plt.show()
Output
[13]
# Comparación agregada por familia (MLP vs CNN)
family_summary = results.groupby('Familia')[['Accuracy', 'F1', 'AUC', 'Train_time_s', 'Inference_time_s', 'Params']].mean()
family_summary
Accuracy F1 AUC Train_time_s Inference_time_s Params
Familia
CNN 0.755087 0.733003 0.851629 28.818811 0.568979 1629441.0
MLP 0.563772 0.516147 0.600593 15.504322 0.277810 4821761.0

5) Diagnóstico cualitativo: ejemplos acertados y fallados

Visualizamos algunos ejemplos para interpretar errores típicos.

[14]
best_model_name = results.iloc[0]['Modelo']
print('Mejor modelo por Accuracy:', best_model_name)

best_model = trained_models[best_model_name]

# Obtenemos predicciones por imagen en test (sin batch grande para inspección)
all_imgs, all_true = [], []
for xb, yb in test_ds:
    all_imgs.append(xb.numpy())
    all_true.append(yb.numpy())

all_imgs = np.concatenate(all_imgs, axis=0)
all_true = np.concatenate(all_true, axis=0).astype(int)
all_prob = best_model.predict(test_ds, verbose=0).ravel()
all_pred = (all_prob >= 0.5).astype(int)

correct_idx = np.where(all_pred == all_true)[0]
wrong_idx = np.where(all_pred != all_true)[0]

print('Aciertos:', len(correct_idx), '| Errores:', len(wrong_idx))
Mejor modelo por Accuracy: CNN_A
Aciertos: 2778 | Errores: 711
[15]
# Mostramos ejemplos
n_show = 8

fig, axes = plt.subplots(2, n_show, figsize=(2.2*n_show, 5))

# Aciertos
for i in range(n_show):
    idx = correct_idx[i]
    axes[0, i].imshow(all_imgs[idx])
    axes[0, i].set_title(f'T:{class_names[all_true[idx]]}\nP:{class_names[all_pred[idx]]}')
    axes[0, i].axis('off')

# Errores (si hay menos de n_show, rellenamos con los que haya)
for i in range(n_show):
    if i < len(wrong_idx):
        idx = wrong_idx[i]
        axes[1, i].imshow(all_imgs[idx])
        axes[1, i].set_title(f'T:{class_names[all_true[idx]]}\nP:{class_names[all_pred[idx]]}')
    axes[1, i].axis('off')

axes[0, 0].set_ylabel('Aciertos')
axes[1, 0].set_ylabel('Errores')
plt.suptitle(f'Ejemplos del mejor modelo: {best_model_name}')
plt.tight_layout()
plt.show()
Output

6) Tests rápidos (sanity checks)

Comprobaciones para asegurar que la comparación es coherente.

[16]
assert len(results) == 4, 'Deben compararse exactamente 4 modelos'
assert set(results['Familia']) == {'MLP', 'CNN'}, 'Se esperan familias MLP y CNN'

for col in ['Accuracy', 'Precision', 'Recall', 'F1', 'AUC']:
    assert np.isfinite(results[col]).all(), f'{col} contiene valores no finitos'
    assert ((results[col] >= 0) & (results[col] <= 1)).all(), f'{col} fuera de rango [0,1]'

assert (results['Params'] > 0).all(), 'Params debe ser positivo'
assert (results['Train_time_s'] > 0).all(), 'Train_time_s debe ser positivo'
assert (results['Inference_time_s'] > 0).all(), 'Inference_time_s debe ser positivo'

print('✅ Sanity checks completados correctamente')
✅ Sanity checks completados correctamente

Conclusiones y siguientes pasos

Conclusiones principales

  1. Tratar una imagen como vector (MLP) ignora estructura espacial local y suele rendir peor en visión.
  2. Las CNN aprovechan patrones locales (bordes/texturas) y tienden a mejorar las métricas de clasificación.
  3. Comparar solo accuracy no basta: también importa AUC, F1, tiempos y complejidad en parámetros.
  4. El mejor modelo depende del equilibrio entre rendimiento y coste computacional.

Qué podrías probar después

  • Data augmentation (flip, rotaciones suaves, zoom).
  • BatchNorm y regularización adicional.
  • Transfer learning con backbones preentrenados (MobileNetV2 / EfficientNet).
  • Curvas de aprendizaje con más épocas y ajuste de learning rate.
  • Evaluación por subgrupos difíciles (fondos complejos, iluminación, postura).

Mensaje clave: para imágenes, las CNN no son solo “más profundas”, sino que incorporan una estructura inductiva que las hace mucho más adecuadas que flatten + MLP.