Transfer Learning y Fine-Tuning: A Hombros de Gigantes#

Open In Colab

Objetivos#

  • Comprender la jerarquía de características visuales aprendidas en ImageNet y cómo extrapolarlas a nuevos dominios.

  • Criterio de Arquitectura: Aprender a leer e interpretar el catálogo de Keras Applications (Trade-offs entre Tamaño, Latencia y Precisión).

  • Dominar la Matriz de Decisión del Transfer Learning: Cuándo usar Feature Extraction vs. Fine-Tuning.

  • Criterio de Ingeniería (La Trampa del Sesgo): Detectar correlaciones espurias en el dataset (ej. fondos de colores) y mitigarlas mediante Data Augmentation integrado en el grafo.

  • Construir un Pipeline de datos de alto rendimiento usando tf.data.

Prerrequisitos#


Configuración del Entorno#

Hide code cell source

# @title *Esta celda clona el repositorio, descarga los datos e importa utilidades*
import sys
import os

IN_COLAB = "google.colab" in sys.modules

if IN_COLAB:
    import subprocess
    REPO_NAME = "applied-ai-engineering"
    if not os.path.exists(REPO_NAME):
        subprocess.run(["git", "clone", f"https://github.com/AxelSkrauba/{REPO_NAME}.git"], check=True)
    os.chdir(f"/content/{REPO_NAME}")
    sys.path.append(f"/content/{REPO_NAME}")

    # Descarga del dataset de Enfermedades del Té
    if not os.path.exists("tea_sickness_dataset"):
        subprocess.run(["pip", "install", "-q", "gdown"], check=True)
        import gdown
        print("Descargando dataset (puede tardar unos minutos)...")
        gdown.download(f"https://drive.google.com/uc?id=1ac6EkoBBxCEnJNcJO438DGHhGQKaTELx", "tea_sickness_dataset.zip", quiet=False)
        subprocess.run(["unzip", "-qq", "tea_sickness_dataset.zip"], check=True)
        os.remove("tea_sickness_dataset.zip")
else:
    os.chdir(f"../../")

from utils.plots import setup_plot_style
setup_plot_style()

os.environ["KERAS_BACKEND"] = "tensorflow"

import keras
import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt
import shutil
import random

keras.utils.set_random_seed(42)

gpus = tf.config.list_physical_devices('GPU')
if gpus:
    print(f"GPU detectada: {gpus[0].name}")
else:
    print("ADVERTENCIA: No se detectó GPU. El entrenamiento será MUY lento.")
Descargando dataset (puede tardar unos minutos)...
Downloading...
From (original): https://drive.google.com/uc?id=1ac6EkoBBxCEnJNcJO438DGHhGQKaTELx
From (redirected): https://drive.google.com/uc?id=1ac6EkoBBxCEnJNcJO438DGHhGQKaTELx&confirm=t&uuid=0eb22456-e481-4068-89d6-a82f598dee94
To: /content/applied-ai-engineering/tea_sickness_dataset.zip
100%|██████████| 775M/775M [00:08<00:00, 94.3MB/s]
GPU detectada: /physical_device:GPU:0

NOTA: Se recomienda entorno con GPU para este notebook.

1. Introducción Teórica y la Matriz de Decisión#

Entrenar una Red Convolucional profunda desde cero presenta dos problemas principales:

  1. Datos: Requiere millones de imágenes etiquetadas para no caer en Overfitting.

  2. Cómputo: Requiere semanas de procesamiento en clústeres de GPUs.

El Transfer Learning (Aprendizaje por Transferencia) resuelve esto. Consiste en tomar una red que ya fue entrenada por gigantes tecnológicos (Google, Meta, Microsoft, incluso laboratorios o equipos independientes) en un dataset masivo como ImageNet (1,2 millones de imágenes, 1000 categorías) y reutilizar su conocimiento.

¿Por qué funciona? Porque las CNNs aprenden una jerarquía universal:

  • Las primeras capas aprenden a detectar bordes, gradientes y texturas. (Útil para cualquier imagen del universo).

  • Las capas intermedias aprenden formas (círculos, esquinas).

  • Solo las últimas capas aprenden conceptos específicos (perros, autos).

De manera esquematizada, esto se vería así:

image.png

Matriz de Decisión#

Antes de escribir una línea de código, debemos ubicar el problema en la siguiente matriz. De modo de encaminar correctamente el proceso según la naturaleza del contexto a abordar:

Dominio Similar a ImageNet (Ej. Animales, Vehículos)

