Los dígitos del recibo
En resumen: El reconocimiento de dígitos con una red neuronal convierte la imagen en píxeles como características, y una red con una neurona por dígito aprende a decir de qué dígito se trata.
Problema
El total desde una foto
Para devolver un artículo, el cliente envía una foto del recibo de la caja, y el sistema tiene que leer el total. La caja imprime los dígitos con una sola fuente, así que parece fácil: compara los píxeles con la fuente y listo.
Pero el papel se arruga, la tinta se desvanece y la foto tiene manchas. Aquí está el 7 de la fuente y tres sietes de recibos reales:
print_side([show(pixels(DIGITS[7])), show(train_x[7]), show(train_x[17]), show(train_x[27])])##### ##### ##### #####....# ....# ....# ....#...#. ...## ..... ...#...#.. ..#.. #.#.. ..##...#.. ..#.. ..#.. #.##.Al tercer 7 le falta un píxel en la fila de en medio, el cuarto tiene tinta de más abajo. Una regla «es un 7 si estos 9 píxeles son tinta» se rompe en la primera mancha. Y no puedes escribir una regla para cada mancha posible.
La idea
Los píxeles son características
Una imagen de 5 × 5 son 25 píxeles. Alinéalos fila por fila y tienes 25 números: 1 para tinta, 0 para vacío. Son características, igual que la distancia y el peso de un pedido. Reconocer una imagen es clasificación con 25 características y 10 clases, los dígitos del 0 al 9. Esto es clasificación de imágenes.
La red es una capa de 10 neuronas, una por dígito. Cada neurona tiene un peso para cada píxel: su suma es grande cuando la tinta está donde su dígito tiene tinta.
Las diez sumas se convierten en probabilidades con softmax: cada suma pasa a ser eˢ, y luego cada número se divide entre el total de todos. Los números son positivos y suman 1. El mayor es la respuesta, y su tamaño es cuán segura está la red.
El entrenamiento es el ciclo de la lección anterior: épocas, mezcla, lotes, un paso. El error de una imagen es −ln(p) del dígito correcto: 0 si la red da la respuesta correcta al 100 %, y grande si le da poco.
Los datos: 60 dígitos para entrenar (6 de cada uno) y 30 para la prueba (3 de cada uno), todos de la fuente con manchas. Cada píxel se invierte con una probabilidad del 8 %, dos píxeles por dígito en promedio.
Míralo
Los datos y la red
La fuente está escrita en el código como texto, # es tinta:
# los dígitos de la fuente de la caja: 5 × 5 píxeles, # es tintaDIGITS = [ [".###.", "#...#", "#...#", "#...#", ".###."], ["..#..", ".##..", "..#..", "..#..", ".###."], [".###.", "#...#", "..##.", ".#...", "#####"], ["####.", "....#", ".###.", "....#", "####."], ["#..#.", "#..#.", "#####", "...#.", "...#."], ["#####", "#....", "####.", "....#", "####."], [".###.", "#....", "####.", "#...#", ".###."], ["#####", "....#", "...#.", "..#..", "..#.."], [".###.", "#...#", ".###.", "#...#", ".###."], [".###.", "#...#", ".####", "....#", ".###."],]Una imagen se convierte en características, las manchas vienen del generador, y las características vuelven a ser una imagen para poder imprimirlas:
# la imagen como 25 características: fila por fila, 1 para tinta, 0 para vacíodef pixels(rows): return [1.0 if rows[r][c] == "#" else 0.0 for r in range(5) for c in range(5)]
# una mancha o tinta desvaída: cada píxel se invierte con una probabilidad del 8 %def smudge(x, rng): return [1 - v if rng.next() < 0.08 else v for v in x]
# las características de vuelta como imagendef show(x): return ["".join("#" if v > 0.5 else "." for v in x[r * 5:r * 5 + 5]) for r in range(5)]
# varias imágenes una al lado de otradef print_side(pics): for r in range(5): print(" ".join(pic[r] for pic in pics))El generador es el de la lección anterior, con semilla 7. Primero vienen los 60 dígitos de entrenamiento, luego los 30 de prueba:
class Rng: def __init__(self, seed): self.state = seed
# el siguiente número entre 0 y 1; una misma semilla da los mismos números def next(self): self.state = self.state * 48271 % 2147483647 return self.state / 2147483647rng = Rng(7)train_y = [n % 10 for n in range(60)]train_x = [smudge(pixels(DIGITS[d]), rng) for d in train_y]test_y = [n % 10 for n in range(30)]test_x = [smudge(pixels(DIGITS[d]), rng) for d in test_y]La red: 10 × 25 pesos y 10 sesgos. predict calcula las diez sumas y las convierte en probabilidades. Antes de exp, a cada suma se le resta la mayor: el resultado es el mismo, pero exp no se desborda con sumas grandes. En step la pendiente de la neurona k según su suma es p[k] − 1 para el dígito correcto y p[k] para los demás: la neurona correcta se empuja hacia arriba y las otras hacia abajo, tanto como se equivocaron.
class Net: # pesos aleatorios pequeños; los sesgos siguen en 0 def __init__(self, rng): self.w = [[(rng.next() * 2 - 1) * 0.1 for _ in range(25)] for _ in range(10)] # un peso para cada píxel en cada una de las 10 neuronas self.b = [0.0] * 10
# las diez probabilidades (softmax): suman 1 def predict(self, x): p = [] for k in range(10): s = self.b[k] for i in range(25): s += self.w[k][i] * x[i] p.append(s) top = max(p) total = 0.0 for k in range(10): p[k] = math.exp(p[k] - top) total += p[k] for k in range(10): p[k] /= total return p
# el error: la media de −ln(p) de la respuesta correcta def loss(self, xs, ys): total = 0.0 for x, y in zip(xs, ys): total -= math.log(self.predict(x)[y]) return total / len(xs)
# un paso por lote: la pendiente de la neurona k es p[k] − 1 para el dígito correcto, p[k] para los demás def step(self, xs, ys, batch, rate): gw = [[0.0] * 25 for _ in range(10)] gb = [0.0] * 10 for n in batch: p = self.predict(xs[n]) for k in range(10): d = p[k] - (1 if k == ys[n] else 0) gb[k] += d for i in range(25): gw[k][i] += d * xs[n][i] for k in range(10): self.b[k] -= rate * gb[k] / len(batch) for i in range(25): self.w[k][i] -= rate * gw[k][i] / len(batch)Pruébalo
Predice la salida
¿Qué imprime el programa? x[r * 5 + 2] es el píxel de la fila r y la columna 2 (contando desde 0).
x = pixels(DIGITS[7])print(" ".join(str(int(x[r * 5 + 2])) for r in range(5)))La fila r empieza en el índice r · 5, así que r · 5 + 2 es la tercera columna de cada fila. En el 7 es tinta arriba (#####), vacío en las dos filas siguientes y tinta en las dos de abajo (..#..). «0 0 0 1 0» sería la tercera fila, no la tercera columna.
Míralo
El entrenamiento, y un dígito de cerca
60 épocas, lotes de 10, tasa 0,5. Cada 10 épocas el programa imprime el error y cuántos dígitos son correctos en el conjunto de entrenamiento y en el de prueba:
net = Net(rng)order = list(range(len(train_x)))print("época error entrenamiento prueba")for epoch in range(1, 61): shuffle(order, rng) for start in range(0, len(order), 10): net.step(train_x, train_y, order[start:start + 10], 0.5) if epoch % 10 == 0: right = f"{correct(net, train_x, train_y)}/{len(train_x)}" test = f"{correct(net, test_x, test_y)}/{len(test_x)}" print(f"{epoch:5d}{net.loss(train_x, train_y):7.3f}{right:>15}{test:>8}")época error entrenamiento prueba 10 0.268 59/60 28/30 20 0.128 60/60 29/30 30 0.084 60/60 29/30 40 0.063 60/60 29/30 50 0.050 60/60 29/30 60 0.042 60/60 29/30Tras 20 épocas la red acierta los 60 dígitos de entrenamiento y 29 de 30 en el conjunto de pruebas. El error sigue bajando porque la red se vuelve más segura, pero el dígito de prueba que falla sigue siendo el mismo.
Aquí ves lo que le pasa a un dígito. Los píxeles entran, cada neurona suma sus pesos sobre ellos, y softmax convierte las sumas en probabilidades:
# las diez probabilidades (softmax): suman 1def predict(self, x): p = [] for k in range(10): s = self.b[k] for i in range(25): s += self.w[k][i] * x[i] p.append(s) top = max(p) total = 0.0 for k in range(10): p[k] = math.exp(p[k] - top) total += p[k] for k in range(10): p[k] /= total return p
net = train(train_x, train_y, rng)shifted = pixels(shift(DIGITS[1]))for x in [test_x[17], shifted]: print_side([show(x)]) p = net.predict(x) for k in range(10): bar = "█" * round(p[k] * 20) print(f"{k} {bar:<20} {p[k] * 100:5.1f}%") print(f"→ {best(p)}")Paso a paso, en palabras
- Un 7 del conjunto de pruebas, con una mancha abajo a la izquierda. Para la red son 25 números: 1 para tinta, 0 para vacío.
- Cada una de las 10 neuronas suma sus pesos sobre los píxeles con tinta. La neurona del 7 tiene la mayor suma: 6,68.
- Softmax: e elevado a cada suma, dividido por el total. Las diez barras suman el 100 %.
- La barra más grande es la respuesta: 7 con el 98,4 %. La mancha no estorbó.
- El 1 de la fuente, sin mancha, impreso un píxel a la derecha. Su tinta está en la cuarta columna.
- La red dice 4 con 57,1 %, y el 1 recibe solo 4,4 %. Los pesos del 4 esperan tinta justo en esa columna.
- Cada peso está atado a un píxel. Las manchas la red aprendió a ignorarlas; un desplazamiento de un píxel nunca lo ha visto.
net = train(train_x, train_y, rng)shifted = pixels(shift(DIGITS[1]))for x in [test_x[17], shifted]: print_side([show(x)]) p = net.predict(x) for k in range(10): bar = "█" * round(p[k] * 20) print(f"{k} {bar:<20} {p[k] * 100:5.1f}%") print(f"→ {best(p)}")#####....#...#...#..#.#..0 0.1%1 0.0%2 0.2%3 0.4%4 0.0%5 0.7%6 0.0%7 ████████████████████ 98.4%8 0.1%9 0.1%→ 7...#...##....#....#...###0 0.2%1 █ 4.4%2 ███ 15.4%3 2.3%4 ███████████ 57.1%5 0.6%6 █ 5.0%7 █ 7.1%8 █ 2.9%9 █ 5.0%→ 4El 7 del conjunto de pruebas tiene una mancha abajo a la izquierda, y la red igual dice 7 con 98,4 %. La segunda imagen es el 1 de la fuente, impreso un píxel a la derecha. Ni una sola mancha, y la red dice 4 con 57,1 %. Volveremos a eso.
La idea
Qué mira cada neurona
Cada neurona tiene 25 pesos, uno por píxel, así que sus pesos también son una imagen de 5 × 5. Un peso positivo grande significa «la tinta aquí está a mi favor», uno negativo grande significa «la tinta aquí está en mi contra»:
# # «sí» fuerte, + «sí», . da igual, - «no», = «no» fuertedef symbol(v): if v > 0.8: return "#" if v > 0.25: return "+" if v < -0.8: return "=" if v < -0.25: return "-" return "."
def weight_map(weights): return ["".join(symbol(v) for v in weights[r * 5:r * 5 + 5]) for r in range(5)]net = train(train_x, train_y, rng)print(" 0 1 7 ")print_side([weight_map(net.w[d]) for d in (0, 1, 7)]) 0 1 7.+.-- .-+=. #+++#+.-.# -##.- -.--+#===# --+=- -==+.#--.+ -+#.- ..#.-.+.-- -+.+- -=+-.La neurona del 0 quiere tinta en las dos columnas exteriores y está muy en contra de la tinta en medio de la tercera fila: ahí el 0 está vacío, mientras que 8, 3 y 2 tienen tinta. La neurona del 7 quiere toda la fila de arriba y la tercera columna abajo. La neurona del 1 quiere los dos píxeles de arriba en el centro (.##.) y está en contra de la esquina inferior izquierda, donde 2 y 5 tienen tinta.
Nadie le dijo a la red dónde está el trazo del 7. Los pesos se encontraron solo a partir de los ejemplos. No son una copia del dígito: una neurona también mira en qué se diferencia su dígito de los demás.
Pruébalo
Completa
Las últimas líneas de softmax. ¿Entre qué se divide cada número, para que los diez sumen 1?
for k in range(10):
p[k] /= Entre total, la suma de los diez valores de exp. Cada número pasa a ser su parte del todo, así que las partes suman 1. Si dividieras entre el mayor, el mayor valdría 1 y la suma de todos sería más de 1, y los números ya no serían probabilidades.
La idea
Un píxel hacia un lado
La caja a veces imprime un dígito un poco hacia un lado, y la foto se toma de forma torcida. Aquí hay tres dígitos de la fuente, sin manchas, desplazados un píxel a la derecha:
# el mismo dígito, impreso un píxel a la derechadef shift(rows): return ["." + row[:4] for row in rows]net = train(train_x, train_y, rng)digits = [1, 3, 7]print_side([shift(DIGITS[d]) for d in digits])for d in digits: p = net.predict(pixels(shift(DIGITS[d]))) print(f"{d} → {best(p)} ({p[best(p)] * 100:.1f}%)")...#. .#### .####..##. ..... ........#. ..### ....#...#. ..... ...#...### .#### ...#.1 → 4 (57.1%)3 → 9 (77.7%)7 → 9 (39.0%)La red que acierta 29 de 30 dígitos manchados se equivoca en los tres. De los diez dígitos desplazados solo el 9 sale bien, y con 53,8 %.
La razón está en los pesos. Cada peso está atado a un píxel en un lugar. La neurona del 1 espera tinta en la columna central. El 1 desplazado está en la cuarta columna, y la tinta ahí es lo que espera la neurona del 4. Para la red la misma forma en otro lugar es otra imagen: no sabe que ha visto «un trazo», solo sabe qué píxeles son tinta.
Por eso los datos de entrenamiento tienen que parecerse a los datos reales. Si los dígitos se desplazan en las fotos, los ejemplos de entrenamiento también tienen que desplazarse. O la red tiene que ser de otro tipo, una que busque la forma en todas partes (mira «Más»).
Pruébalo
Encuentra el error
Un colega escribe un informe sobre la exactitud de la red y dice «100 %». En los recibos de los clientes la red se equivoca. ¿En qué línea está el error?
Elige la línea con el error.
La exactitud se mide sobre los dígitos de los que aprendió la red. Ya los ha visto, así que acierta los 60. La medida honesta es el conjunto de pruebas, que no participó en el entrenamiento: correct(net, test_x, test_y), 29 de 30, o 96 %. Y el conjunto de pruebas es la misma fuente con manchas: no tiene dígitos desplazados, así que incluso el 96 % es más optimista que los recibos reales.
La idea
Cuándo perjudica
- Equivocada con seguridad. El único error del conjunto de pruebas es un 3 con una mancha en medio que la red lee como un 9 con 91,2 %. La probabilidad es cuán segura está la red, no cuánta razón tiene. Cuando un error cuesta dinero, el umbral de «seguro» se comprueba en un conjunto de pruebas, y una persona mira los dígitos dudosos.
- Todo lo que no ha visto. Un desplazamiento, otra fuente, una foto torcida, un dígito escrito a mano: la red solo ha visto la fuente con manchas y no sabe nada más. Para ella cada imagen es uno de los diez dígitos, incluso un cuadrado vacío o una letra.
- Pocos datos. 60 imágenes bastan porque hay una sola fuente y los dígitos son de 5 × 5. La foto de un recibo real necesita miles de ejemplos y una red más grande.
- No hace falta una red. Si la caja también envía el total como texto, o imprime un código QR en el recibo, lee eso. El reconocimiento es para los casos en que no hay otro camino.
Más precisamente, para curiosos
Softmax como fórmula. Las sumas de las diez neuronas son s₀ … s₉. La probabilidad del dígito k es:
p[k] = e^(s[k]) / (e^(s₀) + e^(s₁) + … + e^(s₉))
En palabras: vuelve positiva cada suma con e elevado a la suma, y luego divide entre el total. Con dos dígitos esto es la sigmoide de la regresión logística: softmax es su extensión a más clases. Restar la mayor suma no cambia el resultado, porque e^(s − m) = e^s / e^m, y e^m se cancela.
Por qué la pendiente es p[k] − 1. El error de una imagen es L = −ln(p[y]), donde y es el dígito correcto (entropía cruzada). Su pendiente según la suma de la neurona k sale muy simple: p[k] − 1 para k = y y p[k] para las demás. Si la red da la respuesta correcta al 98 %, el empujón es solo −0,02. Si le da el 10 %, es −0,9. A partir de ahí la retropropagación sigue hacia los pesos como en las lecciones anteriores: la pendiente según el peso i es la pendiente según la suma por el píxel i.
Por qué no hay capa oculta. Con 10 neuronas directamente desde los píxeles, cada peso es de un píxel y un dígito, así que el mapa de pesos se puede leer. Con una capa oculta la red acierta más, pero los mapas de las neuronas ocultas son una mezcla de partes de varios dígitos y difíciles de leer. Pruébalo en los ejercicios.
Redes para imágenes. Las redes reales para imágenes son convolucionales (CNN): una ventana pequeña de pesos, digamos de 3 × 3, se desliza por toda la imagen y busca la misma forma en todas partes. Un trazo es un trazo a la izquierda y a la derecha, así que un desplazamiento casi no las molesta. El ejemplo clásico es MNIST: 70 000 dígitos escritos a mano, de 28 × 28 píxeles. En PyTorch la ventana es nn.Conv2d.
Los mismos números en cuatro lenguajes. Los programas imprimen las probabilidades con un decimal, y las barras se redondean a 20 partes. La lección comprueba que ninguna barra ni ningún peso queda justo en un límite de redondeo, para que la salida sea la misma en Java, C#, Python y JavaScript.
Ejercicio
El total del recibo
Resuélvelo en tu editor.
El total del recibo es 12,90 €: los dígitos 1, 2, 9 y 0. La caja imprimió el último dígito un píxel a la derecha, y la foto tiene manchas. Haz las cuatro imágenes (la última desplazada, todas manchadas con el mismo generador, después de entrenar), reconoce cada una e imprime el dígito y su probabilidad. Si la probabilidad está por debajo del 90 %, marca el dígito para revisarlo a mano y pon un ? en su lugar en el total.
dígito 1: 1 (97.0%)dígito 2: 2 (94.4%)dígito 3: 9 (90.5%)dígito 4: 7 (37.4%) → revisar a manototal: 12,9? € → revisar a manoEl 0 desplazado se convierte en un 7, pero solo con 37,4 %, y el umbral lo atrapa. El 9 pasa con 90,5 %, por muy poco sobre el umbral. El umbral no hace a la red más lista, te dice cuándo no confiar en ella. Un recibo con un ? va a una persona, no a contabilidad.
Más ejercicios
- Ejemplos desplazados. Añade a los datos de entrenamiento cada dígito de la fuente desplazado un píxel a la izquierda y a la derecha, y entrena de nuevo. ¿Cuántos de los diez dígitos desplazados acierta ahora? ¿Y cuántos en el conjunto de pruebas?
- Una capa oculta. Pon una capa oculta de 16 neuronas con sigmoide entre los píxeles y softmax. Imprime los pesos de dos neuronas ocultas como imagen. ¿Alguna se parece a un dígito?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: La neurona · Capas · Retropropagación · El entrenamiento de principio a fin
- Continúa en: Predecir el siguiente token
- Ejercicios sobre el tema: Una red neuronal por dentro