Train a Surface Projection¶
The following \(Cl(3, 0)\) experiment trains a rotor action followed by a learned lane gate to project a sampled surface toward the \(e_1\)–\(e_2\) plane. The data formula, model, loss, optimizer, robustness measurement, and surface plotting helper are included. Familiarity with PyTorch training loops is assumed; every clifra-specific object is introduced locally.
The surface is a grid patch:
The grade-2 VersorLayer learns a bivector, exponentiates it to a rotor, and
applies the planned isometric action. BladeSelector then learns per-lane
gates. The selector makes this model a projection rather than an invertible
change of coordinates; a single global rotor cannot flatten a curved surface.


Imports¶
import torch
from clifra import make_algebra
from clifra.core.foundation import CliffordModule
from clifra.layers import BladeSelector, VersorLayer
from clifra.optimizers import make_riemannian_optimizer
Sample the Manifold¶
\(Cl(3, 0)\) has eight canonical lanes. embed_vector places Cartesian coordinates
in the grade-1 subspace; GradeLayout.compact retrieves them without encoding
canonical lane positions in the example.
def sample_patch(algebra, n: int = 24) -> tuple[torch.Tensor, tuple[int, int]]:
x = torch.linspace(-1.0, 1.0, n, device=algebra.device, dtype=algebra.dtype)
y = torch.linspace(-1.0, 1.0, n, device=algebra.device, dtype=algebra.dtype)
X, Y = torch.meshgrid(x, y, indexing="ij")
Z = 0.5 * X * Y
xyz = torch.stack((X.reshape(-1), Y.reshape(-1), Z.reshape(-1)), dim=-1)
return algebra.embed_vector(xyz).unsqueeze(1), (n, n) # [N, C=1, 8]
def vector_coordinates(algebra, values: torch.Tensor) -> torch.Tensor:
"""Gather Cartesian vector coordinates from canonical multivectors."""
return algebra.layout((1,)).compact(values)
def coordinate_component(algebra, values: torch.Tensor, axis: int) -> torch.Tensor:
"""Return one Cartesian coordinate without using a basis-lane index."""
return vector_coordinates(algebra, values).select(-1, int(axis))
Build the Projection¶
A grade-2 rotor action is followed by a blade selector. The selector starts as pass-through and learns which lanes to attenuate.
class SurfaceProjection(CliffordModule):
"""Apply a rotor action and filter residual lane energy."""
def __init__(self, algebra):
super().__init__(algebra)
full_layout = algebra.layout(range(algebra.n + 1))
self.rotor = VersorLayer(
algebra,
channels=1,
grade=2,
input_layout=full_layout,
output_layout=full_layout,
)
self.selector = BladeSelector(algebra, channels=1, layout=full_layout)
def forward(self, x):
return self.selector(self.rotor(x))
def selector_penalty(self):
return self.selector.weights.abs().mean()
Define the Loss¶
The loss has two terms:
| Term | Purpose |
|---|---|
z_energy |
pushes the e3 coordinate toward zero |
selector_deviation |
penalizes selector logits away from the pass-through value |
def loss_terms(model, noisy):
output = model(noisy)
z = coordinate_component(model.algebra, output, axis=2)
z_energy = z.square().mean()
selector_deviation = model.selector_penalty()
weighted_selector_deviation = 1.0e-3 * selector_deviation
loss = z_energy + weighted_selector_deviation
metrics = {
"loss": loss.detach(),
"z": z_energy.detach(),
"selector_deviation": selector_deviation.detach(),
"weighted_selector_deviation": weighted_selector_deviation.detach(),
}
return loss, output, metrics
Train¶
torch.manual_seed(7)
algebra = make_algebra(3, 0, device="cpu", dtype=torch.float32)
data, grid_shape = sample_patch(algebra)
model = SurfaceProjection(algebra)
optimizer = make_riemannian_optimizer(
model,
algebra,
optimizer="adam",
lr=0.03,
max_bivector_norm=1.2,
)
history = []
for step in range(240):
vector_noise = torch.randn_like(vector_coordinates(algebra, data)) * 0.01
noisy = algebra.layout((1,)).full(vector_coordinates(algebra, data) + vector_noise)
optimizer.zero_grad()
loss, output, metrics = loss_terms(model, noisy)
loss.backward()
optimizer.step()
history.append({"step": step, **{key: float(value) for key, value in metrics.items()}})
with torch.no_grad():
projected = model(data)
Inspect¶
def measure(algebra, values, model=None):
metrics = {
"z": float(coordinate_component(algebra, values, axis=2).square().mean()),
}
if model is not None:
metrics["selector_deviation"] = float(model.selector_penalty().detach())
return {
key: round(value, 6)
for key, value in metrics.items()
}
raw_metrics = measure(algebra, data)
projected_metrics = measure(algebra, projected, model)
print("raw", raw_metrics)
print("projected", projected_metrics)
Representative run:
Inspect the learned gate in vector coordinates rather than indexing canonical lanes:
with torch.no_grad():
gates = 2.0 * torch.sigmoid(model.selector.weights)
vector_gates = algebra.layout((1,)).compact(gates)
print("vector gates", vector_gates.squeeze())
The \(e_3\) gate is smaller than the \(e_1\) and \(e_2\) gates. This is the non-invertible step that removes most of the height variation.
Noise Check¶
def noise_test(model, data):
rows = []
for noise_std in [0.0, 0.01, 0.05, 0.1, 0.2]:
coordinates = vector_coordinates(model.algebra, data)
noisy_coordinates = coordinates + torch.randn_like(coordinates) * noise_std
noisy = model.algebra.layout((1,)).full(noisy_coordinates)
with torch.no_grad():
output = model(noisy)
rows.append({"noise": noise_std, **measure(model.algebra, output, model)})
return rows
noise_rows = noise_test(model, data)