Dominio Diferente a ImageNet (Ej. Hojas de Té, Radiografías)

Dataset Pequeño

Feature Extraction: Congelar toda la base. Entrenar solo la capa de salida.

Feature Extraction Agresivo: Congelar la base, pero quizás descartar las últimas capas convolucionales que son muy específicas.

Dataset Grande

Fine-Tuning Profundo: Descongelar gran parte de la red y reentrenar con un Learning Rate bajo.

Entrenar desde Cero o Fine-Tuning Total: Usar los pesos de ImageNet solo como inicialización.

Nuestro Caso: Vamos a trabajar con un dataset de hojas de té (Dominio Diferente) y es muy pequeño (~800 imágenes). Estamos en el cuadrante superior derecho. Usamos Feature Extraction seguido de un Fine-Tuning muy conservador.

Esto mismo pero en forma de imagen (Colab no siempre renderiza bien las tablas largas):

image.png

3. Exploración de Datos y la “Trampa del Sesgo”#

Preparamos nuestro dataset de Enfermedades del Té. Primero, dividimos las carpetas crudas en conjuntos estrictos de Entrenamiento (70%), Validación (15%) y Prueba (15%) para evitar cualquier fuga de datos (Data Leakage).

Literalmente, armamos un nuevo directorio con subdirectorios por cada set. De modo de “separar” las imágenes en el sistema de archivos.

# 1. Script robusto para dividir el dataset en Train/Val/Test
dir_origen = './tea_sickness_dataset'
dir_base = './data_split'

for split in ['train', 'val', 'test']:
    os.makedirs(os.path.join(dir_base, split), exist_ok=True)

clases = [d for d in os.listdir(dir_origen) if os.path.isdir(os.path.join(dir_origen, d))]

for clase in clases:
    os.makedirs(os.path.join(dir_base, 'train', clase), exist_ok=True)
    os.makedirs(os.path.join(dir_base, 'val', clase), exist_ok=True)
    os.makedirs(os.path.join(dir_base, 'test', clase), exist_ok=True)

    imagenes = os.listdir(os.path.join(dir_origen, clase))
    random.shuffle(imagenes)

    # Splits: 70% Train, 15% Val, 15% Test
    idx_train = int(len(imagenes) * 0.7)
    idx_val = int(len(imagenes) * 0.85)

    for img in imagenes[:idx_train]:
        shutil.copy(os.path.join(dir_origen, clase, img), os.path.join(dir_base, 'train', clase, img))
    for img in imagenes[idx_train:idx_val]:
        shutil.copy(os.path.join(dir_origen, clase, img), os.path.join(dir_base, 'val', clase, img))
    for img in imagenes[idx_val:]:
        shutil.copy(os.path.join(dir_origen, clase, img), os.path.join(dir_base, 'test', clase, img))

print("Dataset dividido exitosamente en Train, Val y Test.")
Dataset dividido exitosamente en Train, Val y Test.

Análisis Visual: El Efecto “Clever Hans”#

Cargamos los datos usando la API moderna image_dataset_from_directory y visualizamos un lote.

image_dataset_from_directory facilita mucho del proceso de interacción con las imágenes y el modelo. Espera un directorio, con subdirectorios por cada categoría. Aplica cambio en las dimensiones de las imágenes, permite carga por batch, muestreo aleatorio sobre batch, etc. Todo lo necesario para un pipeline de deep learning…

IMG_SIZE = (224, 224) # Tamaño estándar para EfficientNetB0
BATCH_SIZE = 32

train_ds = keras.utils.image_dataset_from_directory(
    './data_split/train', label_mode='categorical', image_size=IMG_SIZE, batch_size=BATCH_SIZE, seed=42)
val_ds = keras.utils.image_dataset_from_directory(
    './data_split/val', label_mode='categorical', image_size=IMG_SIZE, batch_size=BATCH_SIZE, shuffle=False)
test_ds = keras.utils.image_dataset_from_directory(
    './data_split/test', label_mode='categorical', image_size=IMG_SIZE, batch_size=BATCH_SIZE, shuffle=False)

nombres_clases = train_ds.class_names

# Visualización
plt.figure(figsize=(12, 12))
for imagenes, etiquetas in train_ds.take(1):
    for i in range(9):
        ax = plt.subplot(3, 3, i + 1)
        plt.imshow(imagenes[i].numpy().astype("uint8"))
        plt.title(nombres_clases[np.argmax(etiquetas[i])])
        plt.axis("off")
