Capítulo 15 de 17 9 secciones 12 min

Reutilizar lo que otra red ya aprendió

Congelar la capa oculta de una red entrenada y medir qué pasa. Salió lo contrario de lo que yo esperaba.

Reutilizar una red ya entrenada es lo que hace casi todo el mundo en produccion, y yo escribi este capitulo esperando medir cuanto se gana. Salió lo contrario: sobre estos dígitos la transferencia pierde, 0,6781 contra 0,8032 de los píxeles crudos. Y la explicación fácil, que la base tiró la información, también es falsa al medirla 🔄

Este capítulo lo escribí esperando una cosa y salió otra. Te cuento las dos, porque la segunda enseña más 🔄

La idea de partida es la que sostiene casi todo el deep learning que se usa hoy: nadie entrena desde cero. Se agarra una red que ya aprendió sobre millones de imágenes y se le enseña lo tuyo encima, con cien fotos en vez de un millón.

Antes de medirlo, piénsalo tú: ¿qué es exactamente lo que una red aprende y se puede prestar? No son las respuestas, porque las clases son otras. Es la capa del medio 🧠

Qué es lo que se presta

En el capítulo 4 vimos que una capa oculta transforma el espacio hasta que el problema se pueda separar. Esa transformación es lo valioso: aprender a ver bordes, curvas y trazos cuesta muchos datos, y una vez aprendida sirve para reconocer cosas que la red nunca vio.

Vamos a montarlo con los dígitos del capítulo 9, partidos en dos mitades: la red aprende con los dígitos del 0 al 4, y después le pedimos que reconozca del 5 al 9 con poquísimos ejemplos.

import numpy as np
from sklearn.datasets import load_digits
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier

d = load_digits()
X = d.data / 16.0
y = d.target
X_viejas, y_viejas = X[y <= 4], y[y <= 4]        # las que tienen muchas fotos
X_nuevas, y_nuevas = X[y >= 5], y[y >= 5]        # las que casi no tienen
print('fotos de las clases viejas:', len(X_viejas))
print('fotos de las clases nuevas:', len(X_nuevas))
fotos de las clases viejas: 901
fotos de las clases nuevas: 896

Esa partición imita la situación real: tienes un montón de datos de una cosa y cuatro fotos de la que te interesa 📸

base = MLPClassifier(hidden_layer_sizes=(64,), max_iter=600,
                     random_state=0).fit(X_viejas, y_viejas)
print('acierto de la base en sus propias clases:', round(base.score(X_viejas, y_viejas), 4))
print('pesos de la capa oculta:', base.coefs_[0].shape)
acierto de la base en sus propias clases: 1.0
pesos de la capa oculta: (64, 64)

La base aprendió sus cinco dígitos perfecto. Y lo que nos vamos a llevar prestado es esa matriz de 64 por 64, que es su capa del medio.

Prestarla son dos líneas

def rasgos(M):
    # La capa oculta de la base, usada como extractor de caracteristicas.
    return np.maximum(0, M @ base.coefs_[0] + base.intercepts_[0])

print('64 pixeles se vuelven', rasgos(X_nuevas[:1]).shape[1], 'numeros aprendidos')
64 pixeles se vuelven 64 numeros aprendidos

Eso es todo. Multiplicar por los pesos que la base aprendió y aplicar su activación, o sea la misma cuenta del capítulo 2 pero parando a mitad de camino 🔧

El np.maximum(0, ...) es la ReLU del capítulo 3: MLPClassifier la usa por defecto, así que hay que aplicar la misma o los números no significarían lo mismo.

A esto se le llama congelar la base: sus pesos no se vuelven a tocar, solo se usan. Encima ponemos un modelo barato, que con pocos datos es justo lo que hay que poner.

Dos caminos comparados: reutilizar la capa oculta de una red entrenada con los dígitos del 0 al 4 da 0,6781 con dos fotos por clase, y usar los 64 píxeles crudos sin prestar nada da 0,8032.
Prestar la capa oculta pierde doce puntos contra no prestar nada. No porque se pierda información, sino porque queda ordenada para la pregunta vieja.

Y ahora la medición

