DisCoCat in DisCoPy
In the previous tutorial, we learnt the basics of monoidal categories and how to represent them in DisCoPy. In this tutorial, we look at the Distributional Compositional Categorical model [CSC2010], which uses functors to map diagrams from the rigid category of pregroup grammars to vector space semantics.
Pregroup grammars
Pregroup grammar is a grammatical formalism devised by Joachim Lambek in 1999 [Lam1999]. In pregroups, each word is a morphism with type \(I \to T\) where \(I\) is the monoidal unit and \(T\) is a rigid type, referred to as the pregroup type. Here are some examples for pregroup type assignments:
a noun is given the base type \(n\).
an adjective consumes a noun on the noun’s left to return another noun, so it is given the type \(n\cdot n^l\).
a transitive verb consumes a noun on its left and another noun on its right to give a sentence, so is given the type \(n^r \cdot s \cdot n^l\).
In the context of pregroups, the adjoints \(n^l\) and \(n^r\) can be thought of as the left and right inverses of a type \(n\) respectively. In a pregroup derivation, the words are concatenated using the monoidal product \(\otimes\) and linked using cups, which are special morphisms that exist in any rigid category. A sentence is grammatically sound if its derivation has a single uncontracted sentence wire.
In DisCoPy, words are defined using the Word
class. A Word
is just a Box
where the input type is fixed to be the monoidal unit \(I\) (or Ty()
). A pregroup derivation diagram can be drawn using either the monoidal.Diagram.draw()
method or the lambeq.pregroups.draw()
method.
[1]:
from discopy.grammar.pregroup import Cap, Cup, Id, Ty, Word
from lambeq import pregroups
n, s = Ty('n'), Ty('s')
words = [
Word('she', n),
Word('goes', n.r @ s @ n.l),
Word('home', n)
]
cups = Cup(n, n.r) @ Id(s) @ Cup(n.l, n)
assert Id().tensor(*words) == words[0] @ words[1] @ words[2]
assert Ty().tensor(*[n.r, s, n.l]) == n.r @ s @ n.l
diagram = Id().tensor(*words) >> cups
pregroups.draw(diagram)

Note
In lambeq
, method create_pregroup_diagram()
provides an alternative, more compact way to create pregroup diagrams, by explicitly defining a list of cups and swaps. For example, the above diagram can be also generated using the following code:
from lambeq import create_pregroup_diagram
from discopy.grammar.pregroup import Ty
words = [Word('she', n), Word('goes', n.r @ s @ n.l), Word('home', n)]
morphisms = [(Cup, 0, 1), (Cup, 3, 4)]
diagram = create_pregroup_diagram(words, Ty('s'), morphisms)
where the numbers in morphisms
define the indices of the corresponding wires at the top of the diagram
(n @ n.r @ s @ n.l @ n)
. In contrast, the .cup()
method in DisCoPy can be used to define
diagrams using chaining, and uses indices at the bottom of the diagram:
words = Word('she', n) @ Word('goes', n.r @ s @ n.l) @ Word('home', n)
same_diagram = words.cup(0, 1).cup(1, 2)
The .cup()
method can be applied to non-adjacent qubits, which implicitly introduces swaps. For example:
n, s, p = map(Ty, "nsp")
words = Word('A', n @ p) @ Word('V', n.r @ s @ n.l) @ Word('B', p.r @ n)
words.cup(1, 5).cup(0, 1).cup(1, 2).draw()

Note that only diagrams of the form word @ ... @ word >> cups_and_swaps
can be drawn using lambeq.pregroups.draw()
. Applying functors or normal forms will often cause the diagram to deviate from this form, in which case monoidal.Diagram.draw()
should be used.
[2]:
from discopy.drawing import Equation
from discopy import monoidal
from pytest import raises
# In the original diagram, words appear before the cups
print('Before normal form:', ', '.join(map(str, diagram.boxes)))
diagram_nf = monoidal.Diagram.normal_form(diagram)
print('After normal form:', ', '.join(map(str, diagram_nf.boxes)))
Equation(diagram, diagram_nf, symbol='->').draw(figsize=(10, 4))
# In the normalised diagram, boxes are not in the right order
# anymore, so cannot be drawn using pregroups.draw()
with raises(ValueError):
pregroups.draw(diagram_nf)
Before normal form: she, goes, home, Cup(n, n.r), Cup(n.l, n)
After normal form: she, goes, Cup(n, n.r), home, Cup(n.l, n)