plt.tight_layout()
plt.show()
Found 618 files belonging to 8 classes.
Found 133 files belonging to 8 classes.
Found 134 files belonging to 8 classes.
../../_images/229b44d10254e5b6be4b643c2847a22aaf5978436aea732920b9a324666ae035.png

⚠️ ALERTA DE INGENIERÍA (correlaciones espurias):
Observar detenidamente las imágenes. ¿Algo extraño en los fondos?

  • Las hojas de la clase healthy (sanas) suelen estar fotografiadas sobre un fondo celeste/azul.

  • Las hojas de la clase Anthracnose suelen estar sobre un fondo rosado/violáceo.

  • Las hojas de brown blight están sobre un fondo blanco.

Si entrenamos una red neuronal con estos datos tal cual están, la red es “perezosa”. En lugar de aprender la compleja textura de la enfermedad en la hoja, aprenderá a clasificar el color del fondo. Obtendríamos un 99% de Accuracy en el laboratorio, pero cuando el agricultor tome una foto de una hoja enferma en el campo (con fondo de tierra o cielo), el modelo fallará. A esto se le llama el efecto Clever Hans (o el sesgo del Husky/Lobo en la nieve).

4. El Pipeline de Datos: Aumento y el “Infierno” del Preprocesamiento#

Para mitigar el sesgo del fondo y evitar el Overfitting en nuestro pequeño dataset, aplicamos Data Augmentation agresivo. Al rotar, hacer zoom y trasladar la hoja, forzamos a la red a centrarse en la textura de la planta y no en la posición estática del fondo.

Además, abordamos el preprocesamiento.

El “Infierno” del Preprocesamiento#

Cada modelo pre-entrenado espera que los píxeles se le entreguen exactamente como los vio durante su entrenamiento original:

  • VGG16 espera píxeles centrados en la media de ImageNet (valores entre -127 y 127).

  • MobileNetV2 espera píxeles escalados entre [-1, 1].

  • EfficientNet es moderno: incluye la capa de normalización dentro de su propia arquitectura. Espera píxeles crudos entre [0, 255].

Si dividimos las imágenes por 255.0 y las pasamos a EfficientNet, directamente destruimos el rendimiento del modelo; ya que usamos un preprocesamiento diferente. Siempre leer la documentación de la API.

# 1. Data Augmentation Integrado en el Grafo
# Estas capas SOLO se activan durante el model.fit(). En model.predict() se apagan automáticamente.
data_augmentation = keras.Sequential([
    keras.layers.RandomFlip("horizontal_and_vertical"),
    keras.layers.RandomRotation(0.3), # Rotación agresiva (±30%)
    keras.layers.RandomZoom(0.2),     # Zoom para recortar bordes del fondo
    keras.layers.RandomTranslation(height_factor=0.1, width_factor=0.1)
], name="capas_aumento_datos")

# Visualizamos cómo el aumento de datos altera una imagen para "romper la memorización"
plt.figure(figsize=(10, 5))
for imagenes, _ in train_ds.take(1):
    imagen_base = imagenes[0]
    for i in range(4):
        ax = plt.subplot(1, 4, i + 1)
        # Aplicamos el aumento a la misma imagen 4 veces
        img_aumentada = data_augmentation(tf.expand_dims(imagen_base, 0), training=True)
        plt.imshow(img_aumentada[0].numpy().astype("uint8"))
        plt.axis("off")
        plt.title(f"Variación {i+1}")
plt.suptitle("Data Augmentation: Forzando a la red a ignorar el fondo estático", fontsize=14)
plt.tight_layout()
plt.show()

# 2. Optimización de Rendimiento del Pipeline (Hardware)
# .cache() guarda las imágenes en RAM después de la primera época.
# .prefetch() prepara el siguiente lote en CPU mientras la GPU entrena el actual.
AUTOTUNE = tf.data.AUTOTUNE

train_ds = train_ds.cache().prefetch(buffer_size=AUTOTUNE)
val_ds = val_ds.cache().prefetch(buffer_size=AUTOTUNE)
test_ds = test_ds.cache().prefetch(buffer_size=AUTOTUNE)

print("Pipeline de datos optimizado y listo para inyectar al modelo.")
../../_images/9144951d6c4efbad94584832b0c869558124275b69986ad6d15db88058d344fb.png
Pipeline de datos optimizado y listo para inyectar al modelo.

5. Fase 1: Extracción de Características (Feature Extraction)#

En esta primera fase, tratamos a EfficientNetB0 como una “caja negra” perfecta. Congelamos todos sus millones de pesos pre-entrenados y solo entrenamos una nueva “cabeza” clasificadora para que aprenda a interpretar las características extraídas y mapearlas a nuestras 8 enfermedades del té.