Plot¶
The plotting code first gathers grade-1 coordinates through the layout. It does not depend on the canonical positions of \(e_1\), \(e_2\), or \(e_3\). Both surfaces use the same coordinate bounds. Without shared bounds, Matplotlib expands the small residual \(e_3\) range in the projected result and makes it appear much larger than it is.
def xyz(algebra, values):
coordinates = vector_coordinates(algebra, values).detach().cpu()
if coordinates.ndim == 3:
coordinates = coordinates.squeeze(-2)
return coordinates.unbind(dim=-1)
def shared_bounds(algebra, *values):
coordinates = torch.cat(
[vector_coordinates(algebra, value).reshape(-1, 3) for value in values],
dim=0,
)
lower = coordinates.amin(dim=0)
upper = coordinates.amax(dim=0)
padding = (upper - lower).clamp_min(1.0e-6) * 0.05
return tuple(
(float(lo - pad), float(hi + pad))
for lo, hi, pad in zip(lower, upper, padding)
)
def plot_patch(algebra, values, grid_shape, title, path, bounds):
import matplotlib.pyplot as plt
x, y, z = xyz(algebra, values)
n0, n1 = grid_shape
fig = plt.figure(figsize=(6, 5))
ax = fig.add_subplot(111, projection="3d")
ax.plot_surface(
x.reshape(n0, n1),
y.reshape(n0, n1),
z.reshape(n0, n1),
cmap="viridis",
norm=plt.Normalize(vmin=bounds[2][0], vmax=bounds[2][1]),
linewidth=0,
alpha=0.88,
)
ax.set_xlim(*bounds[0])
ax.set_ylim(*bounds[1])
ax.set_zlim(*bounds[2])
ax.set_box_aspect(tuple(hi - lo for lo, hi in bounds))
ax.view_init(elev=28, azim=-60)
ax.set_title(title)
ax.set_xlabel("e1 / x")
ax.set_ylabel("e2 / y")
ax.set_zlabel("e3 / z")
fig.tight_layout()
fig.savefig(path, dpi=170)
plt.close(fig)
bounds = shared_bounds(algebra, data, projected)
plot_patch(
algebra,
data,
grid_shape,
"Sampled surface",
"surface-original.png",
bounds,
)
plot_patch(
algebra,
projected,
grid_shape,
"Projected representation",
"surface-projected.png",
bounds,
)
Experiment components¶
| Piece | Role |
|---|---|
make_algebra(3, 0) |
3-D Euclidean Clifford algebra |
algebra.embed_vector(xyz) |
Formula samples into full-lane multivectors |
VersorLayer(..., grade=2) |
learned bivector and isometric rotor action |
BladeSelector |
learned lane gate that supplies the projection step |
coordinate_component(..., axis=2) |
z-energy pressure without a basis-lane literal |
model.selector_penalty() |
selector-logit regularization toward pass-through |
The selector can discard information, so the projected result is not an invertible flattening or evidence of a change in the surface's intrinsic geometry.