Capítulo 5 · El porqué
Decisiones técnicas
Cada número y cada herramienta de este proyecto es una decisión. Aquí están todas, con lo que se eligió, las alternativas que había, por qué se eligió así y qué pasaría si lo cambias. Las que vienen del artículo lo dicen. Las que se tomaron por las limitaciones de la máquina de entrenamiento, un portátil sin GPU, también.
Cómo leer esta página
Cada tarjeta tiene cuatro partes:
- Elegí: lo que hace el código.
- Alternativas: lo que se pudo hacer en su lugar.
- Por qué: el argumento. Si viene del artículo, dice artículo; si se midió en la máquina de entrenamiento, dice medido.
- Si lo cambias: qué esperar si experimentas con eso. Casi todos estos valores son constantes al
principio de
nca.pyoentrenar.py.
Tecnologías
PyTorch, en CPU, con uv
- Elegí
- Entrenar con PyTorch, la versión para CPU, corrida con
uv runy el índice de PyTorch para CPU. - Alternativas
- TensorFlow (el código original del artículo), JAX, o escribir la retropropagación a mano con NumPy.
- Por qué
- PyTorch es la librería más usada para aprender deep learning, y su código se lee casi como la explicación.
Calcula los gradientes solo (
backward()), cosa indispensable aquí: escribir a mano la retropropagación a través de 96 pasos con máscaras sería un proyecto en sí mismo. La máquina de entrenamiento, un portátil con un AMD Ryzen 9 8945HS, no tiene GPU NVIDIA, así que la versión CUDA solo agregaría varios GB de descarga sin usarse. - Si lo cambias
- Con una GPU NVIDIA, la versión CUDA entrenaría bastante más rápido y podrías usar los 8 000 pasos del artículo sin esperar horas.
El emoji, dibujado con la fuente que ya tienes
- Elegí
- Renderizar 🦎 con Pillow usando
/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf, recortarlo y reducirlo a 40×40. - Alternativas
- Descargar el PNG del emoji desde el repositorio de Noto Emoji, como hace el artículo.
- Por qué
- No depende de internet ni de una URL que puede cambiar. Un detalle que costó: Noto Color Emoji no es una
fuente de contornos sino de mapas de bits, y solo trae un tamaño, 109 píxeles. Pedirle otro tamaño falla.
Por eso se dibuja a 109 y después se reduce con
thumbnail. - Si lo cambias
--emoji 🐙entrena con otro emoji y--imagen dibujo.pngcon tu propio dibujo (con fondo transparente). Las formas compactas son más fáciles que las de muchas ramas finas.
JavaScript puro y canvas 2D en el navegador
- Elegí
- Reescribir el paso del autómata en JavaScript plano (
web/nca.js, unas 150 líneas), con bucles célula por célula y un<canvas>2D. - Alternativas
- WebGL con shaders, como el demo del artículo; TensorFlow.js; exportar el modelo a ONNX y correrlo con ONNX Runtime Web.
- Por qué
- La prioridad es que se entienda. Los bucles de
nca.jsse leen igual que la explicación del capítulo 3, sin librerías ni compilación. La red es diminuta y en cada paso calculan solo unas centenas de células, así que JavaScript simple alcanza para animar a decenas de pasos por segundo. WebGL sería mucho más rápido, pero el cálculo quedaría escondido en un shader. - Si lo cambias
- Para cuadrículas de 256×256 o más, el JavaScript simple ya no daría abasto y habría que ir a WebGL.
Pesos en archivos .js, codificados en base64
- Elegí
- Que
entrenar.pyescribaweb/modelos/<experimento>.js, un script que agrega sus datos aNCA_MODELOS. Los pesos van como float32 en base64. - Alternativas
- Un
.jsonleído confetch; los pesos como listas de decimales en texto. - Por qué
- Las páginas se abren con doble clic, sin servidor. Desde
file://el navegador bloqueafetch, pero sí carga un<script src>. El base64 de float32 guarda los números exactos (33 280 bytes por punto de control, 44 KB en base64). En decimales con pocas cifras, el autómata corrido en el navegador se iría separando del entrenado: es un sistema que se aplica cientos de veces, y los errores chicos crecen. - Si lo cambias
- Si sirves la carpeta
web/con un servidor,fetchfuncionaría igual de bien.
Verificar JavaScript contra PyTorch con Node
- Elegí
verificar_js.py: hace crecer la lagartija en PyTorch, da un paso sin azar en los dos lenguajes con los mismos pesos y compara los 82 944 números.- Alternativas
- Mirar las dos animaciones y ver que "se parecen".
- Por qué
- Dos implementaciones del mismo cálculo divergen fácilmente: un filtro Sobel transpuesto, un orden de canales
distinto o una máscara aplicada a destiempo producen una lagartija que casi funciona. Mirarla no basta. La
comparación numérica da 10⁻⁷, puro redondeo. El azar se apaga (
prob = 1) porque los dos lenguajes tienen generadores de números aleatorios distintos. - Si lo cambias
- Si tocas
nca.pyonca.js, vuelve a correrlo.
Páginas estáticas, sin framework
- Elegí
- HTML, un
estilo.csscompartido (un cuaderno de cuadros),nca.jsycomun.js. Sin React, sin compilación. - Alternativas
- Una aplicación con Vite y React, o un cuaderno Jupyter.
- Por qué
- Se abre con doble clic, se publica copiando la carpeta y en diez años va a seguir abriendo. Para unas páginas con unos pocos widgets, un framework agrega más de lo que resuelve.
El modelo
16 canales: 4 visibles y 12 ocultos
- Elegí
CANALES = 16.- Por qué
- Artículo. Los 12 ocultos son el espacio que tienen las células para coordinarse: con ellos inventan señales que les dicen, por ejemplo, a qué distancia están de la semilla o en qué parte del cuerpo están. 4 canales visibles (RGBA) son el mínimo para dibujar.
- Si lo cambias
- Menos canales ocultos dejan a las células con menos señales para coordinarse; más canales dan más capacidad y hacen más
lento cada paso. No lo medí: es un buen experimento cambiar
CANALESa 8 y comparar la foto final.
Percepción fija: identidad y Sobel
- Elegí
- Tres filtros de 3×3 fijos por canal, que dan 48 números de entrada.
- Alternativas
- Una convolución 3×3 aprendida, con 16 × 9 pesos por filtro.
- Por qué
- Artículo. Valor y gradiente es justo lo que necesita una célula: qué tengo yo y hacia dónde hay más de cada cosa. Es la analogía de una célula que sigue un gradiente químico. Además permite el experimento 4: girar los filtros gira la forma.
- Si lo cambias
- Una percepción aprendida funciona también, con más parámetros y sin la interpretación física.
128 neuronas ocultas, 8 320 parámetros
- Elegí
OCULTAS = 128.- Por qué
- Artículo. Es la capacidad que usa el original para un emoji de 40×40. Es lo que más cuesta en cada paso: 8 192 multiplicaciones por célula.
- Si lo cambias
- El costo por célula es proporcional a
OCULTAS: con 64 cada paso cuesta la mitad. Cuánto se pierde en nitidez no lo medí; es otro experimento barato.
Última capa en cero y sin sesgo
- Elegí
nn.init.zeros_(self.capa2.weight)ybias=False.- Por qué
- Artículo. Al empezar, el cambio es cero en todas partes: la semilla se queda quieta y el entrenamiento parte desde la calma, no desde el ruido. Sin sesgo, "no percibo nada" se traduce en "no cambio nada", que es el comportamiento natural del vacío.
- Si lo cambias
- Con pesos al azar, las primeras decenas de pasos de entrenamiento se gastan en apagar el caos que produce la regla aleatoria.
Actualización residual: la red propone un cambio
- Elegí
x = x + dx, en vez dex = red(percepción).- Por qué
- Artículo. Mantener la forma es producir ceros, lo más fácil del mundo para la red. Si la red tuviera que producir el estado completo cada vez, quedarse quieta exigiría copiar exactamente sus 16 números, y cualquier error se acumularía.
Disparo con probabilidad ½
- Elegí
PROB_DISPARO = 0.5.- Por qué
- Artículo. Sin reloj global (no existe en biología), rompe la simetría de la semilla y obliga a una regla robusta al orden. El capítulo 3 lo explica.
- Si lo cambias
- Medido con los modelos finales, cambiándolo sin reentrenar: con 0,25 la lagartija crece el doble de lento y después se mantiene igual de bien (pérdida 0,0001 en el paso 1 000 para regenerar). Con 1, todas a la vez, ninguno funciona: persistir arma una forma equivocada de casi el doble de tamaño y regenerar crece sin control hasta ocupar casi toda la cuadrícula. La regla aguanta ir más lento, pero no aguanta que todas las células actúen sincronizadas, porque nunca lo vio. Pruébalo en el laboratorio.
Umbral de vida 0,1, aplicado antes y después
- Elegí
UMBRAL_VIDA = 0.1y la máscaraantes & después.- Por qué
- Artículo. Sin vecinas vivas no hay vida: evita que el fondo se llene de basura y define dónde se calcula. Aplicarla también después permite que la forma se encoja: si una célula se apaga, sus vecinas sin otro apoyo mueren en el mismo paso.
Bordes de la cuadrícula en cero, no circulares
- Elegí
padding=1con ceros en la percepción: fuera de la cuadrícula no hay nada.- Alternativas
- Bordes circulares: lo que sale por la derecha entra por la izquierda.
- Por qué
- Es lo que hace el original con la convolución estándar. Con 16 células de margen alrededor del objetivo, una lagartija sana nunca toca el borde. Uno que explota sí lo hace, y con bordes en cero se ve claramente cómo choca.
El entrenamiento
Objetivo de 40×40 con 16 de margen: cuadrícula de 72×72
- Elegí
LADO_OBJETIVO = 40,RELLENO = 16.- Por qué
- Artículo. 40×40 es suficiente para reconocer un emoji y deja la red chica. El margen da espacio para crecer y para que la pérdida castigue lo que se sale de la forma.
- Si lo cambias
- El costo crece con el área: un objetivo de 64×64 tiene 2,6 veces más células que calcular.
Entre 64 y 96 pasos del autómata por intento, al azar
- Elegí
PASOS_AUTOMATA = (64, 96).- Por qué
- Artículo. Con un número fijo, la red aprendería a verse bien en ese paso exacto y nada más. Con un rango, tiene que llegar y quedarse un rato. 64 alcanza para que la forma crezca desde un punto.
- Si lo cambias
- La vida avanza como mucho una célula por paso, porque solo nace junto a otra viva, y con el disparo al 50 % avanza más lento. La lagartija llega a unas 20 células del centro, así que con pocos pasos no alcanza a crecer entera. Más pasos significa más memoria y más tiempo por paso de entrenamiento.
Lotes de 8
- Elegí
LOTE = 8.- Por qué
- Artículo. Promediar el gradiente de 8 intentos distintos (cada uno con su azar) da una dirección más confiable que uno solo. Cada intento guarda 96 pasos para la retropropagación, así que 8 ya ocupa alrededor de 1,5 GB de RAM.
Pérdida: error cuadrático medio sobre los 4 canales visibles
- Elegí
((x[:, :4] - objetivo) ** 2).mean().- Por qué
- Artículo. Compara solo lo que se ve. Los 12 canales ocultos quedan libres: la red decide qué guardar en ellos. Si también los comparáramos con algo, le quitaríamos justamente el espacio para coordinarse.
Adam con tasa 0,002 que baja a 0,0002 en el paso 2 000
- Elegí
Adam(lr=2e-3)conMultiStepLR(milestones=[2000], gamma=0.1).- Por qué
- Artículo. Pasos grandes para aprender rápido la forma general; pasos chicos después para afinarla sin volver a desarmarla. En la curva de pérdida del capítulo 4 se ve el escalón donde baja la tasa.
Normalizar el gradiente de cada tensor
- Elegí
p.grad /= p.grad.norm() + 1e-8antes de cada paso de Adam.- Por qué
- Artículo. A través de 96 pasos los gradientes pueden explotar. Normalizados, solo importa su
dirección, y Adam decide el tamaño del paso. Es más agresivo que el recorte de gradiente usual
(
clip_grad_norm_), que solo actúa cuando el gradiente pasa de un límite.
Pool de 1 024 estados; la peor muestra vuelve a ser semilla
- Elegí
POOL = 1024. En cada paso se sacan 8 del pool, se ordenan por pérdida, la peor se reemplaza por la semilla y, después de entrenar, las 8 vuelven al pool.- Por qué
- Artículo. El pool hace que la red entrene desde estados que ya crecieron 100, 500 o 5 000 pasos, y tenga que seguir viéndose bien 64 a 96 pasos más. Así aprende a quedarse en la forma. Reemplazar la peor por la semilla impide que olvide cómo crecer desde cero, y saca del pool los estados que se echaron a perder.
- Si lo cambias
- Con un pool chico, los mismos estados se reentrenan muy seguido y hay menos variedad de edades. Sin reemplazar por la semilla, nada obliga a la red a seguir sabiendo crecer desde un punto. Ninguno de los dos casos lo medí aquí.
Daño: borrar un círculo a las 3 mejores muestras
- Elegí
DANADAS = 3, círculos de radio entre 0,1 y 0,4 (en una cuadrícula que va de −1 a 1) con centro al azar dentro de ±0,5.- Por qué
- Artículo. Se dañan las mejores porque son las que ya tienen la forma: dañar una muestra mal formada no enseña nada nuevo. Así el pool siempre tiene heridas frescas que curar.
- Si lo cambias
- Medido: el modelo final se cura también de un corte por la mitad, que nunca vio. La pérdida baja de 0,014 a 0,002 en 100 pasos. Lo que aprendió no es "rellenar círculos" sino algo más general: volver a la forma desde lo que quede. Sin daño en el entrenamiento (experimento persistir), la misma herida circular empeora en vez de curarse.
4 000 pasos de entrenamiento en vez de 8 000
- Elegí
--pasos 4000por defecto.- Por qué
- Medido. En la CPU de entrenamiento cada paso de entrenamiento toma un poco más de un segundo con los tres experimentos corriendo en paralelo. 8 000 pasos serían más de dos horas y media por experimento. Con 4 000 la forma ya sale nítida y la curva de pérdida sigue bajando, pero lento: del paso 3 000 al 4 000 bajó entre un 20 y un 40 %.
- Si lo cambias
python entrenar.py regenerar --pasos 8000. Los puntos de control van hasta 8 000.
Semilla aleatoria fija
- Elegí
torch.manual_seed(0)yrandom.seed(0).- Por qué
- Para que dos corridas den resultados parecidos y se puedan comparar. No es reproducibilidad exacta: con varios hilos, PyTorch suma en órdenes distintos y los decimales finales cambian.
Puntos de control con fotos
- Elegí
- En los pasos 25, 50, 100, 200, 400, 700, 1 000, 1 500, 2 000, 3 000 y 4 000 se guardan los pesos y cuatro fotos: a los 100 pasos del autómata, a los 1 000, recién herida en el paso 300, y 300 pasos después de la herida.
- Por qué
- Para mostrar en la página cómo fue aprendiendo, sin que el navegador tenga que calcular miles de pasos. Están más juntos al principio porque ahí es donde la red cambia más. Además el archivo se reescribe en cada punto, así que puedes recargar la página y mirar el avance mientras entrena.
Rendimiento: qué se probó y qué funcionó
La primera versión tardaba 1,5 segundos por paso de entrenamiento: 4 000 pasos eran casi 2 horas por experimento, y con los tres en paralelo, que compiten por los mismos núcleos, cerca de 4 horas. Estas son las mediciones de un paso de entrenamiento completo (80 pasos del autómata hacia adelante y la retropropagación), lote de 8, en un AMD Ryzen 9 8945HS (8 núcleos, 16 hilos):
| Variante | Segundos por paso | Resultado |
|---|---|---|
| Base: convoluciones 1×1 sobre toda la cuadrícula, 8 hilos | 1,55 | punto de partida |
| La misma, con 16 hilos | 2,78 | peor: los 16 hilos son 8 núcleos con dos hilos cada uno, y se estorban |
torch.compile | 1,90 | peor, y tarda 11 s en compilar |
Capas densas (F.linear) con los canales al final | 1,93 | peor: las permutaciones copian memoria |
Memoria channels_last (canales al final) | 0,92 | 40 % más rápido |
| Calcular la red solo en células vivas que disparan, desde la semilla | 0,21 | 4,5 veces más rápido |
| Lo mismo con un bloque de 40×40 completamente vivo (el peor caso) | 0,49 | 1,9 veces más rápido |
La idea que funcionó fue no calcular lo que se descarta. En la versión base, la red se calcula en las 5 184 células, y después el cambio de las muertas y de las que no dispararon se multiplica por cero. Ahora se arma la lista de las células que sí cuentan, la red se calcula solo en ellas, y los resultados se vuelven a poner en su lugar:
calcula = (antes & disparo)[:, 0] # qué células cuentan
p = self.percibir(x).permute(0, 2, 3, 1)[calcula] # (células, 48)
cambio = self.capa2(torch.relu(self.capa1(p))) # (células, 16)
dx = x.new_zeros(...)
dx[calcula] = cambio # de vuelta a su lugar
Es exactamente el mismo cálculo, también para los gradientes: los de las células descartadas eran cero.
Se comprobó con la misma semilla aleatoria: las dos versiones dan pérdidas idénticas y estados que difieren en 10⁻⁷.
Es también lo que hace nca.js en el navegador.
Para pausar los entrenamientos y medir sin interferencia usé pkill -f "python entrenar.py". La opción
-f busca en la línea de comando completa, y la de la propia terminal que corría pkill también
contenía ese texto: se pausó a sí misma. Para apuntar solo a los procesos de Python, el patrón tiene que no
coincidir consigo mismo, por ejemplo "[b]in/python entrenar.py".
Con todo esto, los tres experimentos en paralelo, con 5 hilos cada uno, tardaron entre 1,14 y 1,19 segundos por paso de entrenamiento, y entre 76 y 79 minutos en total. Antes de la optimización, en paralelo, iban a 3,5 segundos por paso. El detalle está en el capítulo 4.
Figuras y publicación
Otras figuras que las del artículo
- Elegí
- Además de la lagartija, tres memes (This is fine, Pepe y un tripulante de Among Us) y tres emojis (🍄 👾 🐙).
- Alternativas
- Los diez emojis de la demo del artículo: 🦎 😀 💥 👁 🐠 🦋 🐞 🕸 🥨 🎄.
- Por qué
- Para que el laboratorio no sea una copia de la demo, y porque formas distintas ponen a prueba cosas distintas: el perro de This is fine tiene llamas finas y muchos colores, Pepe tiene una cara con rasgos que hay que ubicar con precisión, el tripulante es una forma compacta con bordes negros gruesos y el invasor es pixel art con huecos. La lagartija se quedó porque los cinco capítulos están construidos sobre ella.
Los memes, dibujados con código
- Elegí
dibujos.pylos dibuja con elipses, polígonos y rectángulos de Pillow a 160×160, les pone un contorno oscuro dilatando la silueta yentrenar.pylos reduce a 40×40, igual que a un emoji.- Alternativas
- Bajar imágenes de los memes de internet.
- Por qué
- Las imágenes originales tienen autor y derechos. Un dibujo propio, hecho como homenaje, se puede publicar con su crédito. Además, a 40×40 conviene un dibujo pensado para ese tamaño: rasgos grandes, pocos colores y contornos marcados. Una foto reducida se volvería una mancha.
Solo el experimento regenerar, 3 000 pasos
- Elegí
- Cada figura nueva tiene un solo modelo, entrenado con daño, por 3 000 pasos en vez de 4 000.
- Por qué
- Medido. Los tres experimentos para seis figuras serían 18 entrenamientos, más de 6 horas de CPU. Regenerar es el único que crece, se mantiene y se cura, que es lo que se quiere ver en el laboratorio. En la historia de la lagartija, regenerar ya curaba heridas perfectamente en el paso 3 000.
Seis entrenamientos a la vez, con 2 hilos cada uno
- Elegí
- Lanzar las seis figuras juntas con
--hilos 2. - Por qué
- Medido. Así se aprovechan los 16 hilos del procesador sin hacer seis tandas. Cada uno fue más lento, entre 2,3 y 2,5 segundos por paso en promedio, pero juntos hicieron unos 2,5 pasos por segundo: lo mismo que tres a la vez con 5 hilos, y con figuras más grandes que la lagartija. Terminaron en 113 a 126 minutos. Al principio iban más rápido, a 2 segundos por paso, porque las figuras todavía eran pequeñas.
Cargar cada modelo solo cuando se elige
- Elegí
cargarModeloencomun.jsagrega un<script src="modelos/pepe.js">al pulsar la figura. Los botones muestran el PNG del objetivo, que pesa unos 3 KB.- Por qué
- Cada modelo pesa entre 0,6 y 0,9 MB: 10 u 11 puntos de control de pesos y 4 fotos por punto. Cargar los nueve de
entrada serían más de 6 MB antes de ver nada. Así la portada baja uno solo, y es un
<script>y nofetchpara que siga funcionando con doble clic.
Publicada en Cloudflare Pages
- Elegí
- Subir la carpeta
web/tal cual connpx wrangler pages deploy, a un subdominio.pages.dev. - Alternativas
- GitHub Pages, Netlify o un servidor propio.
- Por qué
- Como las páginas no tienen compilación ni servidor, publicarlas es copiar archivos. Pages los sirve desde su red de
distribución, con HTTPS, gratis y sin configurar nada. Todos los enlaces son relativos, así que la misma carpeta funciona
publicada y abierta desde el disco. El enlace
index.html#pepeabre el laboratorio directo en una figura, para compartirlo.
La visualización
Una placa blanca con píxeles grandes
- Elegí
- El autómata siempre se dibuja sobre blanco, en un canvas de 72×72 escalado con
image-rendering: pixelated. - Por qué
- La lagartija se entrenó contra fondo blanco y las fotos del entrenamiento son sobre blanco: así todas se ven igual, también en modo oscuro, como una placa de laboratorio. Los píxeles grandes dejan ver que cada cuadrado es una célula.
Rayos X: ámbar para lo positivo, azul verdoso para lo negativo
- Elegí
- Cada canal en su propio cuadro, con el color saturando en ±1.
- Por qué
- Los canales ocultos toman valores negativos y positivos, y el signo importa. Dos colores opuestos sobre blanco dejan ver dónde cada "sustancia química" está alta, baja o ausente. Saturar en ±1 evita que un valor extremo aplaste el resto.
El pincel borra los 16 canales, no solo el color
- Elegí
NCA.borrarpone en cero la célula entera.- Por qué
- Es arrancar células, como cortar tejido. Si solo borrara el color, los canales ocultos seguirían ahí y la célula "recordaría" qué era: la curación sería trampa. Es también lo que hace el daño durante el entrenamiento.
La curva de pérdida en escala logarítmica, promediada cada 10 pasos
- Elegí
entrenar.pyguarda el promedio de cada 10 pasos y la página lo dibuja en escala logarítmica.- Por qué
- La pérdida baja de 0,05 a 0,001: en escala lineal todo lo interesante quedaría aplastado contra el piso. Cada paso es ruidoso (el azar del disparo, las heridas, el pool), y promediar 10 deja ver la tendencia.
Lo que quedó fuera, y cómo agregarlo
- Experimento 4, rotar la percepción. Basta con girar los filtros Sobel en
percibir(en Python y en JS) por un ángulo:c·sobel_x − s·sobel_yys·sobel_x + c·sobel_y. La lagartija crecería girada sin reentrenar. Es un buen ejercicio para entender la percepción. - Reanudar un entrenamiento cortado.
entrenar.pyno guarda el estado del optimizador ni el pool: si se corta, empieza de cero. Para entrenamientos de una hora no valió la pena. - GPU y WebGL. La máquina de entrenamiento no tiene GPU NVIDIA y el JavaScript simple anima con fluidez.
- Formas más grandes. Todo está pensado para 40×40. Para imágenes grandes harían falta más canales, más pasos del autómata y bastante más cómputo.