# 1. Carga del Modelo Base (EfficientNetB0)
# include_top=False: Descartamos la capa final de 1000 clases de ImageNet
# weights='imagenet': Cargamos el conocimiento previo
base_model = keras.applications.EfficientNetB0(
    input_shape=IMG_SIZE + (3,),
    include_top=False,
    weights='imagenet'
)

# 2. CONGELAMIENTO TOTAL
base_model.trainable = False

# 3. Ensamblaje del Modelo Final (API Funcional)
inputs = keras.Input(shape=IMG_SIZE + (3,))

# Aplicamos el Data Augmentation que definimos en la Parte 1
x = data_augmentation(inputs)

# LA TRAMPA DEL BATCH NORMALIZATION (Parte A):
# Al pasar 'training=False', le decimos a las capas dentro del modelo base
# que operen en modo inferencia. Esto evita que actualicen sus pesos.
# Además, las capas de BatchNormalization son especiales, en la Parte B las
# tratamos de manera especial, para evitar que sus estadísticas (media y varianza)
# se "rompan" con nuestro pequeño dataset.
x = base_model(x, training=False)

# Cuello de botella moderno
x = keras.layers.GlobalAveragePooling2D()(x)
x = keras.layers.Dropout(0.3)(x) # Regularización para evitar memorización

# Capa de salida (8 clases de enfermedades)
num_clases = len(nombres_clases)
outputs = keras.layers.Dense(num_clases, activation='softmax')(x)

modelo_tl = keras.Model(inputs, outputs, name="Tea_Disease_Classifier")

# 4. Compilación
# Usamos un Learning Rate estándar (1e-3) porque solo estamos entrenando la última capa
modelo_tl.compile(
    optimizer=keras.optimizers.Adam(learning_rate=1e-3),
    loss='categorical_crossentropy',
    metrics=['accuracy']
)

print("Arquitectura ensamblada. Parámetros entrenables:")
modelo_tl.summary()
Downloading data from https://storage.googleapis.com/keras-applications/efficientnetb0_notop.h5
16705208/16705208 ━━━━━━━━━━━━━━━━━━━━ 0s 0us/step
Arquitectura ensamblada. Parámetros entrenables:
Model: "Tea_Disease_Classifier"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                     Output Shape                  Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ input_layer_2 (InputLayer)      │ (None, 224, 224, 3)    │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ capas_aumento_datos             │ (None, 224, 224, 3)    │             0 │
│ (Sequential)                    │                        │               │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ efficientnetb0 (Functional)     │ (None, 7, 7, 1280)     │     4,049,571 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ global_average_pooling2d        │ (None, 1280)           │             0 │
│ (GlobalAveragePooling2D)        │                        │               │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dropout (Dropout)               │ (None, 1280)           │             0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense (Dense)                   │ (None, 8)              │        10,248 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 4,059,819 (15.49 MB)
 Trainable params: 10,248 (40.03 KB)
 Non-trainable params: 4,049,571 (15.45 MB)

Nota de Ingeniería: Observar el summary(). De los ~4,1 millones de parámetros, solo ~10.000 son entrenables. El entrenamiento será rapidísimo.

Entrenamiento de la Fase 1#

# Callbacks
callbacks_fe = [
    keras.callbacks.EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True),
    keras.callbacks.ModelCheckpoint("modelo_fase1.keras", save_best_only=True)
]

EPOCHS_FE = 20

