Skip to content

Commit f61195a

Browse files
committed
feat(path): init
1 parent 6aedb5b commit f61195a

5 files changed

Lines changed: 470 additions & 0 deletions

File tree

vbjax/app/path/README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# **JAX-based DeltaNet Model for Pathfinder Task**
2+
3+
## **1\. Project Overview**
4+
5+
This project provides a JAX-based implementation of a recurrent neural network inspired by the DeltaNet architecture. The model is specifically designed to solve the Long Range Arena (LRA) Pathfinder task. This task, which requires determining if two points on a 64x64 grid are connected by a continuous path, serves as an ideal testbed. It directly challenges a model's ability to capture long-range spatial dependencies and route information selectively, capabilities that are central to the implemented architecture.
6+
The model's design is guided by a theoretical framework that views cognition not as a single, passive feed-forward pass, but as an active, iterative process of belief updating. This perspective is more aligned with biological cognition. The core of the model is a recurrent update rule that mathematically embodies this process of Bayesian belief propagation through cycles of prediction and error correction.
7+
8+
## **2\. Theoretical Foundations**
9+
10+
The model synthesizes several key concepts from computational neuroscience and machine learning to create a unified system.
11+
12+
* **Active Inference & Belief Updating**: Unlike standard Vision Transformers which process an entire image in one go, this model processes information sequentially, token-by-token. Each new token (an image patch) is used to update an internal belief state. This iterative refinement is computationally analogous to how an organism might scan a scene, gathering evidence over time. This approach has implications for biological plausibility and offers potential efficiency gains in complex tasks where only a subset of information is relevant at any given moment.
13+
* **The DeltaNet Update Rule**: The heart of the recurrence is the update to a state matrix S at each step t. This matrix represents the accumulated knowledge or beliefs of the model. The update rule has two equivalent mathematical forms:
14+
1. Multiplicative Form: S\_t \= S\_{t-1} @ (I \- B \* k\_t @ k\_t.T) \+ v\_t @ k\_t.T
15+
This form highlights how the prior belief S\_{t-1} is first adjusted by "explaining away" the predictable parts of the input, and then new information is added.
16+
2. Prediction Error Form: S\_t \= S\_{t-1} \+ (v\_t \- B \* S\_{t-1} @ k\_t) @ k\_t.T
17+
This is the form directly implemented in the code as it makes the learning signal explicit. It elegantly decomposes the update into intuitive computational steps:
18+
* v\_pred \= B \* S\_{t-1} @ k\_t: The model uses its prior belief (S\_{t-1}) to predict the current value vector (v\_t) based on the current context or key vector (k\_t).
19+
* error \= v\_t \- v\_pred: The model calculates the prediction error—the "surprise"—which quantifies how much the actual input deviates from the prediction. This error signal is the primary driver of learning and belief change.
20+
* The outer product of the error and the k\_t vector creates the final update matrix dS, ensuring that the change to the belief state S is precisely targeted based on the context that produced the error.
21+
* B is a learnable, per-head scalar parameter that modulates the strength of the prediction, allowing each head to learn how confidently to trust its own prior beliefs.
22+
* **Criticality & Precision Amplification**: The framework hypothesizes that effective cognitive systems operate near a critical state, where information can propagate without dying out or exploding. This is characterized by "avalanches" of neural activity that follow scale-free power laws. To model this, the system incorporates a precision amplification mechanism tied to prediction error:
23+
* The magnitude of the prediction error ||error|| is calculated. A large error signifies a highly surprising event.
24+
* This magnitude is used to compute a temperature T \= exp(-alpha \* ||error||). A larger error leads to a smaller temperature.
25+
* This temperature dynamically sharpens the softmax output for the efferent (outgoing) signal: softmax((S\_t @ q\_t) / T).
26+
* The consequence is that more "surprising" events (large errors) trigger higher-precision (lower temperature) outputs. This amplifies the signal, ensuring that significant, belief-violating information is propagated forcefully through the network, leading to rapid adaptation.
27+
* **Normalization**: Input tokens are z-scored before being projected into Q, K, and V vectors. This serves as a simple homeostatic mechanism to stabilize the network's internal dynamics. By ensuring each token has a mean of zero and unit variance, it prevents the recurrent updates from causing the signals to either explode or vanish over long sequences, keeping the network in a responsive and trainable regime.
28+
29+
## **3\. Model Architecture & Implementation**
30+
31+
### **3.1 Data Pipeline**
32+
33+
* **Input**:
34+
* Images (ims.npy): A NumPy array of shape (N, 64, 64).
35+
* Labels (labels.npy): A NumPy array of binary labels (N,).
36+
* **Preprocessing**:
37+
* Each 64x64 image is divided into a sequence of non-overlapping 4x4 patches. This tokenization transforms the spatial problem into a sequential one.
38+
* Each patch is flattened into a 16-dimensional vector (token), resulting in a sequence length of (64/4)^2 \= 256 tokens.
39+
* To retain crucial spatial information lost during flattening, two types of embeddings are added to each token: positional embeddings for the patch's original (x, y) grid location and sequential embeddings for the token's index in the 1D sequence.
40+
41+
### **3.2 Core Logic (delta\_net\_layer\_forward)**
42+
43+
The model consists of several stacked DeltaNet layers. Within each layer, a jax.lax.scan function iterates through the sequence, which is the functional equivalent of a for loop in JAX and the engine for the temporal recurrence. For each token:
44+
45+
1. The input token is **z-scored** for normalization.
46+
2. The normalized token is projected into per-head Q, K, and V vectors.
47+
3. Q and K vectors are passed through a sigmoid nonlinearity to constrain their values.
48+
4. The per-head state matrix S is updated using the computationally explicit prediction error formulation of the DeltaNet rule.
49+
5. A precision-amplified softmax is applied to generate an efferent signal, with the temperature determined by the prediction error.
50+
6. The signals from all heads are concatenated, projected back to the embedding dimension, and added to the original input via a **skip connection**. This is critical for training deep networks as it provides a direct path for gradients to flow, mitigating the vanishing gradient problem.
51+
7. A final layer normalization is applied to stabilize the output of the entire layer before it is passed to the next.
52+
53+
### **3.3 JAX Implementation Details**
54+
55+
* **Framework**: The model is built entirely in JAX, using optax for the Adam optimizer and its rich ecosystem of optimizers and schedulers.
56+
* **Dataclasses**:
57+
* TrainingConfig: Stores all global hyperparameters like learning rate, batch size, and model dimensions (embed\_dim, num\_heads, num\_layers).
58+
* ModelParams & DeltaLayerParams: Flax-style dataclasses hold all trainable parameters. This is essential as it organizes the parameters into a nested structure that JAX recognizes as a pytree, allowing functions like grad and jit to work seamlessly on the entire model. The trainable B parameter, for instance, lives inside DeltaLayerParams with a shape of (num\_heads,).
59+
* **Vectorization**: vmap is used extensively to parallelize computations. It allows the same function to be efficiently run over the batch dimension and across the different attention heads within each layer, leading to clean and highly performant code.
60+
* **Compilation**: jit is used on the train\_step function. This compiles the entire device-side computation graph for the loss calculation, backpropagation, and optimizer update, resulting in significant speedups on accelerators like GPUs.
61+
62+
### **3.4 Code Structure**
63+
64+
* main.py: A self-contained script with all the code.
65+
* **Dataclasses**: ModelParams, DeltaLayerParams, TrainingConfig.
66+
* init\_model(): Initializes all model parameters with random values according to the specified configuration.
67+
* delta\_net\_layer\_forward(): Contains the core recurrent logic for a single DeltaNet layer.
68+
* forward\_pass(): Defines the full pass through all layers, from input embedding to final logit.
69+
* loss\_fn(): Calculates the sigmoid binary cross-entropy loss from the logits and labels.
70+
* train\_step(): A JIT-compiled function that executes one full training step.
71+
* train(): The main training loop that handles data loading, batching, and orchestrates the training process.
72+
73+
## **4\. Next Steps & Future Directions**
74+
75+
* **Analysis**: Implement logging within the train\_step to record the average magnitude of the prediction error ||error|| for each batch. This data can be used to test the criticality hypothesis by plotting the distribution of error magnitudes over time and checking for power-law characteristics (e.g., via a log-log plot).
76+
* **Optimization**: Experiment with more advanced optax learning rate schedules, such as warmup\_cosine\_decay\_schedule, which can lead to more stable training and better final performance.
77+
* **Architecture**: Explore alternative architectural choices. For example, instead of averaging all final token outputs for the readout, one could use only the output of the very last token in the sequence. Different nonlinearities for the Q and K vectors (e.g., tanh) could also be investigated.

