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.
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:
- ¿Qué ocurre cuando tratamos imágenes como vectores (MLP) frente a mapas espaciales (CNN)?
- ¿Cómo cambian accuracy, F1, tiempo de entrenamiento, tiempo de inferencia y número de parámetros?
- ¿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_dogsdesdetensorflow_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.
# 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.
# 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']
# 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
# 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)
# 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
# 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
2) Modelos a comparar
Definimos 4 arquitecturas: 2 MLP y 2 CNN.
# 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
# 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.
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 =====
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 =====
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 =====
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 =====
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
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 |
# 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()
# 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()
# 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.
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
# 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()
6) Tests rápidos (sanity checks)
Comprobaciones para asegurar que la comparación es coherente.
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
- Tratar una imagen como vector (MLP) ignora estructura espacial local y suele rendir peor en visión.
- Las CNN aprovechan patrones locales (bordes/texturas) y tienden a mejorar las métricas de clasificación.
- Comparar solo accuracy no basta: también importa AUC, F1, tiempos y complejidad en parámetros.
- 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.