print("Iniciando Fase 1: Feature Extraction...")
historia_fe = modelo_tl.fit(
    train_ds,
    epochs=EPOCHS_FE,
    validation_data=val_ds,
    callbacks=callbacks_fe,
    verbose=1
)
Iniciando Fase 1: Feature Extraction...
Epoch 1/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 36s 1s/step - accuracy: 0.2929 - loss: 1.8383 - val_accuracy: 0.4511 - val_loss: 1.5775
Epoch 2/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 127ms/step - accuracy: 0.5129 - loss: 1.4143 - val_accuracy: 0.5338 - val_loss: 1.3582
Epoch 3/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 122ms/step - accuracy: 0.6408 - loss: 1.1813 - val_accuracy: 0.5414 - val_loss: 1.2488
Epoch 4/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 121ms/step - accuracy: 0.6796 - loss: 1.0342 - val_accuracy: 0.5564 - val_loss: 1.1658
Epoch 5/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 3s 121ms/step - accuracy: 0.6845 - loss: 0.9519 - val_accuracy: 0.5639 - val_loss: 1.1149
Epoch 6/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 3s 135ms/step - accuracy: 0.7249 - loss: 0.8709 - val_accuracy: 0.5789 - val_loss: 1.0843
Epoch 7/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 3s 128ms/step - accuracy: 0.7557 - loss: 0.8196 - val_accuracy: 0.5865 - val_loss: 1.0405
Epoch 8/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 122ms/step - accuracy: 0.7540 - loss: 0.7825 - val_accuracy: 0.6316 - val_loss: 0.9903
Epoch 9/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 123ms/step - accuracy: 0.7508 - loss: 0.7332 - val_accuracy: 0.6015 - val_loss: 0.9794
Epoch 10/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 86ms/step - accuracy: 0.7816 - loss: 0.7056 - val_accuracy: 0.5940 - val_loss: 0.9991
Epoch 11/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 123ms/step - accuracy: 0.7702 - loss: 0.6631 - val_accuracy: 0.6316 - val_loss: 0.9268
Epoch 12/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 3s 137ms/step - accuracy: 0.7832 - loss: 0.6673 - val_accuracy: 0.6391 - val_loss: 0.9233
Epoch 13/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 85ms/step - accuracy: 0.7929 - loss: 0.6335 - val_accuracy: 0.6391 - val_loss: 0.9301
Epoch 14/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 122ms/step - accuracy: 0.8091 - loss: 0.6198 - val_accuracy: 0.6316 - val_loss: 0.9063
Epoch 15/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 86ms/step - accuracy: 0.8204 - loss: 0.5850 - val_accuracy: 0.6241 - val_loss: 0.9098
Epoch 16/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 124ms/step - accuracy: 0.8172 - loss: 0.5641 - val_accuracy: 0.6466 - val_loss: 0.8654
Epoch 17/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 87ms/step - accuracy: 0.8301 - loss: 0.5503 - val_accuracy: 0.6767 - val_loss: 0.8822
Epoch 18/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 3s 160ms/step - accuracy: 0.8204 - loss: 0.5515 - val_accuracy: 0.6541 - val_loss: 0.8535
Epoch 19/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 123ms/step - accuracy: 0.8042 - loss: 0.5645 - val_accuracy: 0.6692 - val_loss: 0.8519
Epoch 20/20
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 124ms/step - accuracy: 0.8058 - loss: 0.5449 - val_accuracy: 0.6617 - val_loss: 0.8361

6. Fase 2: Ajuste Fino Progresivo (Fine-Tuning)#

La Fase 1 nos dio un modelo decente rápidamente. Pero las características de ImageNet (perros, autos) no son perfectas para hojas de té. En la Fase 2, vamos a descongelar las últimas capas del modelo base para que sus filtros se adapten sutilmente a las texturas de las enfermedades agrícolas.

⚠️ LA TRAMPA DEL BATCH NORMALIZATION (Parte B): Si simplemente hacemos base_model.trainable = True, descongelamos las capas BatchNormalization. Al entrenar con un batch size pequeño (32) en un dataset diminuto, las estadísticas de BN fluctuarán violentamente, destruyendo los pesos pre-entrenados. Regla de Oro de Keras: Durante el Fine-Tuning, mantener las capas BN congeladas.

# 1. Descongelamos el modelo base
base_model.trainable = True

# 2. Congelamos todo excepto las últimas 20 capas
for layer in base_model.layers[:-20]:
    layer.trainable = False

# 3. Aplicamos la Regla de Oro: Re-congelar las capas BatchNormalization
for layer in base_model.layers[-20:]:
    if isinstance(layer, keras.layers.BatchNormalization):
        layer.trainable = False

# Verificamos cuántas capas quedaron entrenables
entrenables = sum([1 for layer in base_model.layers if layer.trainable])
print(f"Capas entrenables en el modelo base: {entrenables} de {len(base_model.layers)}")

# 4. Re-compilación (¡CRÍTICO!)
# Usamos un Learning Rate MICROSCOPICO (1e-5). Si usamos 1e-3, los gradientes
# grandes destruirán los pesos pre-entrenados (Catastrophic Forgetting).
modelo_tl.compile(
    optimizer=keras.optimizers.Adam(learning_rate=1e-5),
    loss='categorical_crossentropy',
    metrics=['accuracy']
)

# 5. Entrenamiento Continuo
callbacks_ft = [
    keras.callbacks.EarlyStopping(monitor='val_loss', patience=4, restore_best_weights=True),
    keras.callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=2)
]