vbjax/app/path/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
3+
"""

vbjax/app/path/model.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
from functools import partial
2+
import numpy as np
3+
4+
import jax
5+
import jax.numpy as jnp
6+
from jax import random, grad, jit, vmap
7+
import jax_dataclasses as jdc
8+
9+
# Set random seed for reproducibility
10+
key = random.PRNGKey(0)
11+
12+
# setup model structure using jax dataclass
13+
@jdc.pytree_dataclass
14+
class Model:
15+
"""
16+
DeltaNet-like model for solving pathfinder task
17+
"""
18+
19+
20+
21+
# --- 1. Define Data Structures ---
22+
23+
@dataclass
24+
class ModelParams:
25+
# Input Embeddings
26+
pos_embeds: jnp.ndarray # (16, 16, embed_dim)
27+
seq_embeds: jnp.ndarray # (seq_len, embed_dim)
28+
29+
# DeltaNet Layers
30+
delta_layers: list # List of layer-specific parameters
31+
32+
# Output Head
33+
output_w: jnp.ndarray # (embed_dim, 1)
34+
output_b: jnp.ndarray
35+
36+
@dataclass
37+
class DeltaLayerParams:
38+
wq: jnp.ndarray # (num_heads, embed_dim, head_dim)
39+
wk: jnp.ndarray # (num_heads, embed_dim, head_dim)
40+
wv: jnp.ndarray # (num_heads, embed_dim, head_dim)
41+
wo: jnp.ndarray # (num_heads * head_dim, embed_dim)
42+
norm: jnp.ndarray
43+
B: jnp.ndarray # (num_heads,) - NEW: Learnable per-head parameter
44+
45+
@dataclass
46+
class TrainingConfig:
47+
learning_rate: float
48+
batch_size: int
49+
num_iterations: int
50+
# Model dimensions
51+
embed_dim: int
52+
num_heads: int
53+
num_layers: int
54+
patch_size: int
55+
image_size: int
56+
# Precision amplification factor
57+
alpha: float
58+
59+
# --- 2. Model Initialization ---
60+
61+
def init_model(key, config):
62+
head_dim = config.embed_dim // config.num_heads
63+
seq_len = (config.image_size // config.patch_size)**2
64+
65+
keys = random.split(key, 2 + config.num_layers * 6) # Adjusted for new B param
66+
67+
delta_layers = []
68+
for i in range(config.num_layers):
69+
layer_params = DeltaLayerParams(
70+
wq=random.normal(keys[i*6+0], (config.num_heads, config.embed_dim, head_dim)),
71+
wk=random.normal(keys[i*6+1], (config.num_heads, config.embed_dim, head_dim)),
72+
wv=random.normal(keys[i*6+2], (config.num_heads, config.embed_dim, head_dim)),
73+
wo=random.normal(keys[i*6+3], (config.num_heads * head_dim, config.embed_dim)),
74+
norm=jnp.ones(config.embed_dim),
75+
B=jnp.ones(config.num_heads) # NEW: Initialize B for each head
76+
)
77+
delta_layers.append(layer_params)
78+
79+
return ModelParams(
80+
pos_embeds=random.normal(keys[-4], (config.image_size // config.patch_size, config.image_size // config.patch_size, config.embed_dim)),
81+
seq_embeds=random.normal(keys[-3], (seq_len, config.embed_dim)),
82+
delta_layers=delta_layers,
83+
output_w=random.normal(keys[-2], (config.embed_dim, 1)),
84+
output_b=random.normal(keys[-1], (1,))
85+
)
86+
87+
# --- 3. Core Model Logic ---
88+
89+
def z_score(x, axis=-1, eps=1e-5):
90+
mean = jnp.mean(x, axis=axis, keepdims=True)
91+
std = jnp.std(x, axis=axis, keepdims=True)
92+
return (x - mean) / (std + eps)
93+
94+
def delta_net_layer_forward(params: DeltaLayerParams, x: jnp.ndarray, S: jnp.ndarray, alpha: float):
95+
96+
def scan_fn(carry, token_x):
97+
S_prev = carry
98+
99+
# Z-score input token
100+
token_x_norm = z_score(token_x)
101+
102+
# Project to Q, K, V for each head
103+
q_h = jnp.einsum('d,hdm->hm', token_x_norm, params.wq)
104+
k_h = jnp.einsum('d,hdm->hm', token_x_norm, params.wk)
105+
v_h = jnp.einsum('d,hdm->hm', token_x_norm, params.wv)
106+
107+
# Sigmoid nonlinearity
108+
q_h, k_h = jax.nn.sigmoid(q_h), jax.nn.sigmoid(k_h)
109+
110+
# --- Per-head recurrent update (vmapped) ---
111+
def head_update(S_h, B_h, q_token, k_token, v_token):
112+
# NEW: Prediction includes learnable parameter B
113+
v_pred = B_h * (S_h @ k_token)
114+
error = v_token - v_pred
115+
116+
# State update
117+
dS = jnp.outer(error, k_token)
118+
S_curr = S_h + dS
119+
120+
# --- Mathematical Equivalence Comment ---
121+
# The update S_curr = S_prev + outer(v - B * (S_prev @ k), k)
122+
# expands to: S_curr = S_prev + v @ k.T - B * S_prev @ k @ k.T
123+
# which rearranges to: S_curr = S_prev @ (I - B * k @ k.T) + v @ k.T
124+
# This is the DeltaNet update rule.
125+
# -----------------------------------------
126+
127+
# Precision amplification
128+
temp = jnp.exp(-alpha * jnp.linalg.norm(error)) + 1e-6
129+
130+
# Efferent signal
131+
efferent = jax.nn.softmax((S_curr @ q_token) / temp)
132+
return S_curr, efferent
133+
134+
S_next, efferent_h = vmap(head_update)(S_prev, params.B, q_h, k_h, v_h)
135+
136+
# Concatenate heads and project out
137+
efferent_flat = efferent_h.flatten()
138+
output_token = efferent_flat @ params.wo
139+
140+
# Skip connection
141+
final_token = token_x + output_token
142+
143+
return S_next, final_token
144+
145+
final_S, output_sequence = jax.lax.scan(scan_fn, S, x)
146+
return output_sequence, final_S
147+
148+
def forward_pass(params: ModelParams, batch_patches: jnp.ndarray, config: TrainingConfig):
149+
# Add positional and sequential embeddings
150+
batch_size, seq_len, _ = batch_patches.shape
151+
pos_embeds_flat = params.pos_embeds.reshape(seq_len, config.embed_dim)
152+
x = batch_patches + pos_embeds_flat[jnp.newaxis, :, :] + params.seq_embeds[jnp.newaxis, :, :]
153+
154+
# Initialize recurrent states (one per layer)
155+
head_dim = config.embed_dim // config.num_heads
156+
initial_S = jnp.zeros((config.num_layers, config.num_heads, head_dim, head_dim))
157+
158+
# Propagate through layers
159+
current_x = x
160+
for i in range(config.num_layers):
161+
layer_params = params.delta_layers[i]
162+
S_layer = initial_S[i]
163+
164+
# Vmap across the batch dimension
165+
batch_layer_fwd = vmap(delta_net_layer_forward, in_axes=(None, 0, None, None))
166+
current_x, _ = batch_layer_fwd(layer_params, current_x, S_layer, config.alpha)
167+
current_x = jax.nn.layer_norm(current_x, use_scale=False) * layer_params.norm
168+
169+
# Readout from the mean of the final sequence
170+
final_representation = jnp.mean(current_x, axis=1) # (batch, embed_dim)
171+
logits = final_representation @ params.output_w + params.output_b
172+
return jnp.squeeze(logits)
173+
174+
# --- 4. Loss and Training Functions ---
175+
176+
def loss_fn(params: ModelParams, batch_patches, batch_labels, config: TrainingConfig):
177+
logits = forward_pass(params, batch_patches, config)
178+
loss = jnp.mean(optax.sigmoid_binary_cross_entropy(logits, batch_labels))
179+
return loss
180+
181+
@partial(jit, static_argnums=(3,))
182+
def train_step(params: ModelParams, opt_state, batch, config: TrainingConfig):
183+
batch_patches, batch_labels = batch
184+
loss_val, grads = grad(loss_fn, has_aux=False)(params, batch_patches, batch_labels, config)
185+
updates, opt_state = optimizer.update(grads, opt_state, params)
186+
params = optax.apply_updates(params, updates)
187+
return params, opt_state, loss_val
188+
189+
def train(config: TrainingConfig, model_params: ModelParams, train_images, train_labels):
190+
print("--- Starting Training ---")
191+
opt_state = optimizer.init(model_params)
192+
key = random.PRNGKey(42)
193+
194+
# Data preprocessing function
195+
def preprocess_images(images):
196+
p = config.patch_size
197+
b, h, w = images.shape
198+
# (B, H/p, W/p, p, p)
199+
patches = images.reshape(b, h // p, p, w // p, p).transpose(0, 1, 3, 2, 4)
200+
# (B, H/p * W/p, p*p)
201+
patches = patches.reshape(b, (h // p) * (w // p), p * p)
202+
return patches / 255.0
203+
204+
for i in range(config.num_iterations):
205+
# Create a mini-batch
206+
key, subkey = random.split(key)
207+
indices = random.permutation(subkey, train_images.shape[0])[:config.batch_size]
208+
batch_images = train_images[indices]
209+
batch_labels = train_labels[indices]
210+
211+
batch_patches = preprocess_images(batch_images)
212+
213+
model_params, opt_state, loss_val = train_step(model_params, opt_state, (batch_patches, batch_labels), config)
214+
215+
if i % 10 == 0:
216+
print(f"Iteration {i:03d} | Loss: {loss_val:.4f}")
217+
218+
print("--- Training Finished ---")
219+
return model_params
220+
221+
222+
# --- 5. Main Execution ---
223+
if __name__ == '__main__':
224+
# Create dummy data files for demonstration
225+
print("Creating dummy data files 'ims.npy' and 'labels.npy'...")
226+
dummy_ims = np.random.randint(0, 256, size=(100, 64, 64), dtype=np.uint8)
227+
dummy_labels = np.random.randint(0, 2, size=(100,), dtype=np.int32)
228+
np.save('ims.npy', dummy_ims)
229+
np.save('labels.npy', dummy_labels)
230+
print("Dummy data created.")
231+
232+
# Configuration
233+
config = TrainingConfig(
234+
learning_rate=1e-4,
235+
batch_size=8,
236+
num_iterations=101,
237+
embed_dim=64,
238+
num_heads=4,
239+
num_layers=3,
240+
patch_size=4,
241+
image_size=64,
242+
alpha=1.0,
243+
)
244+
245+
# Initialize optimizer
246+
optimizer = optax.adam(config.learning_rate)
247+
248+
# Initialize model
249+
key = random.PRNGKey(0)
250+
model_params = init_model(key, config)
251+
252+
# Load data
253+
try:
254+
images_data = np.load('ims.npy')
255+
labels_data = np.load('labels.npy')
256+
print(f"Loaded data: images shape {images_data.shape}, labels shape {labels_data.shape}")
257+
except FileNotFoundError:
258+
print("Error: Could not find 'ims.npy' or 'labels.npy'.")
259+
exit()
260+
261+
# Run training
262+
trained_params = train(config, model_params, jnp.array(images_data, dtype=jnp.float32), jnp.array(labels_data))

0 commit comments

Comments
 (0)