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.

1

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.py o entrenar.py.
2

Tecnologías

PyTorch, en CPU, con uv

Elegí
Entrenar con PyTorch, la versión para CPU, corrida con uv run y 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.png con 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.js se 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.py escriba web/modelos/<experimento>.js, un script que agrega sus datos a NCA_MODELOS. Los pesos van como float32 en base64.
Alternativas
Un .json leído con fetch; los pesos como listas de decimales en texto.
Por qué
Las páginas se abren con doble clic, sin servidor. Desde file:// el navegador bloquea fetch, 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, fetch funcionarí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.py o nca.js, vuelve a correrlo.

Páginas estáticas, sin framework

Elegí
HTML, un estilo.css compartido (un cuaderno de cuadros), nca.js y comun.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.
3

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 CANALES a 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) y bias=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 de x = 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.1 y la máscara antes & 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=1 con 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.
4

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) con MultiStepLR(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-8 antes 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 4000 por 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) y random.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.
5

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):

VarianteSegundos por pasoResultado
Base: convoluciones 1×1 sobre toda la cuadrícula, 8 hilos1,55punto de partida
La misma, con 16 hilos2,78peor: los 16 hilos son 8 núcleos con dos hilos cada uno, y se estorban
torch.compile1,90peor, y tarda 11 s en compilar
Capas densas (F.linear) con los canales al final1,93peor: las permutaciones copian memoria
Memoria channels_last (canales al final)0,9240 % más rápido
Calcular la red solo en células vivas que disparan, desde la semilla0,214,5 veces más rápido
Lo mismo con un bloque de 40×40 completamente vivo (el peor caso)0,491,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.

Una trampa que encontré en el camino

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.

6

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.py los dibuja con elipses, polígonos y rectángulos de Pillow a 160×160, les pone un contorno oscuro dilatando la silueta y entrenar.py los 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í
cargarModelo en comun.js agrega 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 no fetch para que siga funcionando con doble clic.

Publicada en Cloudflare Pages

Elegí
Subir la carpeta web/ tal cual con npx 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#pepe abre el laboratorio directo en una figura, para compartirlo.
7

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.borrar pone 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.py guarda 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.
8

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_y y s·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.py no 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.