Capítulo 9 de 15 8 secciones 16 min

Cuando el dato tiene forma: la convolución

Un filtro de nueve números que recorre la imagen. Escrito a mano y medido contra los píxeles sueltos, moviendo los dígitos.

Una convolución es un filtro pequeño, por ejemplo de 3 por 3, que se pasa por toda la imagen multiplicando y sumando. Sirve porque comparte los mismos nueve números en todas las posiciones, así que aprende una vez y reconoce en cualquier sitio. Sobre dígitos limpios, los píxeles sueltos ganan (0,9622 contra 0,9156); si desplazas la imagen un píxel, los píxeles caen a 0,4089 y la convolución aguanta en 0,5689.

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.

Una rejilla de entrada de 6 por 6 con una ventana de 3 por 3 resaltada, y una flecha que va de esa ventana a una sola celda del mapa de características de 4 por 4 que resulta.
La ventanita se posa, multiplica y suma, y de esas nueve celdas sale un solo número. Luego se corre una posición y repite. Los nueve pesos son los mismos en todas las posiciones, y eso es lo que hace que un borde aprendido arriba a la izquierda valga también abajo a la derecha.

La convolución, escrita

S(i,j)=uvI(i+u,j+v)K(u,v)

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.

Cuatro filas apiladas, de la entrada a la capa 3, con líneas que conectan cada zona resaltada con la de arriba. La zona resaltada se va estrechando hacia arriba: 7 pixeles en la entrada, 5, 3 y 1 en la capa 3.
Ninguna de las tres capas mira más de tres pixeles a la vez, y aun así la neurona de arriba acaba dependiendo de siete de la imagen original. Apilar capas ensancha lo que se ve sin agrandar el filtro, y por eso la profundidad no es un capricho.

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.

Una rejilla de 4 por 4 con números que se reduce a otra de 2 por 2: de cada bloque de dos por dos se conserva únicamente el valor máximo.
De cuatro números queda uno, el más alto. Se pierde dónde estaba exactamente el patrón y se conserva que estaba por esa zona, que para reconocer un gato es justo lo que hace falta y de paso divide entre cuatro lo que tiene que procesar la capa siguiente.

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! 🌸

¿Tienes alguna duda o consulta?