Browse the docs · Apsi 1.10.0
use layers — reference
use layers builds neural networks of any architecture out of small, composable layers: plain stacks,
residual networks, any directed acyclic graph (several inputs and outputs, shared weights), convolutional and
recurrent networks, transformers and graph neural networks. Models are ordinary Apsi values, so grad,
value_and_grad, the optimizers of use nn, save_params / load_params and fit all work on them. Every
model runs on NumPy (exact float64, always available) or on PyTorch (CPU, CUDA, ROCm, Apple MPS, Intel XPU) —
see BACKENDS.md.
- 1. The model contract
- 2. Parameters
- 3. Layers
- 4. Composition: sequential, residual, parallel, dag
- 5. Graph neural networks
- 6. The model zoo
- 7. Training: fit, predict
- 8. Inspection, saving and loading
- 9. Writing your own layer
- 10. Recipes
- 11. Errors and how to fix them
1. The model contract
Every layer and every model is a value with two operations:
| Operation | Meaning |
|---|---|
model.init(seed=0) |
creates the parameters — a tree of records and lists of tensors. The same seed gives the same parameters on every machine. |
model(params, x, train=false, graph=…, mask=…) |
applies the model. train=true turns on dropout and the VAE's random sampling. graph is needed by graph layers, mask is an additive attention mask. |
use layers
use nn
let model = sequential([linear(4, 32), activation("gelu"), linear(32, 3)])
var params = model.init(seed=0)
let logits = model(params, randn(8, 4)) # shape (8, 3)
Because the parameters are kept outside the model, a model value can be shared freely (between threads, between training runs, between a server and a trainer) and several parameter sets can be used with the same model.
2. Parameters
init returns a tree whose leaves are tensors:
- a layer with weights returns a
record, e.g.linear(4, 3).init()→record(W=Tensor[4, 3], b=Tensor[3]) sequentialandparallelreturn a list with one entry per layerdagreturns a record with one field per node name- layers without weights (activations, pooling, dropout, …) return an empty
record()
Read and change parameters like any other value: params[0].W, params.encoder[2].b. Every function that works
on trees — tree_map, grad, optimizers, count_params, save_params — works on these.
Initialisation: linear uses He initialisation (init="glorot" for Glorot/Xavier), convolutions He, attention and
GNN weights Glorot, embeddings N(0, 0.02²), LSTM/GRU uniform ±1/√hidden with the LSTM forget-gate bias set to 1.
3. Layers
Shapes: B batch, T sequence length, D features, C channels, H × W image size, N graph nodes.
Dense and embeddings
| Constructor | Input → output | Parameters |
|---|---|---|
linear(n_in, n_out, bias=true, init="he") |
[…, n_in] → […, n_out] |
W[n_in, n_out], b[n_out] |
dense(n_in, n_out, act=none) |
linear followed by an activation | as linear |
bilinear(n1, n2, n_out) |
[a: [B, n1], b: [B, n2]] → [B, n_out], aᵀ W_k b + b_k |
W[n_out, n1, n2], b |
embedding(vocab, d) |
integer ids [B, T] → [B, T, d] |
E[vocab, d] |
positional_encoding(max_len, d, learned=false) |
[B, T, d] → same, adds position information (sinusoidal, or a learned table) |
P[max_len, d] when learned |
Convolution and pooling (images [B, C, H, W])
| Constructor | Meaning |
|---|---|
conv2d_layer(c_in, c_out, k=3, stride=1, padding="same") |
2-D convolution (cross-correlation, as in PyTorch); padding is "same" (k // 2) or a number. Parameters W[c_out, c_in, k, k], b[c_out]. |
max_pool(k=2), avg_pool(k=2) |
k × k pooling with stride k (an incomplete border is dropped) |
global_avg_pool() |
[B, C, H, W] → [B, C] |
flatten_layer() |
[B, …] → [B, everything else] |
reshape_layer(d1, d2, …) |
[B, …] → [B, d1, d2, …] |
Sequences
| Constructor | Meaning |
|---|---|
lstm_layer(n_in, hidden, return_sequences=true) |
[B, T, n_in] → [B, T, hidden] (or [B, hidden], the last step, when return_sequences=false). Gates i, f, g, o in one fused weight W[n_in + hidden, 4·hidden]. |
gru_layer(n_in, hidden, return_sequences=true) |
GRU with weights Wzr, bzr, Wh, bh |
last_step() |
[B, T, D] → [B, D] |
mean_pool(axis=1) |
mean over an axis, e.g. over time [B, T, D] → [B, D] |
Attention and transformers
| Constructor | Meaning |
|---|---|
multi_head_attention(d, heads=4, causal=false) |
self-attention on x: [B, T, d], or cross-attention on [query, memory]. causal=true hides future positions; model(p, x, mask=M) adds any additive mask M[T, S] (use -1e9 to hide). Parameters Wq, Wk, Wv, Wo[d, d], bo[d]. |
transformer_block(d, heads=4, ff=4·d, causal=false, dropout=0.0, activation="gelu") |
pre-norm block: x + attention(norm(x)), then x + MLP(norm(x)) |
transformer_encoder(d, heads=4, layers=2, ff, causal, dropout) |
layers blocks followed by a final layer norm |
Normalisation, regularisation, scaling
| Constructor | Meaning |
|---|---|
layer_norm_layer(d, eps=1e-5) |
normalises the last axis; gamma, beta |
rms_norm_layer(d, eps=1e-6) |
x / rms(x) · gamma (as in LLaMA-style models) |
batch_norm_layer(d, eps=1e-5) |
normalises over the batch (and spatial) axes with the current batch's statistics; channel axis 1 |
dropout_layer(p=0.1) |
active only with train=true; keeps the expected value (inverted dropout) |
scale(d, shift=true) |
learnable per-feature x · s + t |
Activations
activation(name) with name one of relu, gelu (tanh approximation), silu / swish, mish, elu,
leaky_relu (slope 0.01), tanh, sigmoid, softplus, softmax (last axis), linear / identity — or
activation(any_function), e.g. activation(fn(x) => x * sigmoid(1.702 * x)). Inside sequential([...]) a name can be
written directly: sequential([linear(2, 8), "relu", linear(8, 1)]).
Small helpers
| Constructor | Meaning |
|---|---|
identity() |
returns its input |
select(i) |
element i of a list input (e.g. one branch of parallel(..., combine="list")) |
lambda_layer(fn, name="lambda") |
any function fn(x) as a layer without parameters |
add(), multiply(), concatenate() |
combine the list of inputs of a dag node |
4. Composition
sequential(layers)
Applies the layers in order. Parameters: a list.
let net = sequential([conv2d_layer(1, 16), "relu", max_pool(2), flatten_layer(), linear(16 * 14 * 14, 10)])
residual(inner, pre_norm=none)
x + inner(x); with pre_norm=layer_norm_layer(d) it is x + inner(norm(x)). Parameters record(inner, norm).
parallel(branches, combine="concat")
Runs every branch on the same input and combines the results: "concat" (last axis), "add", "mul", "mean"
or "list" (keep them separate). Parameters: a list, one entry per branch.
dag(nodes, inputs=["x"], output=…) — any directed acyclic graph
Each node is [name, layer, input] or [name, layer, [input1, input2, …]]; a node with several inputs
receives them as a list. Nodes may only use inputs or nodes defined before them, and every name is unique (both are
checked when the model is created, with a clear message). output is a name or a list of names — the model then
returns a list.
# two inputs, a shared trunk, two heads
let net = dag([
["img", cnn(1, 64, image=28), "image"],
["tab", mlp([12, 64, 64]), "table"],
["both", concatenate(), ["img", "tab"]],
["trunk", mlp([128, 128, 64]), "both"],
["cls", linear(64, 10), "trunk"],
["reg", linear(64, 1), "trunk"],
], inputs=["image", "table"], output=["cls", "reg"])
let p = net.init(seed=0)
let outs = net(p, [images, tables]) # [class logits, regression output]
Weight sharing: siamese(encoder, combine, head) (section 6) applies one encoder — one parameter set — to two
inputs [a, b]; use it as a node of a larger dag when you need shared weights inside your own graph.
5. Graph neural networks
The graph
let g = graph(edges=[[0, 1], [1, 2], [2, 0]], n=3, undirected=true) # or graph(src=…, dst=…, n=…)
| Option | Meaning |
|---|---|
edges / src, dst |
directed edges from → to |
n |
number of nodes (default: largest id + 1) |
undirected=true |
adds every edge in both directions |
self_loops=true |
adds i → i for every node (the default; GCN/GAT expect it) |
batch=[…] |
the graph id of every node, for graph-level tasks with many small graphs in one batch |
A graph has .n, .edges, .src, .dst, .in_degree, .out_degree and .adjacency().
Graph layers (node features [N, D]; apply with model(p, x, graph=g))
| Constructor | Update |
|---|---|
gcn(n_in, n_out, bias=true) |
h_i = Σ_j x_j W / √(deg_i · deg_j) (Kipf & Welling) |
sage(n_in, n_out, aggregate="mean") |
h_i = x_i W_self + agg_{j→i} x_j W_neigh with mean, sum or max |
gat(n_in, n_out, heads=4, concat=true, slope=0.2) |
graph attention; heads concatenated ([N, heads·n_out]) or averaged |
gin(n_in, n_out, hidden=n_out) |
MLP((1 + ε) x_i + Σ_j x_j) with a learnable ε |
graph_pool(how="mean") |
[N, D] → [graphs, D] using batch (mean, sum, max) |
Differentiable graph operations
segment_sum(data, ids, n), segment_mean, segment_max, segment_softmax(scores, ids, n) (softmax within each
segment, e.g. over the incoming edges of every node), segment_count(ids, n), gather(x, ids). With these you can
write any message-passing layer yourself (see section 9).
Node classification with a training mask
let r = fit(gat_net(8, 16, classes, heads=4), …, features, labels, loss="cross_entropy",
graph=g, mask=train_mask, val=…, epochs=200)
The whole graph is used in every step; only nodes where mask is true count in the loss.
6. The model zoo
| Model | Input → output |
|---|---|
mlp(sizes, act="relu", dropout=0.0, norm=false, out_act=none) |
e.g. mlp([2, 64, 64, 3]) |
resnet_mlp(d_in, d_out, width=64, blocks=3, act="gelu") |
deep MLP with pre-norm residual blocks — trains stably at any depth |
cnn(in_channels, classes, image=28, channels=[16, 32], dense_width=64) |
[B, C, image, image] → [B, classes] |
lstm_classifier(n_in, hidden, classes, layers=1) |
[B, T, n_in] → [B, classes] |
transformer_lm(vocab, d=64, heads=4, layers=2, context=128, dropout=0.0) |
GPT-style: token ids [B, T] → next-token logits [B, T, vocab] |
transformer_classifier(vocab, classes, d=64, heads=4, layers=2, context=128) |
token ids [B, T] → [B, classes] |
autoencoder(d_in, latent, hidden=[64], act="relu") |
returns [reconstruction, code] |
vae(d_in, latent, hidden=[64], act="relu") |
returns [reconstruction, mu, logvar]; train with loss="vae" (or vae_loss(out, x, beta)) |
gcn_net(n_in, hidden, classes, layers=2, dropout=0.5) |
node classification |
gat_net(n_in, hidden, classes, heads=4, dropout=0.3) |
node classification with attention |
gin_net(n_in, hidden, classes, layers=3, pool="sum") |
graph classification (uses the graph's batch) |
siamese(encoder, combine="concat", head=none) |
one encoder with shared weights applied to inputs [a, b] |
Every zoo model is an ordinary composition of the layers above, so summary(model) shows exactly what is inside and
you can copy and change it.
7. Training
fit
let r = fit(model, params, X, y, loss="cross_entropy", opt=adam(lr=1e-3), epochs=20, batch_size=64)
print(r.history[-1]) # loss of the last epoch
let trained = r.params
| Argument | Default | Meaning |
|---|---|---|
model, params |
the model and its starting parameters | |
X, y |
inputs and targets (a list of inputs for models with several inputs). Without y the target is X (autoencoders, VAEs). |
|
loss |
"mse" |
"mse", "mae", "cross_entropy" (integer labels), "bce" (logits), "vae", "huber" (torch backend), or a function loss(prediction, target) |
opt |
adam(1e-3) |
any optimizer of use nn: sgd(lr, momentum), adam(lr, b1, b2, eps), adamw(lr, …, weight_decay) |
epochs |
10 |
passes over the data |
batch_size |
all | mini-batch size; batches are reshuffled every epoch (reproducibly from seed) |
seed |
0 |
shuffling, dropout and sampling |
clip |
none | clip the global gradient norm to this value |
schedule |
none | a function step → learning rate, e.g. fn(s) => cosine_lr(s, 1000, base=3e-4, warmup=100) |
graph, mask |
none | graph tasks (section 5) |
val |
none | [X_val, y_val]; the validation loss is recorded every epoch |
verbose |
true |
print the loss about ten times during training |
backend |
"numpy" |
"numpy", "torch" or "auto" — see BACKENDS.md |
device, precision, compile |
"auto", "fp32", false |
torch backend only |
fit returns record(params, history, val_history, device) — device is "numpy" or the torch device used.
Your own training loop
fit is a convenience. Any loop works, because the model is a function of its parameters:
let opt = adamw(lr=3e-4, weight_decay=0.01)
var p = model.init(seed=0)
for step in 0..1000:
let xb, yb = next_batch()
let l, g = value_and_grad(fn(q) => cross_entropy(model(q, xb, train=true), yb))(p)
p = opt.step(p, clip_grad_norm(g, 1.0))
predict
predict(model, params, X, graph=…, backend="numpy") applies the model with train=false.
8. Inspection, saving and loading
summary(model, params)prints a table of every layer and its parameter count, and returns it as a stringcount_params(params)— the number of trainable numberssave_params("model.npz", params)/load_params("model.npz", model.init())— portable NumPy archivesquantize_model(params, int8)andqlinearshrink a trained model (seeAI.md)- on the torch backend,
torch_module(model, params)gives a realtorch.nn.Module(state_dict, ONNX export, …)
9. Writing your own layer
Any function fn(params, x) already is a model you can differentiate. To make it a reusable layer with its own
parameters, put it in a dag/sequential through lambda_layer (no parameters), or keep the parameters next to
the model in a record:
use layers
use nn
# a gated message-passing layer written with the segment operations
fn gated_mp(p, x, g):
let msg = gather(x, g.src) @ p.W
let gate = sigmoid(gather(x, g.dst) @ p.G)
return x + segment_mean(msg * gate, g.dst, g.n)
let p = record(W=randn(8, 8, seed=0) * 0.1, G=randn(8, 8, seed=1) * 0.1)
let l, grads = value_and_grad(fn(q) => mean(gated_mp(q, features, g) ** 2))(p)
Mix both styles freely: record(backbone=net.init(seed=0), head=dense(64, 1)) is a valid parameter tree, and
fn model(p, x) = net(p.backbone, x) @ p.head.W + p.head.b is a valid model.
10. Recipes
Image classifier
let net = cnn(1, 10, image=28, channels=[32, 64], dense_width=128)
let r = fit(net, net.init(seed=0), images, labels, loss="cross_entropy", opt=adam(lr=1e-3), epochs=5, batch_size=128)
Character-level GPT
let lm = transformer_lm(vocab_size, d=128, heads=4, layers=4, context=128, dropout=0.1)
let r = fit(lm, lm.init(seed=0), tokens[:, 0:-1], tokens[:, 1:], loss="cross_entropy", epochs=10, batch_size=32,
schedule=fn(s) => cosine_lr(s, 5000, base=3e-4, warmup=200), clip=1.0)
Sequence regression with an LSTM
let net = sequential([lstm_layer(3, 64), lstm_layer(64, 64, return_sequences=false), linear(64, 1)])
Wide & deep
let net = dag([["wide", linear(20, 1), "x"], ["deep", mlp([20, 128, 64, 1]), "x"], ["out", add(), ["wide", "deep"]]])
Variational autoencoder
let v = vae(784, 16, hidden=[256])
let r = fit(v, v.init(seed=0), images_flat, loss="vae", epochs=20, batch_size=128)
let recon = predict(v, r.params, images_flat)[0]
Graph classification — many small graphs in one batch:
let g = graph(src=all_src, dst=all_dst, n=total_nodes, batch=graph_id_of_node)
let net = gin_net(n_features, 64, classes, layers=3)
let r = fit(net, net.init(seed=0), node_features, graph_labels, loss="cross_entropy", graph=g, epochs=100)
11. Errors and how to fix them
| Message | Cause and fix |
|---|---|
sequential: 3 layers but 2 parameter entries |
the parameters belong to another model — create them with model.init() |
dag(): node 'h2' uses 'h3', which is not defined before it |
order the nodes so every input comes first |
graph layers need the graph: model(params, X, graph=g) |
pass graph=g to the model, fit and predict |
multi_head_attention: d=30 must be divisible by heads=4 |
choose d as a multiple of heads |
unknown activation 'reLU' |
names are lower case; the message lists every valid name |
fit(): unknown loss |
use one of the listed names or pass a function |
fit(backend="torch") needs permission 'python' |
the torch backend runs PyTorch: apsi run --allow-python app.apsi |
backend="torch" needs PyTorch |
apsi pkg install torch-cuda (NVIDIA) or apsi pkg install torch (CPU) — see BACKENDS.md |
Found a mistake? Report it · Markdown source