In the example above, the application of normal form to the diagram introduces a cup before the word “home”, so the normalised version cannot be drawn with lambeq.pregroups.draw()
anymore.
Functors
Given monoidal categories \(\mathcal{C}\) and \(\mathcal{D}\), a monoidal functor \(F: \mathcal{C} \to \mathcal{D}\) satisfies the following properties:
monoidal structure of objects is preserved: \(F(A \otimes B) = F(A) \otimes F(B)\)
adjoints are preserved: \(F(A^l) = F(A)^l\), \(F(A^r) = F(A)^r\)
monoidal structure of morphism is preserved: \(F(g \otimes f) = F(g) \otimes F(f)\)
compositonal structure of morphisms is preserved: \(F(g \circ f) = F(g) \circ F(f)\)
Put simply, a functor is a structure-preserving transformation. In a free monoidal category, applying a functor to a diagram amounts to simply providing a mapping for each generating object and morphism. In DisCoPy, a functor is defined by passing mappings (dictionaries or functions) as arguments ob
and ar
to the Functor
class.
Functors are one of the most powerful concepts in category theory. In fact, the encoding, rewriting and parameterisation steps of lambeq
’s pipeline are implemented individually as functors, resulting in an overall functorial transformation from parse trees to tensor networks and circuits. More specifically:
lambeq.CCGParser
uses a functor to transform a biclosed CCG diagram to a pregroup diagram [YK2021].lambeq.Rewriter
functorially transforms a pregroup diagram to a simpler pregroup diagram.lambeq.TensorAnsatz
functorially transforms a pregroup diagram to a tensor diagram, which can be evaluated as a tensor network using NumPy, JAX or PyTorch.lambeq.CircuitAnsatz
functorially transforms a pregroup diagram to a quantum circuit, for evaluation on a quantum device.
Below we present two examples of functors, implemented in DisCoPy.
Example 1: “Very” functor
This functor adds the word “very” in front of every adjective in a DisCoCat diagram.
Since the mapping is from a pregroup.Diagram
to another pregroup.Diagram
, a pregroup.Functor
should be used. Further, the word “very” modifies an adjective to return another adjective, so it should have type
\((n \otimes n^l) \otimes (n \otimes n^l)^l = n \otimes n^l \otimes n^{ll} \otimes n^l\).
[3]:
from lambeq import BobcatParser
parser = BobcatParser(verbose='suppress')
[4]:
from discopy.grammar.pregroup import Diagram, Functor
# determiners have the same type as adjectives
# but we shouldn't add 'very' behind them
determiners = ['a', 'the', 'my', 'his', 'her', 'their']
# type for an adjective
adj = n @ n.l
very = Word('very', adj @ adj.l)
cups = Diagram.cups(adj.l, adj)
def very_ob(ty):
return ty
def very_ar(box):
if box != very:
if box.name not in determiners:
if box.cod == adj:
return very @ box >> Id(adj) @ cups
return box
very_functor = Functor(ob=very_ob, ar=very_ar)
diagram = parser.sentence2diagram('a big bad wolf')
new_diagram = very_functor(diagram)
Equation(diagram, new_diagram, symbol='->').draw(figsize=(10, 4))

