6. First-class transformations

A change-of-base transformation turns a \(\mathcal{V}\)-enriched morphism into a \(\mathcal{W}\)-enriched one. Two flavours coexist in the library:

  • AlgebraHomomorphism values (Rosenthal, 1990, Quantales and Their Applications, Pitman Research Notes in Mathematics 234, Longman; ISBN 978-0-582-06423-2) are lax monoidal lattice maps \(\varphi : \mathcal{V} \to \mathcal{W}\) that act pointwise: every entry of the morphism's tensor is sent through \(\varphi\).
  • MorphismTransformation values act on the whole tensor, not entry-by-entry. Softmax row-normalization, L1/L2 row-normalization, Bayes inversion under a prior; all consume axis information that pointwise actions don't see.

Both inherit a common interface: a .source algebra, a .target algebra, and an apply that ingests a tensor (plus a morphism for shape resolution). The Python API treats them as first-class values: you bind them to local names, compose them by calling compose_trans (the DSL surface spells this as >>>), and pass either kind into Morphism.change_base.

Why a typed transformation surface?

In a PyTorch program you'd write softmax(logits, dim=-1) whenever you needed to normalise. That's fine for a one-shot call, but it has no record of which algebra the result lives in once normalised. If you then compose with another morphism that lives in a fuzzy algebra, nothing flags the mismatch and the result is mathematically incoherent (you've sum-product-composed a row-stochastic tensor with a noisy-OR one).

A MorphismTransformation is a typed function on morphisms: softmax(B) doesn't just normalise a tensor, it announces "I take a ProductFuzzyAlgebra morphism in, I emit a Markov morphism out". The compiler can then verify downstream compositions. Concretely, applying softmax(B) to any row-stochastic-shape morphism f returns a new morphism f.change_base(softmax(B)) whose algebra tag is Markov. Subsequent *> composition (which demands Markov) accepts it; subsequent >> in a fuzzy module rejects it.

This is the same idea as PyMC's pm.Deterministic wrapping a transformation so that the trace knows about it, but lifted to the algebra layer: the tag isn't just "deterministic", it's "lives in algebra W now".

The catalog

from quivers.core.algebra_morphisms import (
    EXPECTATION, MATERIAL_IMPLICATION,
    PROBABILITY_CLAMP, PROBABILITY_TO_REAL,
    COUNTING_FROM_REAL, COUNTING_TO_REAL,
    IdentityHom, Threshold, Embedding,
)
from quivers.core.morphism_transformations import (
    softmax, l1_normalize, l2_normalize, bayes_invert,
    Softmax, L1Normalize, L2Normalize, BayesInvert,
)

The shipped singletons (no arguments needed):

Name .source .target What it does
EXPECTATION Markov ProductFuzzyAlgebra Reinterpret a row-stochastic kernel as fuzzy membership.
MATERIAL_IMPLICATION ProductFuzzyAlgebra Godel Reichenbach implication lift.
PROBABILITY_CLAMP Real Probability Clamp real entries to [0, 1].
PROBABILITY_TO_REAL Probability Real Forget the [0, 1] constraint.
COUNTING_FROM_REAL Real Counting Round real entries to non-negative integers.
COUNTING_TO_REAL Counting Real Embed counts as reals.

The shipped constructors (one argument):

Constructor Argument .source .target
softmax(axis) SetObject ProductFuzzyAlgebra Markov
l1_normalize(axis) SetObject Real Markov
l2_normalize(axis) SetObject Real Real
bayes_invert(prior) Morphism or Tensor Markov Markov

Applying one transformation

import torch
from quivers import FinSet, observed
from quivers.core.morphism_transformations import softmax

A = FinSet(name="A", cardinality=3)
B = FinSet(name="B", cardinality=4)
f = observed(A, B, torch.rand(3, 4))      # ProductFuzzyAlgebra-enriched

phi = softmax(B)
g = f.change_base(phi)                     # Markov-enriched
print(g.tensor.sum(dim=-1))                # tensor([1., 1., 1.])
print(g.algebra.name)                     # 'MarkovAlgebra'

The result is an ObservedMorphism over Markov. The transformation is applied to the tensor; the domain and codomain object are preserved for shape-aware transformations like softmax (Bayes inversion is the exception: it swaps them).

Composing transformations

In Python the call site is compose_trans; in .qvr source it surfaces as the >>> operator. Both wrap two or more MorphismTransformation or AlgebraHomomorphism values into a sequential composition. The compose-time check verifies that t1.target matches t2.source; otherwise it raises.

from quivers.core.algebra_morphisms import EXPECTATION
from quivers.core.morphism_transformations import softmax
from quivers.core.trans import compose_trans

phi   = softmax(B)                # ProductFuzzyAlgebra -> Markov
psi   = EXPECTATION               # Markov       -> ProductFuzzyAlgebra
pipe  = compose_trans(phi, psi)   # ProductFuzzyAlgebra -> ProductFuzzyAlgebra

g = f.change_base(pipe)
print(g.algebra.name)            # back to 'ProductFuzzyAlgebra'

If the seams don't match (e.g. compose_trans(softmax(B), PROBABILITY_TO_REAL) would try to go Markov to Probability), compose_trans raises a TypeError naming the mismatch.

compose_trans returns a TransSeq value: a flattened sequence of single steps. Calling change_base on a TransSeq iterates the steps, applying each in turn. Nested compositions flatten so the result is always a flat tuple of steps.

Three-step pipelines

pipe = compose_trans(
    softmax(B),
    EXPECTATION,
    softmax(B),
)
g = f.change_base(pipe)

compose_trans takes any number of arguments. Each adjacent pair is type-checked at compose time.

Bayes inversion

bayes_invert(prior) is the one constructor whose argument is a morphism rather than an object. It builds a BayesInvert transformation parameterized by the prior; the transformed kernel is the Bayes-inverted posterior under that prior. Domain and codomain swap.

import torch
from quivers import FinSet, observed, MARKOV
from quivers.core.morphism_transformations import bayes_invert

Unit = FinSet(name="Unit", cardinality=1)
A    = FinSet(name="A", cardinality=3)

prior_tensor = torch.tensor([[0.5, 0.3, 0.2]])
prior        = observed(Unit, A, prior_tensor, algebra=MARKOV)

f = observed(A, A, torch.rand(3, 3), algebra=MARKOV)
g = f.change_base(bayes_invert(prior))
print(g.tensor.sum(dim=-1))      # rows of g sum to 1 (Markov)

From the DSL surface

Inside .qvr files, the same machinery is the change_base(t) postfix and the >>> operator:

composition product_fuzzy as algebra
object A : FinSet 3
object B : FinSet 4
morphism f : A -> B [role=latent]

let s    = softmax(B)
let pipe = s >>> expectation
let g    = f.change_base(pipe)
export g

let for trans-valued RHS lands the binding in the compiler's transformation namespace (disjoint from morphisms). The QVR categorical tutorial covers the user-side; this Python-side surface mirrors it directly.

Why this matters

Transformations are first-class values in the same sense morphisms are: you bind them to local names, hand them to functions, and compose them into longer pipelines. Anywhere the API accepts a morphism, the analogous slot accepts a transformation; anywhere the API composes two morphisms, the analogous combinator composes two transformations. The composition check enforces that adjacent steps' source and target algebras line up, so a pipeline either type-checks before any tensor work happens or fails fast at compose time.

Try this

  • Build a pipeline that round-trips a ProductFuzzyAlgebra morphism through Markov and back: compose_trans(softmax(B), EXPECTATION). Verify the source and target match.
  • Apply the same pipeline to two different morphisms f and h; confirm the result has the same algebra for both.
  • Construct an invalid pipeline (compose_trans(softmax(B), PROBABILITY_TO_REAL)) and observe the TypeError.

Next

Tutorial 7 introduces the CompositionRule → BilinearForm | Semigroupoid → Algebra hierarchy and the EinsumWiring surface for operadic n-ary contractions.