EPOCHS_FT = 20
total_epochs = EPOCHS_FE + EPOCHS_FT

print("\nIniciando Fase 2: Fine-Tuning...")
# Usamos initial_epoch para que Keras sepa que es una continuación
historia_ft = modelo_tl.fit(
    train_ds,
    epochs=total_epochs,
    initial_epoch=historia_fe.epoch[-1] + 1,
    validation_data=val_ds,
    callbacks=callbacks_ft,
    verbose=1
)
Capas entrenables en el modelo base: 15 de 238

Iniciando Fase 2: Fine-Tuning...
Epoch 21/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 16s 260ms/step - accuracy: 0.8269 - loss: 0.5430 - val_accuracy: 0.6692 - val_loss: 0.8410 - learning_rate: 1.0000e-05
Epoch 22/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 95ms/step - accuracy: 0.8333 - loss: 0.5274 - val_accuracy: 0.6692 - val_loss: 0.8385 - learning_rate: 1.0000e-05
Epoch 23/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 90ms/step - accuracy: 0.8528 - loss: 0.4589 - val_accuracy: 0.6617 - val_loss: 0.8393 - learning_rate: 1.0000e-05
Epoch 24/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 96ms/step - accuracy: 0.8285 - loss: 0.4749 - val_accuracy: 0.6692 - val_loss: 0.8240 - learning_rate: 1.0000e-05
Epoch 25/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 91ms/step - accuracy: 0.8220 - loss: 0.4600 - val_accuracy: 0.6617 - val_loss: 0.8273 - learning_rate: 1.0000e-05
Epoch 26/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 3s 106ms/step - accuracy: 0.8317 - loss: 0.4817 - val_accuracy: 0.6842 - val_loss: 0.8171 - learning_rate: 1.0000e-05
Epoch 27/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 94ms/step - accuracy: 0.8414 - loss: 0.4514 - val_accuracy: 0.6917 - val_loss: 0.8174 - learning_rate: 1.0000e-05
Epoch 28/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 98ms/step - accuracy: 0.8382 - loss: 0.4458 - val_accuracy: 0.6917 - val_loss: 0.8019 - learning_rate: 1.0000e-05
Epoch 29/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 3s 132ms/step - accuracy: 0.8560 - loss: 0.4251 - val_accuracy: 0.7068 - val_loss: 0.7965 - learning_rate: 1.0000e-05
Epoch 30/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 117ms/step - accuracy: 0.8560 - loss: 0.4143 - val_accuracy: 0.6917 - val_loss: 0.8029 - learning_rate: 1.0000e-05
Epoch 31/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 101ms/step - accuracy: 0.8511 - loss: 0.4323 - val_accuracy: 0.6917 - val_loss: 0.7959 - learning_rate: 1.0000e-05
Epoch 32/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 108ms/step - accuracy: 0.8317 - loss: 0.4214 - val_accuracy: 0.6992 - val_loss: 0.7811 - learning_rate: 1.0000e-05
Epoch 33/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 98ms/step - accuracy: 0.8511 - loss: 0.4119 - val_accuracy: 0.7218 - val_loss: 0.7703 - learning_rate: 1.0000e-05
Epoch 34/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 93ms/step - accuracy: 0.8430 - loss: 0.4197 - val_accuracy: 0.7068 - val_loss: 0.7779 - learning_rate: 1.0000e-05
Epoch 35/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 97ms/step - accuracy: 0.8560 - loss: 0.3799 - val_accuracy: 0.7143 - val_loss: 0.7691 - learning_rate: 1.0000e-05
Epoch 36/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 98ms/step - accuracy: 0.8430 - loss: 0.4079 - val_accuracy: 0.7068 - val_loss: 0.7649 - learning_rate: 1.0000e-05
Epoch 37/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 99ms/step - accuracy: 0.8382 - loss: 0.4207 - val_accuracy: 0.7218 - val_loss: 0.7629 - learning_rate: 1.0000e-05
Epoch 38/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 122ms/step - accuracy: 0.8576 - loss: 0.3745 - val_accuracy: 0.7143 - val_loss: 0.7574 - learning_rate: 1.0000e-05
Epoch 39/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 93ms/step - accuracy: 0.8592 - loss: 0.3717 - val_accuracy: 0.7218 - val_loss: 0.7594 - learning_rate: 1.0000e-05
Epoch 40/40
20/20 ━━━━━━━━━━━━━━━━━━━━ 2s 97ms/step - accuracy: 0.8528 - loss: 0.4091 - val_accuracy: 0.7293 - val_loss: 0.7566 - learning_rate: 1.0000e-05