Example 2: Twist functor
In this functor, cups and caps are treated specially and are not passed to the ar
function; instead they are passed to methods Functor.cod.cups(left, right)
and Functor.cod.caps(left, right)
, respectively. By default, the pregroup.Functor
uses pregroup.Category
as cod
to implement the factory pattern, with methods such as cups()
, caps()
, id()
, and swap()
. For example, for a functor F
that uses the default arrow factory Diagram
, F(Cup(a, a.r)) == Diagram.cups(F(a), F(a.r))
.
Here is an example of how to map a cup to a custom diagram, such as a “twisted” cup. Note that it is up to the user to ensure the new cups and caps satisfy the snake equations.
[5]:
from discopy.grammar.pregroup import Category, Diagram, factory, Functor, Swap
@factory
class TwistedDiagram(Diagram):
@classmethod
def cups(cls, left, right):
swaps = Diagram.swap.__func__(cls, left, right)
cups = Diagram.cups.__func__(cls, right, left)
return swaps >> cups
@classmethod
def caps(cls, left, right):
return cls.cups(left, right).dagger()
class TwistedWord(Word, TwistedDiagram): ...
class TwistedSwap(Swap, TwistedDiagram): ...
class TwistedCup(Cup, TwistedDiagram): ...
class TwistedCap(Cap, TwistedDiagram): ...
TwistedDiagram.braid_factory = TwistedSwap
TwistedDiagram.cup_factory = TwistedCup
TwistedDiagram.cap_factory = TwistedCap
twist_functor = Functor(
ob=lambda ty: ty,
ar=lambda word: TwistedWord(word.name, word.cod),
cod=Category(Ty, TwistedDiagram))
diagram = parser.sentence2diagram('This is twisted')
new_diagram = twist_functor(diagram)
pregroups.draw(diagram)
pregroups.draw(new_diagram)
snake = Id(n) @ Cap(n.r, n) >> Cup(n, n.r) @ Id(n)
Equation(twist_functor(snake), Id(n)).draw(figsize=(4, 2))



Note
Twisting the nested cups for “is” and “twisted” together is not a functorial operation, so it cannot be implemented using a rigid.Functor
.
Classical DisCoCat: Tensor networks
The classical version of DisCoCat sends diagrams in the category of pregroup derivations to tensors in the category of vector spaces FVect. FVect is a monoidal category with vector spaces (e.g. \(\mathbb{R}^2 \otimes \mathbb{R}^2\)) as objects and linear maps between vector spaces as morphisms. It is in fact a compact closed category, which is a special case of rigid categories where \(A^l = A^r = A^*\).
Using the discopy.tensor
module, you can define a free category of vector spaces: objects are defined with the Dim
class and morphisms with the Box
class. Composite morphisms are constructed by freely combining the generating morphisms using the <<
and >>
operators. This is similar to how rigid.Diagram
s and monoidal.Diagram
s are defined. The concrete value of the tensor is passed to the data
attribute as an unshaped list; DisCoPy will reshape it later based on the input and output dimensions.
Apart of diagrams, the discopy.tensor
module has another class that can be tensored and composed: tensor.Tensor
. The key difference is that tensor.Box
es in the diagrams compose together to make tensor.Diagram
s, while tensor.Tensor
s compose together to make another tensor.Tensor
. In other words, tensor.Tensor
computes tensor contractions directly, while tensor.Diagram
delays the computation until eval()
is called.
[6]:
from discopy.tensor import Box, Dim, Id, Tensor
# Dim(1) is the unit object, so disappears when tensored with another Dim
print(f'{Dim(1) @ Dim(2) @ Dim(3)=}')
Dim(1) @ Dim(2) @ Dim(3)=Dim(2, 3)
[7]:
id_box = Box('Id Box', Dim(2), Dim(2), data=[1,0,0,1])
id_tensor = Tensor([1,0,0,1], Dim(2), Dim(2))
# the actual values of id_box and id_tensor are equal
assert (id_box.array == id_tensor.array).all()
print(f'{id_box.eval()=}')
id_box.eval()=Tensor[int]([1, 0, 0, 1], dom=Dim(2), cod=Dim(2))
[8]:
import numpy as np
f_box = Box('f Box', Dim(2, 2), Dim(2), data=range(8))
f_tensor = Tensor(range(8), Dim(2, 2), Dim(2))
combined_diagram = id_box @ Id(Dim(2)) >> f_box
combined_tensor = id_tensor @ Tensor.id(Dim(2)) >> f_tensor
# tensor diagram evaluates to the tensor
assert combined_diagram.eval(dtype=np.int64) == combined_tensor
combined_diagram.draw(figsize=(4, 2))
print(combined_tensor)

