Ocho capítulos diciendo que la red pierde. Aquí cambiamos de tipo de dato y por fin se entiende para qué existe todo esto 🖼️
Y vamos a usar imágenes de verdad, que vienen dentro de scikit-learn: 1.797 dígitos escritos a mano en 8 por 8 píxeles.
Una imagen es una matriz de números
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
digitos = load_digits()
imagenes = digitos.images / 16.0 # de 0 a 1
etiquetas = digitos.target
print('tenemos', imagenes.shape[0], 'imágenes de', imagenes.shape[1], 'por',
imagenes.shape[2])
print('la primera es un', etiquetas[0], 'y por dentro es esto:')
print((imagenes[0] * 16).astype(int))
tenemos 1797 imágenes de 8 por 8 la primera es un 0 y por dentro es esto: [[ 0 0 5 13 9 1 0 0] [ 0 0 13 15 10 15 5 0] [ 0 3 15 2 0 11 8 0] [ 0 4 12 0 0 8 8 0] [ 0 5 8 0 0 9 8 0] [ 0 4 11 0 1 12 7 0] [ 0 2 14 5 10 12 0 0] [ 0 0 6 13 10 0 0 0]]
Ahí lo tienes: un cero dibujado con números. Se ve el agujero del medio en la franja de ceros de las filas 3, 4 y 5 👀
Y aquí está la propiedad que lo cambia todo, la que anuncié en el capítulo 1:
en esta matriz la posición significa algo. El píxel de arriba a
la izquierda y el de abajo a la derecha no son dos columnas intercambiables como
monto y ciudad: son vecinos o no lo son, y eso es
información.
La convolución, escrita
deslizas un filtro chiquito por toda la imagen y en cada posición sumas el producto punto a punto, que es cómo se detecta un borde sin decirle dónde mirar
Un filtro es una matriz chiquita. Se apoya en una esquina de la imagen, se multiplican los números que coinciden, se suman, y ese número va a la salida. Luego se corre una posición y se repite.
def convolucion(imagen, filtro):
alto, ancho = filtro.shape
salida = np.zeros((imagen.shape[0] - alto + 1, imagen.shape[1] - ancho + 1))
for i in range(salida.shape[0]):
for j in range(salida.shape[1]):
trozo = imagen[i:i + alto, j:j + ancho]
salida[i, j] = (trozo * filtro).sum()
return salida
VERTICAL = np.array([[-1., 0, 1],
[-1., 0, 1],
[-1., 0, 1]])
HORIZONTAL = VERTICAL.T
print('el filtro vertical sobre el primer dígito:')
print(np.round(convolucion(imagenes[0], VERTICAL), 1))
el filtro vertical sobre el primer dígito: [[ 2.1 1.7 -0.9 -0.2 -0.4 -1.7] [ 2.5 0.6 -1.9 1.1 0.7 -2.1] [ 2.2 -0.6 -2.2 1.6 1.5 -1.8] [ 1.9 -0.8 -1.9 1.8 1.4 -1.8] [ 2.1 -0.4 -1.4 1.8 0.2 -2.1] [ 1.9 0.8 -0.6 0.4 -0.9 -1.5]]
Mira lo que hizo ese filtro sin que nadie le explicara nada 🤯
La primera columna sale toda positiva (2,1 2,5 2,2 1,9 2,1 1,9) y la última toda negativa. Positivo significa "aquí la imagen se aclara de izquierda a derecha" y negativo lo contrario.
O sea que encontró los dos bordes verticales del cero: el de la izquierda, donde el trazo empieza, y el de la derecha, donde termina. Con nueve números y una resta.
Ese filtro se llama detector de bordes y lleva inventado desde antes que las redes neuronales. Lo que hace una red convolucional es aprender los filtros en vez de que se los escribas tú, con la retropropagación del capítulo 6 exactamente igual que cualquier otro peso.
Por qué un filtro y no una capa normal
Podrías conectar cada píxel a cada neurona, como venimos haciendo. La cuenta explica por qué no se hace:
for lado in [8, 28, 224]:
pixeles = lado * lado
densa = pixeles * 100 # 100 neuronas conectadas a todo
convo = 9 * 100 # 100 filtros de 3 por 3
print(f'imagen de {lado}x{lado}: capa densa {densa:9,} pesos '
f'convolucional {convo:6,} pesos')
imagen de 8x8: capa densa 6,400 pesos convolucional 900 pesos imagen de 28x28: capa densa 78,400 pesos convolucional 900 pesos imagen de 224x224: capa densa 5,017,600 pesos convolucional 900 pesos
Para una foto de 224 por 224, que es el tamaño típico, una capa densa necesitaría cinco millones de pesos y la convolucional novecientos. Cinco mil veces menos 😳
Y no es solo ahorro. Los mismos nueve números se usan en las 50.000 posiciones de la imagen, así que lo que el filtro aprende en una esquina sirve en todas. Una capa densa tendría que aprender por separado a reconocer un borde arriba a la izquierda y el mismo borde abajo a la derecha.
Eso se llama compartir parámetros y es la idea central del capítulo 🔑
¿Y funciona mejor?
Vamos a construir rasgos con los dos filtros y compararlos contra los píxeles crudos. Después de convolucionar, se resume cada zona con su valor máximo, que se llama pooling:
def agrupa(mapa):
return np.array([mapa[i:i + 2, j:j + 2].max()
for i in range(0, 6, 2) for j in range(0, 6, 2)])
def rasgos(imgs):
salida = []
for im in imgs:
v = np.abs(convolucion(im, VERTICAL))
h = np.abs(convolucion(im, HORIZONTAL))
salida.append(np.concatenate([agrupa(v), agrupa(h)]))
return np.array(salida)
indices = np.arange(len(imagenes))
i_tr, i_te = train_test_split(indices, test_size=0.25, random_state=42,
stratify=etiquetas)
I_tr, I_te = imagenes[i_tr], imagenes[i_te]
y_tr, y_te = etiquetas[i_tr], etiquetas[i_te]
pixeles_tr = I_tr.reshape(len(I_tr), -1)
conv_tr = rasgos(I_tr)
print('columnas con píxeles :', pixeles_tr.shape[1])
print('columnas con convolución :', conv_tr.shape[1])
m_pix = LogisticRegression(max_iter=2000).fit(pixeles_tr, y_tr)
m_conv = LogisticRegression(max_iter=2000).fit(conv_tr, y_tr)
print('acierto con píxeles :', round(accuracy_score(
y_te, m_pix.predict(I_te.reshape(len(I_te), -1))), 4))
print('acierto con convolución:', round(accuracy_score(
y_te, m_conv.predict(rasgos(I_te))), 4))
columnas con píxeles : 64 columnas con convolución : 18 acierto con píxeles : 0.9622 acierto con convolución: 0.9156
Pues no: 0,9622 con los píxeles y 0,9156 con la convolución 🙃
Y tiene una explicación aburrida y correcta: 64 píxeles llevan más información que 18 rasgos. Al agrupar tiré datos, y en imágenes tan chiquitas y tan centraditas eso no compensa.
Así que hasta aquí la convolución tampoco gana. Pero espera al siguiente apartado, que es donde se ve para qué existe de verdad.
La prueba de verdad: mover la imagen
Las fotos reales no vienen centradas. Vamos a desplazar los dígitos de prueba uno o dos píxeles y ver quién aguanta:
def desplaza(imgs, dx, dy):
return np.array([np.roll(np.roll(im, dy, axis=0), dx, axis=1) for im in imgs])
print(f'{"desplazamiento":16} {"píxeles":>9} {"convolución":>12}')
for dx, dy in [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0)]:
movidas = desplaza(I_te, dx, dy)
a_pix = accuracy_score(y_te, m_pix.predict(movidas.reshape(len(movidas), -1)))
a_conv = accuracy_score(y_te, m_conv.predict(rasgos(movidas)))
print(f'({dx},{dy}){"":11} {a_pix:9.4f} {a_conv:12.4f}')
desplazamiento píxeles convolución (0,0) 0.9622 0.9156 (1,0) 0.4089 0.5689 (0,1) 0.5356 0.6400 (1,1) 0.1156 0.2911 (2,0) 0.0756 0.1067
Ahí está 🎯
Con un píxel de desplazamiento a la derecha, el modelo de píxeles se desploma de 0,9622 a 0,4089, o sea que pierde más de la mitad de sus aciertos. La convolución baja a 0,5689, que también es una caída pero dieciséis puntos por encima.
La razón es directa: para el modelo de píxeles, la columna "píxel número 27" pasó a contener lo que antes estaba en la 26. Todo lo que aprendió sobre esa columna dejó de valer.
Para la convolución, el borde vertical sigue estando ahí, un poquito más allá, y como después agrupamos por zonas, muchas veces cae en la misma casilla. El filtro no pregunta dónde: pregunta si 🔍
Y ahora la parte honesta: las dos se desploman. Con dos píxeles, 0,0756 y 0,1067, o sea que las dos son inservibles.
Dos filtros escritos a mano y un pooling de 2 por 2 son un juguete. Una red convolucional de verdad tiene decenas de filtros aprendidos por capa, varias capas apiladas y encima aumento de datos (el freno 3 del capítulo 8, que aquí sería entrenar con las imágenes ya movidas). Con eso sí se consigue aguantar de verdad.
El compromiso del pooling
Si agrupar da resistencia al movimiento, ¿por qué no agrupar más?
def agrupa3(mapa):
return np.array([mapa[i:i + 3, j:j + 3].max()
for i in range(0, 6, 3) for j in range(0, 6, 3)])
def rasgos3(imgs):
return np.array([np.concatenate([agrupa3(np.abs(convolucion(im, VERTICAL))),
agrupa3(np.abs(convolucion(im, HORIZONTAL)))])
for im in imgs])
m_grueso = LogisticRegression(max_iter=2000).fit(rasgos3(I_tr), y_tr)
print(f'{"desplazamiento":16} {"pool 2x2":>9} {"pool 3x3":>9}')
for dx, dy in [(0, 0), (1, 0), (1, 1), (2, 0)]:
movidas = desplaza(I_te, dx, dy)
print(f'({dx},{dy}){"":11} '
f'{accuracy_score(y_te, m_conv.predict(rasgos(movidas))):9.4f} '
f'{accuracy_score(y_te, m_grueso.predict(rasgos3(movidas))):9.4f}')
desplazamiento pool 2x2 pool 3x3 (0,0) 0.9156 0.7067 (1,0) 0.5689 0.5178 (1,1) 0.2911 0.3244 (2,0) 0.1067 0.2844
Porque se paga. Con pooling de 3 por 3, en las imágenes quietas el acierto cae de 0,9156 a 0,7067, y a cambio con dos píxeles de desplazamiento sube de 0,1067 a 0,2844, casi el triple.
Ese es el compromiso de todo el asunto: agrupar da resistencia y quita precisión. Cuánto agrupar es la decisión de diseño más repetida en las redes de imágenes, y no tiene una respuesta buena, tiene la respuesta que corresponda a tus datos 🎚️
Ejercicios
1. Inventar tus propios filtros
Prueba un filtro que detecte diagonales y otro que promedie.
DIAGONAL = np.array([[-1., -1, 0],
[-1., 0, 1],
[0., 1, 1]])
PROMEDIO = np.ones((3, 3)) / 9
for nombre, f in [('diagonal', DIAGONAL), ('promedio', PROMEDIO)]:
r = convolucion(imagenes[0], f)
print(f'{nombre}: va de {r.min():.2f} a {r.max():.2f}')
print()
print('el promedio sobre el primer dígito, que es desenfocarlo:')
print(np.round(convolucion(imagenes[0], PROMEDIO), 2))
diagonal: va de -2.69 a 2.12 promedio: va de 0.21 a 0.57 el promedio sobre el primer dígito, que es desenfocarlo: [[0.25 0.46 0.57 0.53 0.41 0.28] [0.33 0.44 0.47 0.42 0.45 0.38] [0.33 0.34 0.26 0.21 0.36 0.36] [0.31 0.31 0.22 0.21 0.37 0.36] [0.31 0.34 0.34 0.34 0.41 0.33] [0.26 0.38 0.49 0.44 0.36 0.22]]
El filtro de promedio no detecta nada: desenfoca. Es lo que hace el "difuminar" de cualquier editor de fotos, y son nueve números.
Cambiar los números del filtro cambia lo que busca, y esa es toda la magia. En una red de verdad, esos nueve números empiezan aleatorios y la retropropagación los va moviendo hasta que detectan lo que haga falta 🎨
2. Cuánto encoge la imagen
Mira qué tamaño sale con filtros de distinto tamaño.
for lado in [3, 5, 7]:
f = np.ones((lado, lado))
print(f'filtro {lado}x{lado} sobre 8x8 deja',
convolucion(imagenes[0], f).shape)
filtro 3x3 sobre 8x8 deja (6, 6) filtro 5x5 sobre 8x8 deja (4, 4) filtro 7x7 sobre 8x8 deja (2, 2)
Cada filtro se come el borde: un 3 por 3 quita una fila por cada lado y un 7 por 7 quita tres.
Apila diez capas y no queda imagen. Por eso existe el padding, que es rodear la imagen de ceros antes de convolucionar para que salga del mismo tamaño 🖼️
3. El padding, en tres líneas
Escribe el relleno de ceros y comprueba que conserva el tamaño.
def con_borde(imagen, cuanto=1):
return np.pad(imagen, cuanto, mode='constant')
print('sin borde:', convolucion(imagenes[0], VERTICAL).shape)
print('con borde:', convolucion(con_borde(imagenes[0]), VERTICAL).shape)
sin borde: (6, 6) con borde: (8, 8)
De 6 por 6 a 8 por 8, igual que la entrada. Una línea de numpy.
Con esto ya puedes apilar todas las capas que quieras sin quedarte sin imagen, que es lo que hacen las redes profundas de visión 🧱
4. Entrenar con las imágenes movidas
Aplica el aumento de datos del capítulo 8: entrena con copias desplazadas.
aumentadas = np.concatenate([I_tr, desplaza(I_tr, 1, 0), desplaza(I_tr, 0, 1)])
y_aumentadas = np.concatenate([y_tr, y_tr, y_tr])
m_aum = LogisticRegression(max_iter=2000).fit(
aumentadas.reshape(len(aumentadas), -1), y_aumentadas)
print(f'{"desplazamiento":16} {"normal":>9} {"con aumento":>12}')
for dx, dy in [(0, 0), (1, 0), (1, 1)]:
movidas = desplaza(I_te, dx, dy).reshape(len(I_te), -1)
print(f'({dx},{dy}){"":11} {accuracy_score(y_te, m_pix.predict(movidas)):9.4f} '
f'{accuracy_score(y_te, m_aum.predict(movidas)):12.4f}')
desplazamiento normal con aumento (0,0) 0.9622 0.9067 (1,0) 0.4089 0.8822 (1,1) 0.1156 0.4600
Con un píxel de desplazamiento pasa de 0,4089 a 0,8822. Más del doble, y usando el mismo modelo de píxeles de siempre.
Y tiene su precio, que también hay que decirlo: en las imágenes quietas baja de 0,9622 a 0,9067. Le pediste que sirviera para tres situaciones en vez de una, y con los mismos pesos algo tenía que soltar.
Aun así, para este problema el aumento de datos resuelve más que la convolución. En imágenes de verdad se usan los dos a la vez, y este ejercicio explica por qué el aumento nunca es opcional 💪
5. Dónde se activa el filtro
Busca en qué zona del dígito responde más fuerte cada filtro.
for indice in [0, 1, 2]:
v = np.abs(convolucion(imagenes[indice], VERTICAL))
h = np.abs(convolucion(imagenes[indice], HORIZONTAL))
print(f'dígito {etiquetas[indice]}: vertical máx {v.max():.2f} '
f'horizontal máx {h.max():.2f} '
f'{"más vertical" if v.max() > h.max() else "más horizontal"}')
dígito 0: vertical máx 2.50 horizontal máx 2.00 más vertical dígito 1: vertical máx 3.00 horizontal máx 1.69 más vertical dígito 2: vertical máx 2.62 horizontal máx 2.38 más vertical
Los tres responden más al vertical, y el que más es el 1 (3,00 contra 1,69), que tiene sentido porque un uno es prácticamente una raya vertical.
Pero fíjate en que los tres dan el mismo veredicto: con solo dos filtros no se distingue casi nada.
Ahí se ve por qué las redes de verdad usan decenas de filtros por capa: cada uno se especializa en un trocito distinto, y la combinación de todos es lo que identifica 🔬
6. Convolución sobre una fila de datos, que también existe
Aplica un filtro de 1 dimensión a las ventas mensuales de la distribuidora, que son las del CSV de los otros capítulos.
import pandas as pd
URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'
v = pd.read_csv(URL).drop_duplicates()
v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
f = pd.to_datetime(v['fecha'], format='%Y-%m-%d', errors='coerce')
falta = f.isna() & v['fecha'].notna()
f[falta] = pd.to_datetime(v.loc[falta, 'fecha'], format='%d/%m/%Y', errors='coerce')
v['fecha'] = f
por_mes = v.set_index('fecha')['monto'].resample('ME').sum() / 1000
ventas_mes = por_mes.values
suaviza = np.ones(3) / 3
bordes = np.array([-1., 0, 1])
print('miles de soles por mes:', np.round(ventas_mes, 1))
print('suavizada :', np.round(np.convolve(ventas_mes, suaviza, mode='valid'), 1))
print('cambios :', np.round(np.convolve(ventas_mes, bordes[::-1], mode='valid'), 1))
miles de soles por mes: [128.8 129.5 139.9 115.5 155.5 138.1 141.6 122.4 142.4 139.6 132.4 125.9 129.8 121.5 164.3 127.4 153.4 103.5] suavizada : [132.7 128.3 136.9 136.3 145. 134. 135.4 134.8 138.1 132.6 129.4 125.7 138.5 137.7 148.3 128.1] cambios : [ 11.1 -14. 15.5 22.6 -13.8 -15.7 0.7 17.3 -10. -13.7 -2.5 -4.5 34.5 5.9 -10.9 -23.9]
El filtro que suaviza es la media móvil de toda la vida: la serie deja de dar tumbos y se ve la tendencia.
Y el de bordes marca dónde hubo un salto de un mes al siguiente. Los picos grandes de esa fila son los meses donde las ventas se movieron de verdad, y los valores cerca de cero son los meses aburridos 📊
Una media móvil es una convolución, solo que en una dimensión. Por eso las convolucionales también se usan en series de tiempo y en audio, y no solo en fotos 📈
7. El filtro más grande que la imagen, que no avisa
Pásale a una imagen de 8 por 8 un filtro de 9 por 9.
vacio = convolucion(imagenes[0], np.ones((9, 9)))
print('forma que devuelve:', vacio.shape)
forma que devuelve: (0, 0)
No revienta: devuelve una matriz de 0 por 0 tan tranquila. Un filtro de 9 no cabe en una imagen de 8, así que el bucle no entra ni una vez y la función retorna la matriz vacía que creó al principio.
El error llega después, cuando alguien intenta usar eso:
vacio.max()
ValueError: zero-size array to reduction operation maximum which has no identity
Y el mensaje no habla de filtros ni de imágenes: habla de un array vacío, en una línea que está lejos de donde nació el problema.
Esto pasa de verdad al apilar capas, porque la imagen se encoge dos píxeles por capa y en algún momento una recibe algo más chico que su propio filtro. Cuando montes una red convolucional, haz la cuenta de los tamaños antes de entrenar 📐
Comprueba que lo tienes
Sobre dígitos limpios los píxeles crudos ganan a la convolución (0,9622 contra 0,9156), y al mover la imagen un píxel se invierte. ¿Qué demuestra?
- Que la convolución no sirve para ver mejor, sirve para no depender de la posición
- Que la convolución está mal implementada
- Que los dígitos son demasiado fáciles
- Que hacen falta más filtros
Lo que te llevas
- 🖼️ En una imagen la posición significa algo, y por eso no se trata como columnas sueltas.
- 🔍 Un filtro de nueve números encuentra los bordes verticales del dígito sin que nadie le explique qué es un borde.
- 🔑 Comparte los mismos nueve números en toda la imagen: para 224 por 224 son 900 pesos contra 5 millones de una capa densa.
- 🙃 En dígitos limpios y centrados los píxeles crudos ganan (0,9622 contra 0,9156), porque agrupar tira información.
- 🎯 Con un píxel de desplazamiento los píxeles caen a 0,4089 y la convolución aguanta en 0,5689. Ahí está para qué existe.
- 😅 Y las dos se desploman con dos píxeles. Dos filtros a mano son un juguete; las redes de verdad aprenden decenas por capa.
- 🎚️ Agrupar más da resistencia y quita precisión: pool 3x3 baja el acierto limpio a 0,7067 y triplica el aguante al desplazamiento.
- 💪 Y el aumento de datos resuelve aquí más que la convolución: de 0,4089 a 0,8822, a cambio de bajar de 0,9622 a 0,9067 en las imágenes quietas.
En el capítulo 10 pasamos al texto, que tiene otra forma de estructura y otra manera de convertirse en números.
Que tengas lindo día! 🌸