Tutorial: Building with Versor¶
A step-by-step guide to using Versor's geometric layers in your own models.
1. Create a Clifford Algebra¶
Everything starts with a CliffordAlgebra instance. The signature \((p, q, r)\) determines the geometry:
from core.algebra import CliffordAlgebra
# 3D Euclidean (rotations in 3-space)
algebra = CliffordAlgebra(p=3, q=0, r=0, device='cpu')
print(algebra.dim) # 8 = 2^3 basis blades
# 2D Minkowski (Lorentz boosts)
algebra_mink = CliffordAlgebra(p=1, q=1, r=0, device='cpu')
print(algebra_mink.dim) # 4 = 2^2 basis blades
# PGA: 3D Euclidean + 1 null dimension for translations
algebra_pga = CliffordAlgebra(p=3, q=0, r=1, device='cpu')
print(algebra_pga.dim) # 16 = 2^4 basis blades
Key properties:
- algebra.n — total dimensions (\(p + q + r\))
- algebra.dim — total basis blades (\(2^n\))
- algebra.num_grades — number of grades (\(n + 1\))
2. Understand Multivector Tensors¶
All data in Versor is represented as multivectors with shape [Batch, Channels, 2^n].
import torch
# Embed raw 3D vectors into multivectors
vectors = torch.randn(32, 3) # [Batch, 3]
mv = algebra.embed_vector(vectors) # [Batch, 8]
# Components: [scalar, e1, e2, e12, e3, e13, e23, e123]
# Indices: 0 1 2 3 4 5 6 7
# Add a channel dimension for neural layers
mv = mv.unsqueeze(1) # [32, 1, 8] — 1 channel
Basis blade indexing uses binary representation:
- Index 0 (000) = scalar (grade 0)
- Index 1 (001) = \(e_1\), Index 2 (010) = \(e_2\), Index 4 (100) = \(e_3\) (grade 1)
- Index 3 (011) = \(e_{12}\), Index 5 (101) = \(e_{13}\), Index 6 (110) = \(e_{23}\) (grade 2)
- Index 7 (111) = \(e_{123}\) (grade 3)
3. Core Algebra Operations¶
A = torch.randn(4, 8) # 4 multivectors
B = torch.randn(4, 8)
# Geometric product
AB = algebra.geometric_product(A, B)
# Grade projection (extract vectors only)
vectors_only = algebra.grade_projection(A, grade=1)
# Reverse (Clifford conjugate): flips sign based on grade
A_rev = algebra.reverse(A)
# Exponentiate a bivector to get a rotor
bivector = torch.zeros(1, 8)
bivector[0, 3] = 0.5 # rotation in e12 plane
R = algebra.exp(bivector)
4. Using Layers¶
RotorLayer — Learned Geometric Rotation¶
from layers.rotor import RotorLayer
rotor = RotorLayer(algebra, channels=4)
x = torch.randn(32, 4, 8) # [Batch, Channels, Dim]
y = rotor(x) # Same shape, rotated
# After training, inspect learned bivectors:
print(rotor.bivector_weights) # [4, 3] — 4 channels, 3 bivector planes
# Prune small bivectors for sparsity
n_pruned = rotor.prune_bivectors(threshold=1e-4)
MultiRotorLayer — Spectral Decomposition¶
from layers.multi_rotor import MultiRotorLayer
multi = MultiRotorLayer(algebra, channels=4, num_rotors=8)
x = torch.randn(32, 4, 8)
y = multi(x) # Superposition of 8 sandwich products
# Get invariant features (grade norms)
invariants = multi(x, return_invariants=True) # [32, 4, n+1]
CliffordLinear — Channel Mixing¶
from layers.linear import CliffordLinear
linear = CliffordLinear(algebra, in_channels=4, out_channels=8)
x = torch.randn(32, 4, 8)
y = linear(x) # [32, 8, 8] — channels mixed, blades preserved
CliffordLayerNorm — Direction-Preserving Normalization¶
from layers.normalization import CliffordLayerNorm
norm = CliffordLayerNorm(algebra, channels=4)
x = torch.randn(32, 4, 8)
y = norm(x) # Normalized to unit magnitude, direction preserved
GeometricGELU — Magnitude-Based Activation¶
from functional.activation import GeometricGELU
act = GeometricGELU(algebra, channels=4)
x = torch.randn(32, 4, 8)
y = act(x) # Magnitude scaled by GELU, direction preserved
BladeSelector — Grade Attention¶
from layers.projection import BladeSelector
selector = BladeSelector(algebra, channels=1)
x = torch.randn(32, 1, 8)
y = selector(x) # Soft per-blade gate (learned)
5. Composing a Model¶
import torch.nn as nn
from layers.rotor import RotorLayer
from layers.linear import CliffordLinear
from layers.normalization import CliffordLayerNorm
from functional.activation import GeometricGELU
class MyGBN(nn.Module):
def __init__(self, algebra):
super().__init__()
self.net = nn.Sequential(
CliffordLinear(algebra, 1, 4),
CliffordLayerNorm(algebra, channels=4),
GeometricGELU(algebra, channels=4),
RotorLayer(algebra, channels=4),
CliffordLinear(algebra, 4, 1),
)
def forward(self, x):
return self.net(x)
algebra = CliffordAlgebra(p=3, q=0, device='cpu')
model = MyGBN(algebra)
x = torch.randn(32, 1, 8)
y = model(x) # [32, 1, 8]
6. Creating a Task¶
All tasks inherit from BaseTask and implement 7 methods:
from tasks.base import BaseTask
from core.algebra import CliffordAlgebra
from functional.loss import GeometricMSELoss
class MyTask(BaseTask):
def setup_algebra(self):
return CliffordAlgebra(p=3, q=0, device=self.device)
def setup_model(self):
return MyGBN(self.algebra)
def setup_criterion(self):
return GeometricMSELoss(self.algebra)
def get_data(self):
# Return a DataLoader or raw tensor
return torch.randn(100, 1, self.algebra.dim).to(self.device)
def train_step(self, data):
self.optimizer.zero_grad()
output = self.model(data)
loss = self.criterion(output, data) # autoencoder
loss.backward()
self.optimizer.step()
return loss.item(), {"Loss": loss.item()}
def evaluate(self, data):
output = self.model(data)
loss = self.criterion(output, data)
print(f"Eval loss: {loss.item():.4f}")
def visualize(self, data):
pass # Optional
Then register it in main.py:
Create a config conf/task/mytask.yaml:
# @package _global_
name: "mytask"
algebra:
p: 3
q: 0
device: "cpu"
training:
epochs: 100
lr: 0.001
batch_size: 32
seed: 42
Run it:
7. Losses¶
from functional.loss import (
GeometricMSELoss, # Standard MSE on multivector coefficients
SubspaceLoss, # Penalizes energy outside target blades
IsometryLoss, # Enforces norm preservation
BivectorRegularization, # Forces outputs to be pure bivectors
)
# SubspaceLoss: keep only vector components
vec_indices = [1, 2, 4] # e1, e2, e3 in Cl(3,0)
loss_fn = SubspaceLoss(algebra, target_indices=vec_indices)
# IsometryLoss: input and output should have same norm
iso_loss = IsometryLoss(algebra)
loss = iso_loss(output, input)
8. Automatic Metric Search¶
Don't know the right \((p, q, r)\)? Let Versor find it:
from core.search import MetricSearch
data = torch.randn(100, 6) # 6D data
searcher = MetricSearch(device='cpu')
best_p, best_q, best_r = searcher.search(data)
print(f"Optimal signature: Cl({best_p}, {best_q}, {best_r})")
This lifts data into a conformal algebra, trains GBN probes with biased initialization, and analyzes the learned bivector energy to classify each dimension as positive, negative, or null — returning the optimal (p, q, r) 3-tuple.