print(f'{"por clase":>10} {"pixeles":>10} {"transferencia":>15}')
for pocas in [2, 3, 5, 10]:
    pix, tra = [], []
    for semilla in range(5):
        i_tr, i_te = train_test_split(np.arange(len(X_nuevas)), train_size=pocas * 5,
                                      random_state=semilla, stratify=y_nuevas)
        Xt, yt = X_nuevas[i_tr], y_nuevas[i_tr]
        Xe, ye = X_nuevas[i_te], y_nuevas[i_te]
        pix.append(accuracy_score(ye, LogisticRegression(max_iter=2000)
                                  .fit(Xt, yt).predict(Xe)))
        tra.append(accuracy_score(ye, LogisticRegression(max_iter=2000)
                                  .fit(rasgos(Xt), yt).predict(rasgos(Xe))))
    print(f'{pocas:10} {np.mean(pix):10.4f} {np.mean(tra):15.4f}')
 por clase    pixeles   transferencia
         2     0.8032          0.6781
         3     0.8456          0.7303
         5     0.9111          0.8259
        10     0.9355          0.8811

Pierde. En los cuatro tamaños 🫠

Con dos fotos por clase, los píxeles crudos sacan 0,8032 y la transferencia 0,6781. Doce puntos de diferencia, y encima la brecha no se cierra al darle más datos.

Y no es de una partición con suerte: cada fila es el promedio de cinco particiones distintas, que es la lección del capítulo 4 aplicada aquí.

La explicación fácil, y por qué es falsa

La primera explicación que se le ocurre a cualquiera, y a mí la primera, es esta:

  • La base se entrenó para distinguir del 0 al 4
  • Así que tiró todo lo que no le servía para eso
  • Incluido justo lo que separa al 5 del 8

Suena impecable. Vamos a comprobarla, que para eso están los datos:

from sklearn.feature_selection import mutual_info_classif

i_pix = mutual_info_classif(X_nuevas, y_nuevas, random_state=0).sum()
i_ras = mutual_info_classif(rasgos(X_nuevas), y_nuevas, random_state=0).sum()
print(f'informacion sobre la clase nueva en los 64 pixeles : {i_pix:.4f}')
print(f'informacion en los 64 rasgos de la base            : {i_ras:.4f}')
informacion sobre la clase nueva en los 64 pixeles : 11.1627
informacion en los 64 rasgos de la base            : 11.8318

Es falsa 😮

Los rasgos de la base tienen más información sobre las clases nuevas que los píxeles crudos, no menos. 11,83 contra 11,16.

O sea que el problema no es que se perdiera nada. Es que la información quedó ordenada para la tarea vieja: repartida entre las 64 neuronas de una forma que le sirve a una recta para separar del 0 al 4, y que a una recta para separar del 5 al 9 no le sirve igual.

Y esto explica de paso por qué en la práctica casi nadie congela la base entera: se ajusta, o sea se sigue entrenando desde esos pesos en vez de arrancar de cero. Eso permite reordenar la representación en lugar de tener que apañárselas con el orden viejo 🔧

La transferencia no falla por perder información. Falla cuando la información queda ordenada para otra pregunta.

El error que sale al cambiar la entrada

Y antes de cerrar, el error que te va a salir el primer día. Imagina que tienes fotos de productos del almacén y decides recortarlas a otro tamaño para que pesen menos:

recortadas = X_nuevas[:, :30]      # nos quedamos con 30 pixeles en vez de 64
rasgos(recortadas)
ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 64 is different from 30)

size 64 is different from 30. La base aprendió una matriz de 64 por 64 y no sabe multiplicar otra cosa.

Esto sí revienta, y por eso es el bueno de los dos errores. El peligroso es el de la trampa de más abajo, que no revienta 🚩

Y en un negocio de verdad, ¿dónde entra esto?

Te lo aterrizo con la distribuidora del libro, que es donde a mí me llega esta pregunta 💼

Lo que te pidenQué haría yo
Clasificar fotos de productos del stock por categoríaModelo preentrenado congelado, y encima una regresión. Es el caso de manual y ahí sí gana
Sacar el monto y el número de pedido de facturas escaneadasEmpieza por un lector de texto ya hecho. Entrenar uno tuyo es meses
Predecir qué cliente va a comprar, con tu tabla de ventasNada de esto. Vuelve al capítulo 1 y usa una logística
Clasificar los reclamos que llegan por WhatsAppUn modelo de lenguaje ya entrenado. Aquí prestar es casi obligatorio