Tensor[int64]([0, 1, 2, 3, 4, 5, 6, 7], dom=Dim(2, 2), cod=Dim(2))
In the category of vector spaces, cups, caps and swaps take on concrete values as tensors.
[9]:
Tensor.cups(Dim(3), Dim(3)).array
[9]:
array([[1, 0, 0],
[0, 1, 0],
[0, 0, 1]])
[10]:
Tensor.swap(Dim(2), Dim(2)).array
[10]:
array([[[[1, 0],
[0, 0]],
[[0, 0],
[1, 0]]],
[[[0, 1],
[0, 0]],
[[0, 0],
[0, 1]]]])
To implement a functor from pregroup.Diagram
to tensor.Tensor
, use a tensor.Functor
with dom = pregroup.Category()
. This functor will automatically contract the resulting tensor network.
[11]:
from discopy.tensor import Functor
import numpy as np
def one_ob(ty):
dims = [2] * len(ty)
return Dim(*dims) # does Dim(2,2,..)
def one_ar(box):
dom = one_ob(box.dom)
cod = one_ob(box.cod)
tensor = np.ones((dom @ cod).inside)
print(f'"{box}" becomes')
print(tensor)
return tensor
one_functor = Functor(
ob=one_ob, ar=one_ar, dom=Category())
one_functor(diagram)
"This" becomes
[1. 1.]
"is" becomes
[[[[1. 1.]
[1. 1.]]
[[1. 1.]
[1. 1.]]]
[[[1. 1.]
[1. 1.]]
[[1. 1.]
[1. 1.]]]]
"twisted" becomes
[[1. 1.]
[1. 1.]]
[11]:
Tensor[int]([8, 8], dom=Dim(1), cod=Dim(2))
Sometimes, defining a functor from pregroup.Diagram
to tensor.Diagram
offers more flexibility, since a tensor.Diagram
can be instantiated with concrete values to be evaluated later using a custom tensor contractor. Such a functor can be defined using a pregroup.Functor
with tensor.Dim
and tensor.Diagram
as cod
, respectively. See the implementation of TensorAnsatz
for an example.
Quantum DisCoCat: Quantum circuits
The quantum version of DisCoCat sends diagrams in the category of pregroup derivations to circuits in the category of Hilbert spaces FHilb. This is a compact closed monoidal category with Hilbert spaces (e.g. \(\mathbb{C}^{2^n}\)) as objects and unitary maps between Hilbert spaces as morphisms.
The discopy.quantum
module is a framework for the free category of quantum circuits: objects are generated using the quantum.circuit.Ob
class and morphisms by using quantum.gates
. In DisCoPy, rotation values range from \(0\) to \(1\) rather than from \(0\) to \(2\pi\). The circuit can then either be evaluated using tensor contraction with the eval()
method, or exported to pytket using the to_tk()
method, which supports multiple hardware backends.
[12]:
from discopy.quantum import qubit, Id
from discopy.quantum.gates import CX, Rz, X
circuit = Id(4)
circuit >>= Id(1) @ CX @ X
circuit >>= CX @ CX
circuit >>= Rz(0.1) @ Rz(0.2) @ Rz(0.3) @ Rz(0.4)
# from discopy 0.4.1, can do:
same_circuit = (Id(4).CX(1, 2).X(3).CX(0, 1).CX(2, 3)
.Rz(0.1, 0).Rz(0.2, 1).Rz(0.3, 2).Rz(0.4, 3))
assert circuit == same_circuit
circuit.draw()
circuit.to_tk()

