Tutorial 1: Your First Quiver¶
In this tutorial, you will create a simple enriched category and work with morphisms as tensors. A quiver in this context is a directed graph where edges carry values in a lattice (algebra) rather than being abstract. When the algebra is \([0, 1]\) with product t-norm and noisy-OR, morphisms are fuzzy relations: functions from pairs of objects to truth values in \([0, 1]\).
If you're coming from Stan, PyMC, or Pyro: the Python API is the lower-level interface that the QVR DSL compiles to. You write models as compositions of typed tensors instead of as program blocks. Use this track when you want to build new categorical constructs, write libraries on top of quivers, or read the type errors emitted by the DSL compiler. For day-to-day modelling, the DSL track is the right entry point.
The setup in this chapter is deliberately tiny: three finite sets and one composed pipeline. The point is to see that morphism in this library is concretely a tensor with a typed signature, and that >> is concretely a (typed) tensor contraction. Everything else builds on these two ideas.
Concepts¶
- Objects: finite sets (
FinSet) - Morphisms: \(\mathcal{V}\)-relations represented as tensors
- Latent morphisms: parameters (learnable)
- Observed morphisms: fixed tensors
- Composition: tensor contraction according to the algebra's operations
Setup¶
import torch
from quivers.core.objects import FinSet
from quivers.core.morphisms import morphism, observed
from quivers.core.algebras import PRODUCT_FUZZY
from quivers.program import Program
Creating Objects¶
Create three finite sets: one for positions (X), one for colors (Y), and one for outcomes (Z):
X = FinSet(name="Position", cardinality=3)
Y = FinSet(name="Color", cardinality=4)
Z = FinSet(name="Outcome", cardinality=2)
print(X.size) # 3
print(Y.size) # 4
print(Z.size) # 2
Each object has a shape and size. These define the dimensions of the tensors representing morphisms.
Creating Morphisms¶
Latent Morphism¶
A latent morphism has learnable tensor entries. Create one from X to Y using morphism:
f = morphism(X, Y)
print(f.domain.name) # Position
print(f.codomain.name) # Color
print(f.tensor) # shape [3, 4], values in (0, 1)
print(f.tensor.shape)
The tensor is initialized with a sigmoid activation, so all entries lie in \((0, 1)\). The entries are PyTorch parameters: they will be updated during backpropagation if you include the morphism in a training loop.
Inspect the underlying module:
mod = f.module()
params = list(mod.parameters())
print(len(params)) # 1
print(params[0].shape) # torch.Size([3, 4])
Observed Morphism¶
An observed morphism has a fixed, non-learnable tensor. Create one from Y to Z with explicit data using observed:
data = torch.tensor([
[1.0, 0.1],
[0.8, 0.2],
[0.5, 0.5],
[0.0, 0.9],
])
g = observed(Y, Z, data)
print(g.tensor)
The tensor shape must match \((|Y|, |Z|) = (4, 2)\). If you pass the wrong shape, an error is raised.
Verify it has no learnable parameters:
mod_g = g.module()
params_g = list(mod_g.parameters())
print(len(params_g)) # 0
Composition¶
Compose two morphisms using the >> operator. Composition applies the algebra's operations: the tensor product \(\otimes\) (multiplication) and the join \(\bigvee\) (noisy-OR).
Create a third latent morphism from Z to a new object:
W = FinSet(name="Result", cardinality=5)
h = morphism(Z, W)
Compose f and g: X -> Y -> Z
fg = f >> g
print(fg.domain.name) # Position
print(fg.codomain.name) # Outcome
print(fg.tensor.shape) # [3, 2]
Composition is lazy: the tensor is not computed until evaluation. Verify this is a ComposedMorphism:
from quivers.core.morphisms import ComposedMorphism
print(isinstance(fg, ComposedMorphism)) # True
Compose again: (X -> Y -> Z) -> W
fgh = fg >> h
print(fgh.tensor.shape) # [3, 5]
The composition operation uses the algebra's tensor product (here, pointwise multiplication) and join (noisy-OR):
In PRODUCT_FUZZY, \(\otimes\) is multiplication and \(\bigvee\) is \(1 - \prod_i (1 - x_i)\).
Accessing Tensor Values¶
Once composed, access the materialized tensor:
tensor_value = fg.tensor
print(tensor_value.dtype) # torch.float32
print(tensor_value.min(), tensor_value.max()) # check range
For a relation in PRODUCT_FUZZY, values should lie in \([0, 1]\).
Working as a Differentiable Module¶
Wrap a morphism in a Program to make it a differentiable nn.Module:
prog = Program(f)
output = prog()
print(output.shape) # [3, 4]
print(output)
Integrate with PyTorch training:
import torch.optim as optim
program = Program(fgh) # a complex composition
optimizer = optim.Adam(program.parameters(), lr=0.01)
for epoch in range(5):
optimizer.zero_grad()
# Forward pass
result = program()
# Define a loss (e.g., sum for illustration)
loss = result.sum()
# Backward and step
loss.backward()
optimizer.step()
if epoch % 1 == 0:
print(f"Epoch {epoch}, Loss: {loss.item():.4f}")
The parameters of all latent morphisms in the composition will be updated.
Algebras¶
The examples so far use PRODUCT_FUZZY as the enrichment:
print(PRODUCT_FUZZY.name) # "ProductFuzzy"
Other algebras are available (BOOLEAN, LUKASIEWICZ, GODEL). The choice of algebra affects:
- How morphisms are initialized
- How they compose
- The semantics of the resulting values
For now, PRODUCT_FUZZY is the natural choice for fuzzy relations.
Summary¶
You have:
- Created finite sets (objects)
- Constructed latent (learnable) and observed (fixed) morphisms
- Composed morphisms with
>> - Inspected tensor shapes and values
- Wrapped morphisms as differentiable modules
- Learned that composition uses the algebra's operations (tensor product and join)
Next, explore how these ideas extend to stochastic morphisms (Markov kernels) in Tutorial 2.