Fíjate en la tercera fila, que es la que más veces me toca decir en voz alta: si tu dato es una tabla de ventas con ciudad, canal y segmento, la transferencia no aplica porque no hay nada que prestar 🙂

Entonces, ¿cuándo sí sirve?

La condición es una y se deduce de todo lo anterior: la transferencia gana cuando aprender los rasgos es la parte cara.

Aquí no lo es. Una imagen de 8 por 8 son 64 números y ya son una representación decente: cada píxel dice algo directo. No hay gran cosa que aprender antes de clasificar, así que prestada no aporta y encima estorba.

Tu dato¿Sirve prestar?
Imágenes de 8x8 como estasNo. Los píxeles crudos ya sirven, medido arriba
Fotos de verdad, 224x224 a colorSí, y muchísimo. Son 150.000 números y aprender a verlos cuesta millones de ejemplos
TextoSí. Aprender qué significa una palabra es el trabajo entero, y es lo que hace el capítulo 10
Tu tabla de ventasNo. Vuelve al capítulo 1

Y una cosa que hay que decir con todas sus letras: este libro no puede enseñarte la parte donde la transferencia gana. Para eso hace falta un modelo preentrenado sobre millones de fotos, que pesa cientos de megas y hay que descargarlo. Lo que sí puedes hacer tú, cuando tengas internet y ganas, es buscar timm o los modelos de Hugging Face y repetir exactamente este montaje con uno de ellos 🌐

Lo que sí te llevas de aquí es lo que no cambia con el modelo: qué es lo que se presta, cómo se enchufa, y cómo se comprueba si sirvió.

La trampa

Un equipo reutiliza una red preentrenada para clasificar fotos de productos. Preparan las imágenes así y el modelo sale con un acierto malísimo que nadie sabe explicar.

# la base se entreno con imagenes normalizadas a media 0 y desviacion 1
base = carga_modelo_preentrenado()

fotos = carga_fotos()          # valores de 0 a 255
rasgos = base.extrae(fotos)    # se le pasan tal cual

modelo = LogisticRegression().fit(rasgos, etiquetas)
Qué está mal

La base espera las imágenes con el mismo preprocesamiento con el que se entrenó, y aquí le están llegando valores de 0 a 255 cuando ella aprendió con valores centrados en cero. Sus neuronas se saturan enteras, que es lo del capítulo de las activaciones, y los rasgos que devuelve son casi todos el mismo número. No da ningún error: da un modelo mediocre. Es el error número uno de la transferencia y por eso todo modelo publicado viene con su función de preprocesamiento al lado, que hay que usar. En el montaje de este capítulo se ve en una línea: la base se entrenó con X = d.data / 16.0, así que rasgos() tiene que recibir datos divididos entre 16 también, y si le pasas d.data crudo el acierto se cae sin avisar.

Ejercicios

Seis. El 3 es el que contesta la pregunta que quedó abierta 💛

1. Rómpelo con el preprocesamiento

Pásale a rasgos() los píxeles sin dividir entre 16 y mira cuánto cae.

crudos = d.data[y >= 5].astype(float)      # de 0 a 255, sin dividir
i_tr, i_te = train_test_split(np.arange(len(X_nuevas)), train_size=50,
                              random_state=0, stratify=y_nuevas)
bien = LogisticRegression(max_iter=2000).fit(rasgos(X_nuevas[i_tr]), y_nuevas[i_tr])
mal = LogisticRegression(max_iter=2000).fit(rasgos(crudos[i_tr]), y_nuevas[i_tr])
print('con el preprocesamiento correcto:',
      round(bien.score(rasgos(X_nuevas[i_te]), y_nuevas[i_te]), 4))
print('con los pixeles crudos          :',
      round(mal.score(rasgos(crudos[i_te]), y_nuevas[i_te]), 4))

Es la trampa de arriba, ejecutada. Y fíjate en que no revienta: devuelve un número peor y ya.

2. Una base más chica

Repite todo con hidden_layer_sizes=(16,) en la base y compara.

