Transformer-shaped language model¶
Overview¶
This model borrows the parallel-head and stacked-block shape of a Transformer, then scores a token with a Categorical head. The head morphisms are independent MLP-parameterized Normal kernels. The source contains no query-key dot products, softmax over positions, causal mask, layer normalization, or additive residual connection. It is thus a wiring demonstration rather than a Transformer implementation or causal language model.
QVR source¶
# Multi-Layer Bayesian Transformer Language Model
#
# A multi-layer Bayesian transformer used as a causal language
# model. Token indices are embedded into a Latent
# representation, passed through two independent attention plus
# feed-forward layers via stack(layer, 2), and projected back
# onto the Token vocabulary via a Categorical lm_head.
#
# Generative structure:
#
# h_attn ~ fan(head)(h) >> attn_proj four-head attention
# h_res ~ residual_attn(h_attn) attention residual
# h_ff ~ ff_up(h_res) >> ff_down feed-forward block
# h ~ residual_ff(h_ff) feed-forward residual
# next_t ~ Categorical(lm_head(h)) next-token target
#
# stack(layer, 2) composes two independent copies of the
# attention plus feed-forward block, each carrying its own
# Normal-prior weights drawn from the morphism declarations.
#
# Resp is the plate: it indexes the 32 scored rows, one
# next-token target per context. Token is the vocabulary, so it
# is the value space of what lm_head draws and of what the
# program returns.
#
# Reference: [Vaswani et al. 2017](https://doi.org/10.48550/arXiv.1706.03762).
object Token : FinSet 32
object Resp : FinSet 32
object Latent : Real 16
object HeadOut : Real 4
object FFHidden : Real 32
morphism tok_embed : Token -> Latent [role=embed]
morphism head : Latent -> HeadOut [replicate=4, param_source=mlp] ~ Normal
morphism attn_proj : Latent -> Latent [param_source=mlp] ~ Normal
morphism ff_up : Latent -> FFHidden [param_source=mlp] ~ Normal
morphism ff_down : FFHidden -> Latent [param_source=mlp] ~ Normal
morphism residual_attn, residual_ff : Latent -> Latent [param_source=mlp] ~ Normal
morphism lm_head : Latent -> Token ~ Categorical
define layer = fan(head) >> attn_proj >> residual_attn >> ff_up >> ff_down >> residual_ff
define backbone = tok_embed >> stack(layer, 2)
program transformer_lm : Token -> Token
sample h <- backbone
observe next_token : Resp <- lm_head(h)
return next_token
export transformer_lm
Walkthrough¶
Parallel heads¶
morphism head : Latent -> HeadOut [replicate=4, param_source=mlp] ~ Normal declares four independent kernels. fan(head) runs them in parallel on the same input and concatenates their four-dimensional outputs. This matches the dimensional wiring of four heads but does not compute attention.
Layer block¶
define layer = fan(head) >> attn_proj >> residual_attn >> ff_up >> ff_down >> residual_ff
attn_proj mixes the parallel outputs back into Latent. residual_attn and residual_ff are sequential stochastic morphisms, not additions of a saved input, despite their names. ff_up >> ff_down is a two-stage MLP-shaped kernel composition.
Deep stack¶
stack(layer, 2) creates two independent deep copies of layer, each with its own parameters (unlike repeat, which weight-ties the iterations). The full backbone is tok_embed >> stack(layer, 2), mapping the input token sequence to a per-position Latent representation.
Language-model head¶
The closing morphism lm_head : Latent -> Token ~ Categorical is a Kleisli morphism Latent -> Token; per position it produces a Categorical distribution over the thirty-two-symbol vocabulary, and the program's observe next_token step accumulates the per-position categorical log-likelihood against the supplied target tensor.
The two FinSet objects sit in different positions and mean different things. Resp : FinSet 32 fills the observe step's index slot, so it is the plate: 32 scored rows. Token : FinSet 32 fills lm_head's codomain and the program's own codomain, so it is the value space: the 32 outcomes a draw ranges over, and the space the returned next_token lives in. That the two happen to have the same cardinality here is a coincidence of this example's sizing, not a shared role.
Try it¶
The short fits below demonstrate the API. Assess convergence with multiple chains and diagnostics before interpreting a posterior.
Generating synthetic data¶
Fix the model's parameters to a chosen draw, then run one forward trace so the latent representation h and the next-token targets generated from it are jointly consistent. true_h names the ground truth for the latent h site, and shipping it in the observations dict is what clamps it: an unclamped h is redrawn on every call, which leaves any reference joint non-deterministic. The corpus is 32 single-token contexts, one per element of the Resp plate, paired with 32 next-token targets. A one-position context does not test cross-position attention or causal masking: fan(head) inside stack folds a multi-position axis into the feature axis, so the composite as written scores one position per row.
import torch
from quivers.dsl import load
from quivers.inference.trace import trace
torch.manual_seed(0)
prog = load("docs/examples/source/transformer_lm.qvr")
model = prog.morphism
for _, p in model.named_parameters():
p.data.copy_(torch.randn_like(p) * 0.3)
rows, vocab = 32, 32
x_in = torch.randint(0, vocab, (rows,))
with torch.no_grad():
forward = trace(model, x_in)
true_h = forward.sites["h"].value.detach()
next_token = forward.sites["next_token"].value.detach()
observations = {"next_token": next_token, "h": true_h}
print("x_in:", tuple(x_in.shape))
print("true_h:", tuple(true_h.shape))
print("next_token:", tuple(next_token.shape))
SVI fit¶
Re-initialise the parameters and recover the next-token weights from the synthetic corpus with AutoNormalGuide + ELBO + SVI. The transformer's per-particle Monte-Carlo log-density makes each step relatively expensive; a short run is enough to verify that the negative ELBO falls.
import torch
from quivers.dsl import load
from quivers.inference import AutoNormalGuide, ELBO, SVI
torch.manual_seed(0)
prog = load("docs/examples/source/transformer_lm.qvr")
model = prog.morphism
for _, p in model.named_parameters():
p.data.copy_(torch.randn_like(p) * 0.3)
rows, vocab = 32, 32
contexts = torch.randint(0, vocab, (rows,))
targets = model.rsample(contexts)
observations = {"next_token": targets}
torch.manual_seed(1)
for _, p in model.named_parameters():
p.data.copy_(torch.randn_like(p) * 0.3)
guide = AutoNormalGuide(model, observed_names={"next_token"})
optim = torch.optim.Adam(
list(model.parameters()) + list(guide.parameters()), lr=5e-2,
)
svi = SVI(model, guide, optim, ELBO(num_particles=1))
losses = [svi.step(contexts, observations)]
for _ in range(8):
losses.append(svi.step(contexts, observations))
print(f"initial loss: {losses[0]:.2f}")
print(f"final loss: {losses[-1]:.2f}")
HMC posterior¶
The lifted Bayesian model treats both the parameters \(\theta\) and the per-position latent \(h\) as random variables: \(p(\theta, h \mid x, y) \propto p(\theta) \, p(h \mid x, \theta) \, p(y \mid h, \theta)\). bayesian_lift_parameters assigns Normal priors to every nn.Parameter and exposes the intermediate h site through additional_latents. The lifted log density is deterministic given the full \((\theta, h)\) state. Because the transformer's log_joint walks every step in the stack, NUTS tree expansion is expensive at this dimension; the example uses HMCKernel with one leapfrog step while retaining the same target distribution.
import torch
from quivers.dsl import load
from quivers.inference import MCMC, HMCKernel, bayesian_lift_parameters
torch.manual_seed(0)
prog = load("docs/examples/source/transformer_lm.qvr")
model = prog.morphism
for _, p in model.named_parameters():
p.data.copy_(torch.randn_like(p) * 0.3)
rows, vocab = 32, 32
contexts = torch.randint(0, vocab, (rows,))
targets = model.rsample(contexts)
observations = {"next_token": targets}
h_shape = tuple(model._step_h.rsample(contexts).shape)
lifted, lx, lobs = bayesian_lift_parameters(
model, contexts, observations,
prior_scale=1.0,
additional_latents={"h": h_shape},
)
# The full transformer log_joint is expensive; use fixed-step HMC
# with one leapfrog step per sample to keep the run tractable.
# NUTS with the same target produces the same chain mathematically
# at much higher cost.
kernel = HMCKernel(step_size=0.001, num_steps=1, target_accept=0.6)
mc = MCMC(kernel, num_warmup=3, num_samples=3, num_chains=1)
result = mc.run(lifted, lx, lobs)
print(f"acceptance: {float(result.acceptance_rates.mean()):.2f}")
print(f"divergences: {int(result.divergence_counts.sum())}")
Categorical perspective¶
The model composes replicated stochastic branches, a projection, and two-stage feed-forward kernels. stack makes independently parameterized layers, while fan copies the input across parallel branches. Neither combinator by itself implements self-attention.
References¶
- Ashish Vaswani, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan N. Gomez, Łukasz Kaiser, and Illia Polosukhin. 2017. Attention is all you need. arXiv preprint arXiv:1706.03762.
- Michèle Giry. 1982. A categorical approach to probability theory. In Bernhard Banaschewski, editor, Categorical Aspects of Topology and Analysis, volume 915 of Lecture Notes in Mathematics, pages 68–85. Springer, Berlin, Heidelberg.