[12]:
tk.Circuit(4).CX(1, 2).X(3).CX(0, 1).CX(2, 3).Rz(0.2, 0).Rz(0.4, 1).Rz(0.6, 2).Rz(0.8, 3)
To apply multi-qubit gates to non-consecutive qubits, use swaps to permute the wires, apply the gate, then unpermute the wires. These swaps are only logical swaps and do not result in more gates when converted to tket format.
[13]:
from discopy.quantum import Circuit
from discopy.quantum.gates import SWAP
# to apply a CNOT on qubits 2 and 0:
circuit1 = Id(3)
circuit1 >>= SWAP @ Id(1)
circuit1 >>= Id(1) @ SWAP
circuit1 >>= Id(1) @ CX
circuit1 >>= Id(1) @ SWAP
circuit1 >>= SWAP @ Id(1)
# or you can do
perm = Circuit.permutation([2, 0, 1])
circuit2 = perm[::-1] >> Id(1) @ CX >> perm
assert circuit1 == circuit2
circuit1.draw(figsize=(3, 3))
# no swaps introduced when converting to tket
circuit1.to_tk()

[13]:
tk.Circuit(3).CX(2, 0)
Since discopy 0.4.0, we have long-ranged controlled gates.
[14]:
from discopy.quantum import Controlled, Rz, X
(Controlled(Rz(0.5), distance=2) >> Controlled(X, distance=-2)).draw(figsize=(3, 2))
Controlled(Controlled(X), distance=2).draw(figsize=(3, 2))


So far, our circuits have been “pure” circuits, consisting of unitaries. Pure circuits can be evaluated locally to return a Tensor
. Circuits containing Discard
s and Measure
s are considered “mixed”, and return Channel
s instead of Tensor
s when evaluated, as they are not unitaries but rather classical-quantum maps (for more details, see Chapter 5 in [HV2013]).
[15]:
from discopy.quantum import Discard, Measure, Ket, Bra
from discopy.quantum.channel import C, Q
print(C(Dim(2)) @ Q(Dim(2, 3)) @ C(Dim(2)))
print(Discard().eval())
print(Measure().eval())
print(Ket(0).eval())
# circuits that have measurements in them are no longer unitary
# and return CQMaps
print((Ket(0) >> Measure()).eval())
C(Dim(2, 2)) @ Q(Dim(2, 3))
Channel([1.+0.j, 0.+0.j, 0.+0.j, 1.+0.j], dom=Q(Dim(2)), cod=CQ())
Channel([1.+0.j, 0.+0.j, 0.+0.j, 0.+0.j, 0.+0.j, 0.+0.j, 0.+0.j, 1.+0.j], dom=Q(Dim(2)), cod=Q(Dim(2)))
Tensor[complex]([1.+0.j, 0.+0.j], dom=Dim(1), cod=Dim(2))
Channel([1.+0.j, 0.+0.j], dom=CQ(), cod=Q(Dim(2)))
Pure circuits can be coerced to evaluate into a CQMap
by setting mixed=True
.
[16]:
CX.eval(mixed=True)
[16]:
Channel([1.+0.j, 0.+0.j, 0.+0.j, ..., 0.+0.j, 0.+0.j, 0.+0.j], dom=Q(Dim(2, 2)), cod=Q(Dim(2, 2)))
Note that the tensor order of CQMap
s is doubled, compared to that of simple Tensor
s:
[17]:
print(CX.eval().array.shape)
print(CX.eval(mixed=True).array.shape)
(2, 2, 2, 2)
(2, 2, 2, 2, 2, 2, 2, 2)
To implement a functor from rigid.Diagram
to quantum.Circuit
, use a quantum.circuit.Functor
.
[18]:
from discopy.quantum.circuit import Functor, Id
def cnot_ob(ty):
# this implicitly maps all rigid types to 1 qubit
return qubit ** len(ty)
def cnot_ar(box):
dom = len(box.dom)
cod = len(box.cod)
width = max(dom, cod)
circuit = Id(width)
for i in range(width - 1):
circuit >>= Id(i) @ CX @ Id(width - i - 2)
# Add Bras (post-selection) and Kets (states)
# to get a circuit with the right amount of
# input and output wires
if cod <= dom:
circuit >>= Id(cod) @ Bra(*[0]*(dom - cod))
else:
circuit <<= Id(dom) @ Ket(*[0]*(cod - dom))
return circuit
cnot_functor = Functor(ob=cnot_ob, ar=cnot_ar, dom=Category())
diagram.draw()
cnot_functor(diagram).draw(figsize=(8, 8))