Con 16 neuronas la capa oculta comprime mucho más, así que la representación prestada es más pobre y la caída es mayor. Es el mismo compromiso del pooling del capítulo 9: resumir da robustez y cuesta información.

3. Ajustar en vez de congelar

La pregunta que el capítulo deja abierta. En vez de congelar la base, arranca de sus pesos y sigue entrenando.

Se escribe con la red a mano del capítulo 4, pasándole W1 y b1 de la base en vez de inicializarlos al azar, y dejando que el entrenamiento los mueva. Tarda un par de minutos.

Esto es lo que me salió a mí, promediando cinco particiones:

 por clase   desde cero   desde la base
         2       0.7679          0.7580
         3       0.8277          0.8070
         5       0.9084          0.8916
        10       0.9291          0.9248

También pierde, aunque por mucho menos que congelando. Tres montajes distintos y los tres dicen lo mismo: sobre estos dígitos no hay nada que prestar. Cuando un resultado aguanta tres formas de medirlo, ya no es un accidente del montaje 🧪

4. Cambia qué mitad es la vieja

Entrena la base con el 5 al 9 y transfiere al 0 al 4.

Si la conclusión fuera un accidente de qué dígitos tocaron, aquí se vería. Comprobar que un resultado no depende de una elección arbitraria es de las cosas más baratas que puedes hacer y de las que más credibilidad dan.

5. ¿Y si la base ve todas las clases?

Entrena la base con los diez dígitos y después transfiere al 5 al 9.

todos = MLPClassifier(hidden_layer_sizes=(64,), max_iter=600,
                      random_state=0).fit(X, y)
def rasgos_todos(M):
    return np.maximum(0, M @ todos.coefs_[0] + todos.intercepts_[0])

Ahora sí debería ir mejor, y hay que entender por qué: la base ya vio esas clases, así que su representación está ordenada también para ellas. Ojo con lo que esto significa de verdad, que es la lección escondida del ejercicio: ya no es transferencia, es entrenar con los datos de prueba. En un proyecto real eso sería fuga de información, que en el libro de machine learning tiene su capítulo entero.

6. Cuántas fotos necesitas para empatar

Sigue subiendo el número de fotos por clase y busca dónde las dos curvas se cruzan.

for pocas in [10, 20, 40, 80]:
    i_tr, i_te = train_test_split(np.arange(len(X_nuevas)), train_size=pocas * 5,
                                  random_state=0, stratify=y_nuevas)
    p = LogisticRegression(max_iter=2000).fit(X_nuevas[i_tr], y_nuevas[i_tr])
    t = LogisticRegression(max_iter=2000).fit(rasgos(X_nuevas[i_tr]), y_nuevas[i_tr])
    print(f'{pocas:3}: pixeles {p.score(X_nuevas[i_te], y_nuevas[i_te]):.4f}   '
          f'transferencia {t.score(rasgos(X_nuevas[i_te]), y_nuevas[i_te]):.4f}')

La pregunta que importa detrás del ejercicio: si con muchas fotos empatan y con pocas pierde, ¿en qué situación de tu trabajo la ibas a usar?

Comprueba que lo tienes

Tienes 300 fotos de piezas de tu almacén y quieres clasificarlas. ¿Por dónde empiezas?

  • Por un modelo preentrenado de imágenes, congelado, y encima una regresión
  • Por entrenar una red desde cero con las 300 fotos
  • Por los píxeles crudos y una regresión, como en este capítulo
  • Por etiquetar 10.000 fotos más

Lo que te llevas

  • 🔄 Prestar una red es usar su capa oculta como entrada de otro modelo, y son dos líneas.
  • 📉 Sobre estos dígitos pierde en los cuatro tamaños, y con dos fotos por clase pierde por doce puntos.
  • 🔬 Y no por perder información: los rasgos prestados tienen más (11,83 contra 11,16). Queda ordenada para la pregunta vieja.
  • 🎯 Gana cuando aprender a ver es la parte cara. Con 64 píxeles no lo es.
  • ⚠️ Y el error que no avisa es olvidarse del preprocesamiento con el que se entrenó la base.

Si quieres el vocabulario suelto de todo esto, está definido en dos líneas por término en el glosario de IA 📖

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?