Growing Unsupervised Neural Cellular Automata
¶
Installation¶
You will need Python 3.12 or later, and a working JAX installation. For example, you can install JAX with:
In [1]:
Copied!
%pip install -U "jax[cuda]"
%pip install -U "jax[cuda]"
/home/faldor_google_com/dev/cax/.venv/bin/python3: No module named pip
Note: you may need to restart the kernel to use updated packages.
Then, install CAX from PyPi:
In [2]:
Copied!
%pip install -U "cax[examples]"
%pip install -U "cax[examples]"
/home/faldor_google_com/dev/cax/.venv/bin/python3: No module named pip
Note: you may need to restart the kernel to use updated packages.
Import¶
In [3]:
Copied!
import time
import jax
import jax.numpy as jnp
import mediapy
import optax
import torchvision
from flax import nnx
from jax import Array
from cax.core import ComplexSystem
from cax.core.perceive import ConvPerceive, grad_kernel, identity_kernel
from cax.core.update import NCAUpdate
from cax.nn.pool import Pool
from cax.nn.vae import Encoder
from cax.utils import clip_and_uint8
import time
import jax
import jax.numpy as jnp
import mediapy
import optax
import torchvision
from flax import nnx
from jax import Array
from cax.core import ComplexSystem
from cax.core.perceive import ConvPerceive, grad_kernel, identity_kernel
from cax.core.update import NCAUpdate
from cax.nn.pool import Pool
from cax.nn.vae import Encoder
from cax.utils import clip_and_uint8
Configuration¶
In [4]:
Copied!
seed = 0
spatial_dims = (28, 28)
features = (1, 32, 32)
latent_size = 8
channel_size = 32
num_kernels = 3
hidden_size = 256
cell_dropout_rate = 0.5
num_steps = 64
pool_size = 1_024
batch_size = 8
learning_rate = 1e-3
key = jax.random.key(seed)
rngs = nnx.Rngs(seed)
seed = 0
spatial_dims = (28, 28)
features = (1, 32, 32)
latent_size = 8
channel_size = 32
num_kernels = 3
hidden_size = 256
cell_dropout_rate = 0.5
num_steps = 64
pool_size = 1_024
batch_size = 8
learning_rate = 1e-3
key = jax.random.key(seed)
rngs = nnx.Rngs(seed)
Dataset¶
In [5]:
Copied!
# Load MNIST dataset
ds_train = torchvision.datasets.MNIST(root="./data", train=True, download=True)
ds_test = torchvision.datasets.MNIST(root="./data", train=False, download=True)
# Convert to jax.Array
y_train = jnp.array([y.resize(spatial_dims) for y, _ in ds_train])[..., None] / 255
y_test = jnp.array([y.resize(spatial_dims) for y, _ in ds_test])[..., None] / 255
# Visualize
mediapy.show_images(y_train[:8].repeat(4, axis=-3).repeat(4, axis=-2))
# Load MNIST dataset
ds_train = torchvision.datasets.MNIST(root="./data", train=True, download=True)
ds_test = torchvision.datasets.MNIST(root="./data", train=False, download=True)
# Convert to jax.Array
y_train = jnp.array([y.resize(spatial_dims) for y, _ in ds_train])[..., None] / 255
y_test = jnp.array([y.resize(spatial_dims) for y, _ in ds_test])[..., None] / 255
# Visualize
mediapy.show_images(y_train[:8].repeat(4, axis=-3).repeat(4, axis=-2))
Instantiate system¶
In [6]:
Copied!
class GrowingUnsupervisedNCA(ComplexSystem):
"""Unsupervised Neural Cellular Automata class."""
def __init__(self, *, rngs: nnx.Rngs):
"""Initialize Unsupervised NCA.
Args:
rngs: rng key.
"""
self.perceive = ConvPerceive(
channel_size=channel_size,
perception_size=num_kernels * channel_size,
feature_group_count=channel_size,
rngs=rngs,
)
self.update = NCAUpdate(
channel_size=channel_size,
perception_size=latent_size + num_kernels * channel_size,
hidden_layer_sizes=(hidden_size,),
cell_dropout_rate=cell_dropout_rate,
zeros_init=True,
rngs=rngs,
)
self.encoder = Encoder(
spatial_dims=spatial_dims,
features=features,
latent_size=latent_size,
rngs=rngs,
)
# Initialize kernel with sobel filters
kernel = jnp.concatenate(
[identity_kernel(num_dims=2), grad_kernel(num_dims=2)], axis=-1
)
kernel = jnp.expand_dims(
jnp.concatenate([kernel] * channel_size, axis=-1), axis=-2
)
self.perceive.conv.kernel[...] = kernel
def encode(self, x):
"""Encode image into latent space."""
mean, logvar = self.encoder(x)
return self.encoder.reparameterize(mean, logvar)
def _step(self, state: Array, input: Array | None = None) -> Array:
# Broadcast the input vector to match the state shape
input_shape = (*state.shape[:-1], input.shape[-1])
input = jnp.broadcast_to(input[..., None, None, :], input_shape)
# Step
perception = self.perceive(state)
next_state = self.update(state, perception, input)
return next_state
@nnx.jit
def render(self, state):
"""Render state to RGB."""
gray = state[..., -1:]
rgb = jnp.repeat(gray, 3, axis=-1)
# Clip values to valid range and convert to uint8
return clip_and_uint8(rgb)
class GrowingUnsupervisedNCA(ComplexSystem):
"""Unsupervised Neural Cellular Automata class."""
def __init__(self, *, rngs: nnx.Rngs):
"""Initialize Unsupervised NCA.
Args:
rngs: rng key.
"""
self.perceive = ConvPerceive(
channel_size=channel_size,
perception_size=num_kernels * channel_size,
feature_group_count=channel_size,
rngs=rngs,
)
self.update = NCAUpdate(
channel_size=channel_size,
perception_size=latent_size + num_kernels * channel_size,
hidden_layer_sizes=(hidden_size,),
cell_dropout_rate=cell_dropout_rate,
zeros_init=True,
rngs=rngs,
)
self.encoder = Encoder(
spatial_dims=spatial_dims,
features=features,
latent_size=latent_size,
rngs=rngs,
)
# Initialize kernel with sobel filters
kernel = jnp.concatenate(
[identity_kernel(num_dims=2), grad_kernel(num_dims=2)], axis=-1
)
kernel = jnp.expand_dims(
jnp.concatenate([kernel] * channel_size, axis=-1), axis=-2
)
self.perceive.conv.kernel[...] = kernel
def encode(self, x):
"""Encode image into latent space."""
mean, logvar = self.encoder(x)
return self.encoder.reparameterize(mean, logvar)
def _step(self, state: Array, input: Array | None = None) -> Array:
# Broadcast the input vector to match the state shape
input_shape = (*state.shape[:-1], input.shape[-1])
input = jnp.broadcast_to(input[..., None, None, :], input_shape)
# Step
perception = self.perceive(state)
next_state = self.update(state, perception, input)
return next_state
@nnx.jit
def render(self, state):
"""Render state to RGB."""
gray = state[..., -1:]
rgb = jnp.repeat(gray, 3, axis=-1)
# Clip values to valid range and convert to uint8
return clip_and_uint8(rgb)
In [7]:
Copied!
cs = GrowingUnsupervisedNCA(rngs=rngs)
cs = GrowingUnsupervisedNCA(rngs=rngs)
In [8]:
Copied!
params = nnx.state(cs, nnx.Param)
print("Number of params:", sum(x.size for x in jax.tree.leaves(params)))
params = nnx.state(cs, nnx.Param)
print("Number of params:", sum(x.size for x in jax.tree.leaves(params)))
Number of params: 2530832
Sample initial state¶
In [9]:
Copied!
def sample_state(key):
"""Sample a state with a single alive cell."""
# Init state
state = jnp.zeros(spatial_dims + (channel_size,))
mid = tuple(size // 2 for size in spatial_dims)
# Set the center cell to alive, with hidden channels at one
state = state.at[mid[0], mid[1], -1].set(1.0)
state = state.at[mid[0], mid[1], :-1].set(1.0)
# Sample a random target y
y_idx = jax.random.choice(key, y_train.shape[0])
return state, y_idx
def sample_state(key):
"""Sample a state with a single alive cell."""
# Init state
state = jnp.zeros(spatial_dims + (channel_size,))
mid = tuple(size // 2 for size in spatial_dims)
# Set the center cell to alive, with hidden channels at one
state = state.at[mid[0], mid[1], -1].set(1.0)
state = state.at[mid[0], mid[1], :-1].set(1.0)
# Sample a random target y
y_idx = jax.random.choice(key, y_train.shape[0])
return state, y_idx
Train¶
Pool¶
In [10]:
Copied!
key, subkey = jax.random.split(key)
keys = jax.random.split(subkey, pool_size)
state, y_idx = jax.vmap(lambda key: sample_state(key))(keys)
pool = Pool.create({"state": state, "y_idx": y_idx})
key, subkey = jax.random.split(key)
keys = jax.random.split(subkey, pool_size)
state, y_idx = jax.vmap(lambda key: sample_state(key))(keys)
pool = Pool.create({"state": state, "y_idx": y_idx})
Optimizer¶
In [11]:
Copied!
lr_sched = optax.linear_schedule(
init_value=learning_rate, end_value=0.1 * learning_rate, transition_steps=50_000
)
optimizer = optax.chain(
optax.clip_by_global_norm(1.0),
optax.adam(learning_rate=lr_sched),
)
grad_params = nnx.All(
nnx.Param, nnx.Any(nnx.PathContains("update"), nnx.PathContains("encoder"))
)
optimizer = nnx.Optimizer(cs, optimizer, wrt=grad_params)
lr_sched = optax.linear_schedule(
init_value=learning_rate, end_value=0.1 * learning_rate, transition_steps=50_000
)
optimizer = optax.chain(
optax.clip_by_global_norm(1.0),
optax.adam(learning_rate=lr_sched),
)
grad_params = nnx.All(
nnx.Param, nnx.Any(nnx.PathContains("update"), nnx.PathContains("encoder"))
)
optimizer = nnx.Optimizer(cs, optimizer, wrt=grad_params)
Loss¶
In [12]:
Copied!
def mse(state, y):
"""Mean Squared Error."""
return jnp.mean(jnp.square(state[..., -1:] - y))
def mse(state, y):
"""Mean Squared Error."""
return jnp.mean(jnp.square(state[..., -1:] - y))
In [13]:
Copied!
@nnx.jit
def loss_fn(cs, state, y, key):
"""Loss function."""
z = cs.encode(y)
state_axes = nnx.StateAxes({nnx.RngState: 0, ...: None})
_, state = nnx.split_rngs(splits=batch_size)(
nnx.vmap(
lambda cs, state, z: cs(state, z, num_steps=num_steps, return_states=True),
in_axes=(state_axes, 0, 0),
)
)(cs, state, z)
idx = jax.random.randint(key, (batch_size,), num_steps // 2, num_steps)
state = state[jnp.arange(batch_size), idx]
loss = mse(state, y)
return loss, state
@nnx.jit
def loss_fn(cs, state, y, key):
"""Loss function."""
z = cs.encode(y)
state_axes = nnx.StateAxes({nnx.RngState: 0, ...: None})
_, state = nnx.split_rngs(splits=batch_size)(
nnx.vmap(
lambda cs, state, z: cs(state, z, num_steps=num_steps, return_states=True),
in_axes=(state_axes, 0, 0),
)
)(cs, state, z)
idx = jax.random.randint(key, (batch_size,), num_steps // 2, num_steps)
state = state[jnp.arange(batch_size), idx]
loss = mse(state, y)
return loss, state
Train step¶
In [14]:
Copied!
@nnx.jit
def train_step(cs, optimizer, pool, key):
"""Train step."""
sample_key, sample_state_key, loss_key = jax.random.split(key, 3)
# Sample from pool
pool_idx, batch = pool.sample(sample_key, batch_size=batch_size, replace=False)
current_state = batch["state"]
current_y_idx = batch["y_idx"]
current_y = y_train[current_y_idx]
# Sort by descending loss
sort_idx = jnp.argsort(jax.vmap(mse)(current_state, current_y), descending=True)
pool_idx = pool_idx[sort_idx]
current_state = current_state[sort_idx]
current_y_idx = current_y_idx[sort_idx]
# Sample a new state to replace the worst
new_state, new_y_idx = sample_state(sample_state_key)
current_state = current_state.at[0].set(new_state)
current_y_idx = current_y_idx.at[0].set(new_y_idx)
current_y = y_train[current_y_idx]
(loss, current_state), grad = nnx.value_and_grad(
loss_fn, has_aux=True, argnums=nnx.DiffState(0, grad_params)
)(cs, current_state, current_y, loss_key)
optimizer.update(cs, grad)
pool = pool.update(pool_idx, {"state": current_state, "y_idx": current_y_idx})
return loss, pool
@nnx.jit
def train_step(cs, optimizer, pool, key):
"""Train step."""
sample_key, sample_state_key, loss_key = jax.random.split(key, 3)
# Sample from pool
pool_idx, batch = pool.sample(sample_key, batch_size=batch_size, replace=False)
current_state = batch["state"]
current_y_idx = batch["y_idx"]
current_y = y_train[current_y_idx]
# Sort by descending loss
sort_idx = jnp.argsort(jax.vmap(mse)(current_state, current_y), descending=True)
pool_idx = pool_idx[sort_idx]
current_state = current_state[sort_idx]
current_y_idx = current_y_idx[sort_idx]
# Sample a new state to replace the worst
new_state, new_y_idx = sample_state(sample_state_key)
current_state = current_state.at[0].set(new_state)
current_y_idx = current_y_idx.at[0].set(new_y_idx)
current_y = y_train[current_y_idx]
(loss, current_state), grad = nnx.value_and_grad(
loss_fn, has_aux=True, argnums=nnx.DiffState(0, grad_params)
)(cs, current_state, current_y, loss_key)
optimizer.update(cs, grad)
pool = pool.update(pool_idx, {"state": current_state, "y_idx": current_y_idx})
return loss, pool
Main loop¶
In [15]:
Copied!
num_train_steps = 8_192
print_interval = 128
losses = []
start = time.perf_counter()
for i in range(num_train_steps):
key, subkey = jax.random.split(key)
loss, pool = train_step(cs, optimizer, pool, subkey)
losses.append(loss)
if i % print_interval == 0 or i == num_train_steps - 1:
avg_loss = sum(losses[-print_interval:]) / len(losses[-print_interval:])
elapsed = time.perf_counter() - start
print(f"Step {i:>4}/{num_train_steps} | {elapsed:6.1f}s | Loss {avg_loss:.3e}")
print(f"✨ Trained for {num_train_steps} steps in {time.perf_counter() - start:.0f}s")
num_train_steps = 8_192
print_interval = 128
losses = []
start = time.perf_counter()
for i in range(num_train_steps):
key, subkey = jax.random.split(key)
loss, pool = train_step(cs, optimizer, pool, subkey)
losses.append(loss)
if i % print_interval == 0 or i == num_train_steps - 1:
avg_loss = sum(losses[-print_interval:]) / len(losses[-print_interval:])
elapsed = time.perf_counter() - start
print(f"Step {i:>4}/{num_train_steps} | {elapsed:6.1f}s | Loss {avg_loss:.3e}")
print(f"✨ Trained for {num_train_steps} steps in {time.perf_counter() - start:.0f}s")
Step 0/8192 | 7.1s | Loss 1.053e-01
Step 128/8192 | 8.9s | Loss 1.340e-01
Step 256/8192 | 10.7s | Loss 8.107e-02
Step 384/8192 | 12.6s | Loss 7.069e-02
Step 512/8192 | 14.4s | Loss 5.714e-02
Step 640/8192 | 16.2s | Loss 5.008e-02
Step 768/8192 | 18.0s | Loss 4.437e-02
Step 896/8192 | 19.8s | Loss 3.966e-02
Step 1024/8192 | 21.6s | Loss 3.680e-02
Step 1152/8192 | 23.4s | Loss 3.403e-02
Step 1280/8192 | 25.2s | Loss 3.305e-02
Step 1408/8192 | 27.1s | Loss 2.968e-02
Step 1536/8192 | 28.9s | Loss 2.963e-02
Step 1664/8192 | 30.7s | Loss 2.807e-02
Step 1792/8192 | 32.5s | Loss 2.635e-02
Step 1920/8192 | 34.3s | Loss 2.607e-02
Step 2048/8192 | 36.1s | Loss 2.536e-02
Step 2176/8192 | 37.9s | Loss 2.425e-02
Step 2304/8192 | 39.8s | Loss 2.394e-02
Step 2432/8192 | 41.6s | Loss 2.306e-02
Step 2560/8192 | 43.4s | Loss 2.220e-02
Step 2688/8192 | 45.2s | Loss 2.133e-02
Step 2816/8192 | 47.0s | Loss 2.026e-02
Step 2944/8192 | 48.8s | Loss 2.029e-02
Step 3072/8192 | 50.6s | Loss 1.966e-02
Step 3200/8192 | 52.4s | Loss 1.919e-02
Step 3328/8192 | 54.3s | Loss 1.909e-02
Step 3456/8192 | 56.1s | Loss 1.888e-02
Step 3584/8192 | 57.9s | Loss 1.825e-02
Step 3712/8192 | 59.7s | Loss 1.831e-02
Step 3840/8192 | 61.5s | Loss 1.763e-02
Step 3968/8192 | 63.3s | Loss 1.729e-02
Step 4096/8192 | 65.1s | Loss 1.674e-02
Step 4224/8192 | 67.0s | Loss 1.595e-02
Step 4352/8192 | 68.8s | Loss 1.594e-02
Step 4480/8192 | 70.6s | Loss 1.644e-02
Step 4608/8192 | 72.4s | Loss 1.554e-02
Step 4736/8192 | 74.2s | Loss 1.569e-02
Step 4864/8192 | 76.0s | Loss 1.573e-02
Step 4992/8192 | 77.8s | Loss 1.522e-02
Step 5120/8192 | 79.7s | Loss 1.463e-02
Step 5248/8192 | 81.5s | Loss 1.561e-02
Step 5376/8192 | 83.3s | Loss 1.469e-02
Step 5504/8192 | 85.1s | Loss 1.410e-02
Step 5632/8192 | 86.9s | Loss 1.487e-02
Step 5760/8192 | 88.7s | Loss 1.487e-02
Step 5888/8192 | 90.5s | Loss 1.386e-02
Step 6016/8192 | 92.4s | Loss 1.385e-02
Step 6144/8192 | 94.2s | Loss 1.321e-02
Step 6272/8192 | 96.0s | Loss 1.399e-02
Step 6400/8192 | 97.8s | Loss 1.374e-02
Step 6528/8192 | 99.6s | Loss 1.317e-02
Step 6656/8192 | 101.4s | Loss 1.314e-02
Step 6784/8192 | 103.2s | Loss 1.311e-02
Step 6912/8192 | 105.1s | Loss 1.307e-02
Step 7040/8192 | 106.9s | Loss 1.318e-02
Step 7168/8192 | 108.7s | Loss 1.231e-02
Step 7296/8192 | 110.5s | Loss 1.289e-02
Step 7424/8192 | 112.3s | Loss 1.230e-02
Step 7552/8192 | 114.1s | Loss 1.253e-02
Step 7680/8192 | 115.9s | Loss 1.297e-02
Step 7808/8192 | 117.8s | Loss 1.218e-02
Step 7936/8192 | 119.6s | Loss 1.226e-02
Step 8064/8192 | 121.4s | Loss 1.289e-02
Step 8191/8192 | 123.2s | Loss 1.245e-02 ✨ Trained for 8192 steps in 123s
Run¶
In [16]:
Copied!
num_examples = 8
key, subkey = jax.random.split(key)
keys = jax.random.split(subkey, num_examples)
state_init, y_idx = jax.vmap(sample_state)(keys)
y = y_train[y_idx]
z = cs.encode(y)
state_axes = nnx.StateAxes({nnx.RngState: 0, ...: None})
state_final, states = nnx.split_rngs(splits=num_examples)(
nnx.vmap(
lambda cs, state, z: cs(state, z, num_steps=2 * num_steps, return_states=True),
in_axes=(state_axes, 0, 0),
)
)(cs, state_init, z)
num_examples = 8
key, subkey = jax.random.split(key)
keys = jax.random.split(subkey, num_examples)
state_init, y_idx = jax.vmap(sample_state)(keys)
y = y_train[y_idx]
z = cs.encode(y)
state_axes = nnx.StateAxes({nnx.RngState: 0, ...: None})
state_final, states = nnx.split_rngs(splits=num_examples)(
nnx.vmap(
lambda cs, state, z: cs(state, z, num_steps=2 * num_steps, return_states=True),
in_axes=(state_axes, 0, 0),
)
)(cs, state_init, z)
Visualize¶
In [17]:
Copied!
states = jnp.concatenate([state_init[:, None], states], axis=1)
frames = nnx.vmap(
lambda cs, state: cs.render(state),
in_axes=(None, 0),
)(cs, states)
mediapy.show_images(y.repeat(4, axis=-3).repeat(4, axis=-2))
mediapy.show_videos(frames.repeat(4, axis=-3).repeat(4, axis=-2))
states = jnp.concatenate([state_init[:, None], states], axis=1)
frames = nnx.vmap(
lambda cs, state: cs.render(state),
in_axes=(None, 0),
)(cs, states)
mediapy.show_images(y.repeat(4, axis=-3).repeat(4, axis=-2))
mediapy.show_videos(frames.repeat(4, axis=-3).repeat(4, axis=-2))
Interpolation¶
In [18]:
Copied!
# Sample two random images
key, subkey = jax.random.split(key)
y_idx = jax.random.choice(subkey, y_train.shape[0], shape=(2,))
y = y_train[y_idx]
# Compute latent encodings
z = cs.encode(y)
# Interpolate between the two latent encodings
num_interpolations = 8
alphas = jnp.linspace(0.0, 1.0, num_interpolations)
z = jnp.array([alpha * z[0] + (1 - alpha) * z[1] for alpha in alphas])
# Sample initial state
key, subkey = jax.random.split(key)
keys = jax.random.split(subkey, num_interpolations)
state_init, _ = jax.vmap(sample_state)(keys)
# Run
state_axes = nnx.StateAxes({nnx.RngState: 0, ...: None})
state_final = nnx.split_rngs(splits=num_interpolations)(
nnx.vmap(
lambda cs, state, z: cs(state, z, num_steps=num_steps),
in_axes=(state_axes, 0, 0),
)
)(cs, state_init, z)
# Sample two random images
key, subkey = jax.random.split(key)
y_idx = jax.random.choice(subkey, y_train.shape[0], shape=(2,))
y = y_train[y_idx]
# Compute latent encodings
z = cs.encode(y)
# Interpolate between the two latent encodings
num_interpolations = 8
alphas = jnp.linspace(0.0, 1.0, num_interpolations)
z = jnp.array([alpha * z[0] + (1 - alpha) * z[1] for alpha in alphas])
# Sample initial state
key, subkey = jax.random.split(key)
keys = jax.random.split(subkey, num_interpolations)
state_init, _ = jax.vmap(sample_state)(keys)
# Run
state_axes = nnx.StateAxes({nnx.RngState: 0, ...: None})
state_final = nnx.split_rngs(splits=num_interpolations)(
nnx.vmap(
lambda cs, state, z: cs(state, z, num_steps=num_steps),
in_axes=(state_axes, 0, 0),
)
)(cs, state_init, z)
In [19]:
Copied!
frames = nnx.vmap(
lambda cs, state: cs.render(state),
in_axes=(None, 0),
)(cs, state_final)
mediapy.show_images(frames.repeat(4, axis=-3).repeat(4, axis=-2))
frames = nnx.vmap(
lambda cs, state: cs.render(state),
in_axes=(None, 0),
)(cs, state_final)
mediapy.show_images(frames.repeat(4, axis=-3).repeat(4, axis=-2))