Evaluación Visual del Proceso Completo#

Empalmamos las historias de ambas fases para ver el impacto real del Fine-Tuning.

# Extraemos las métricas de ambas fases
acc = historia_fe.history['accuracy'] + historia_ft.history['accuracy']
val_acc = historia_fe.history['val_accuracy'] + historia_ft.history['val_accuracy']
loss = historia_fe.history['loss'] + historia_ft.history['loss']
val_loss = historia_fe.history['val_loss'] + historia_ft.history['val_loss']

epoca_inicio_ft = historia_fe.epoch[-1] + 1

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Gráfico de Accuracy
axes[0].plot(acc, label='Train Accuracy', color='teal')
axes[0].plot(val_acc, label='Val Accuracy', color='orange')
axes[0].axvline(x=epoca_inicio_ft, color='black', linestyle='--', label='Inicio Fine-Tuning')
axes[0].set_title('Evolución del Accuracy')
axes[0].set_xlabel('Épocas')
axes[0].legend()

# Gráfico de Loss
axes[1].plot(loss, label='Train Loss', color='teal')
axes[1].plot(val_loss, label='Val Loss', color='orange')
axes[1].axvline(x=epoca_inicio_ft, color='black', linestyle='--', label='Inicio Fine-Tuning')
axes[1].set_title('Evolución de la Pérdida (Loss)')
axes[1].set_xlabel('Épocas')
axes[1].legend()

plt.show()
../../_images/b62b1ed5514f7ec9523b9c60e65973a0eb575adfd3c8af1e369452dcfcfc7249.png

Lectura de Ingeniería: Si el Fine-Tuning fue exitoso, se verá un pequeño “salto” hacia arriba en el Accuracy de validación justo después de la línea punteada negra. El modelo pasó de ser un “experto en ImageNet” a un “experto en hojas de té”.

7. Laboratorio de los Errores (¿Qué pasa si rompemos las reglas?)#

En ingeniería, no basta con saber qué hacer; hay que entender por qué no hacer lo contrario. A continuación, mostramos visualmente las curvas de entrenamiento de los dos errores más costosos (y comunes) en la industria.

NOTA: para evitar “gastar GPU”, las curvas son generadas (simuladas), exagerando un poco los efectos a fines demostrativos. Lo importante es tener una noción de “cómo se ven” estos problemas…

Error 1: Olvido Catastrófico (Catastrophic Forgetting)#

Error: Iniciar la Fase 2 (Fine-Tuning) usando un Learning Rate alto (ej. 1e-3 en lugar de 1e-5).
Física del problema: La nueva capa densa que agregamos genera gradientes de error muy grandes al principio. Si el LR es alto, esos gradientes masivos viajan hacia atrás y “golpean” las capas convolucionales pre-entrenadas, reescribiendo sus pesos de forma caótica. El modelo olvida instantáneamente todo lo que aprendió en ImageNet.

Hide code cell source

# @title Simulación Visual: Olvido Catastrófico
epocas_sim = np.arange(20)
acc_fe_sim = np.linspace(0.4, 0.85, 10)
# El LR alto destruye los pesos, el accuracy cae a niveles aleatorios
acc_ft_bad = np.random.uniform(0.1, 0.3, 10)
acc_total_bad = np.concatenate([acc_fe_sim, acc_ft_bad])

plt.figure(figsize=(6, 4))
plt.plot(epocas_sim, acc_total_bad, color='red', linewidth=2, label='Val Accuracy')
plt.axvline(x=9, color='black', linestyle='--', label='Inicio FT (LR = 1e-3)')
plt.title("Error 1: Olvido Catastrófico (Learning Rate muy alto)")
plt.xlabel("Épocas")
plt.ylabel("Accuracy")
plt.legend()
plt.show()
../../_images/3b4e03a9a021b5517bcb9e24d6da4049b13771322e36dfed357d585cb205b06c.png

Error 2: Corrupción de Estadísticas (La Trampa del Batch Norm)#

Error: Descongelar las capas BatchNormalization durante el Fine-Tuning en un dataset pequeño.
Física del problema: Las capas BN de ImageNet tienen medias y varianzas calculadas sobre 1,2 millones de imágenes. Si las descongelamos, comenzarán a reemplazar esos números robustos con la media y varianza del pequeño batch de 32 hojas de té. Esto introduce un ruido estadístico considerable. El modelo parecerá aprender en entrenamiento, pero fallará en validación.

Hide code cell source

# @title Simulación Visual: Corrupción de Batch Norm
train_acc_bn = np.concatenate([np.linspace(0.4, 0.85, 10), np.linspace(0.85, 0.99, 10)])
# La validación se degrada lentamente por el ruido estadístico
val_acc_bn = np.concatenate([np.linspace(0.4, 0.82, 10), np.linspace(0.82, 0.65, 10)])

plt.figure(figsize=(6, 4))
plt.plot(epocas_sim, train_acc_bn, color='teal', label='Train Accuracy')
plt.plot(epocas_sim, val_acc_bn, color='orange', label='Val Accuracy (Degradada)')
plt.axvline(x=9, color='black', linestyle='--', label='Inicio FT (BN Descongelado)')
plt.title("Error 2: Corrupción de Batch Norm")
plt.xlabel("Épocas")
plt.legend()
plt.show()
../../_images/db9468923884a31b44343a070917378fb961dfcedc41dce237da8341db5b0a29.png

8. Abstracción: De CNNs a LLMs (El paradigma LoRA)#

Aprendimos a hacer Fine-Tuning descongelando el 20% de una CNN de 5 millones de parámetros. Pero la Inteligencia Artificial evolucionó.

Hoy en día, las empresas quieren adaptar Modelos Fundacionales de Lenguaje (LLMs) como Llama 3, Gemma 3, Phi 4, Qween 3.5, etc. que tienen fácilmente 70 Billones de parámetros. Si intentamos hacer Fine-Tuning descongelando el 20% de Llama 3, necesitaríamos un clúster de GPUs A100 (cientos de miles de dólares) solo para almacenar los gradientes en memoria.

La Solución de la Industria: PEFT y LoRA
Para resolver esto, se inventó PEFT (Parameter-Efficient Fine-Tuning), siendo LoRA (Low-Rank Adaptation) la técnica estrella.

¿Cómo funciona LoRA?

  1. Congela el 100% del modelo gigante original (cero gradientes, cero consumo extra de memoria).

  2. Inyecta pequeñas matrices “adaptadoras” al lado de las capas originales.

  3. Entrena solo esas pequeñas matrices (que representan el 0,1% de los parámetros totales).

Conclusión: La filosofía subyacente de LoRA es exactamente la misma que aplicamos hoy en este notebook; Aprovechar el conocimiento masivo pre-entrenado y modificar solo una fracción minúscula de los pesos para adaptar el modelo a nuestra tarea específica, ahorrando tiempo y dinero.

Conexiones y Próximos Pasos#

Con este notebook, completamos el ciclo de Deep Learning aplicado a Visión por Computador. Eres capaz de tomar un problema real, evitar sesgos de datos, elegir la arquitectura correcta y aplicar el estado del arte en entrenamiento.

Referencias#

Artículos y Guías Fundacionales#

  1. Chollet, F. (2020-2023). Transfer learning & fine-tuning. Keras Official Guides.
    [Enlace]
    (Lectura obligatoria — explica en detalle el comportamiento de BatchNormalization durante fine-tuning).

  2. Tan, M., & Le, Q. V. (2019). EfficientNet: Rethinking Model Scaling for Convolutional Neural Networks. ICML.
    [arXiv] (Paper de EfficientNetB0).

  3. Hu, E. J., et al. (2021). LoRA: Low-Rank Adaptation of Large Language Models. arXiv.
    [arXiv] (La técnica que revolucionó el fine-tuning de modelos gigantes).

  4. Yosinski, J., et al. (2014). How transferable are features in deep neural networks? NeurIPS.
    [arXiv] (Estudio clásico sobre transferibilidad de características en capas profundas).

Recursos Prácticos#

  • Keras Applications Documentation: Listado completo de modelos.

  • TensorFlow Hub y Hugging Face Hub para modelos pre-entrenados.

  • Simonyan & Zisserman (2014) — VGG (aunque obsoleto, útil históricamente).

  • Chollet, F. (2021). Deep Learning with Python, Second Edition (Capítulo 5: Deep Learning for Computer Vision).


Entorno de Ejecución#

Hide code cell source

from utils.environment import environment_table
environment_table(include_all=False)
Reproducibility Environment Information
Package Version
Python 3.12.13
Platform Linux-6.6.122+-x86_64-with-glibc2.35
IPython 7.34.0
ipywidgets 7.7.1
joblib 1.5.3
keras 3.13.2
matplotlib 3.10.0
numpy 2.0.2
pandas 2.2.2
scipy 1.16.3
seaborn 0.13.2
sklearn 1.5.3
statsmodels 0.14.6
tensorflow 2.20.0