Categorical Combinators for Graphviz in Python
Graphviz is a graph visualization tool https://www.graphviz.org/. In Conal Elliott’s Compiling to categories http://conal.net/papers/compiling-to-categories/, compiling code to its corresponding graphviz representation was one very compelling example. These graphs are very similar to the corresponding string diagram of the monoidal category expression, but they also look like boolean circuit diagrams. Besides in Conal Elliott’s Haskell implementation, there is an implementation in the Julia Catlab.jl project https://epatters.github.io/Catlab.jl/stable/
I’ve basically implemented a toy version of a similar thing in python. It lets you do things like this
plus = GraphCat.block("+", ["a","b"], ["c"])
I = GraphCat.idd()
p1 = plus
p2 = p1 @ (p1 * p1)
p3 = p1 @ (p2 * p2)
p4 = p1 @ (p3 * p3)
d = p4.run()
d.format = "png"
d.render("adders")
Why python?
- Python is the lingua franca of computing these days. Many people encounter it, even people whose main thing isn’t computers.
- The python ecosystem is nutso good.
- Julia is kind of an uphill battle for me. I’m fighting the battle ( https://github.com/philzook58/Rel.jl ) , but I already know python pretty well. I can rip this out and move on.
What I did was implement some wrappers around the graphviz python package. It exposes a not very feature rich stateful interface. It is pretty nice that it prints the graphs inline in jupyter notebooks though.
The code is written in a style very similar (and hopefully overloadable with) to that of z3py relation algebra. http://www.philipzucker.com/a-sketch-of-categorical-relation-algebra-combinators-in-z3py/ . There is a fairly general cookbook method here for categorifying dsl. Since graphviz does not directly expose fresh node creation as far as I can tell, I made my own using a random number generator. The actual combinators are graphviz object processors, so we build up a giant functional chain of processors and then actually execute it with run
. The inputs and outputs are represented by lists of node names. The is some design space to consider here.
I also had to use class based wrappers Based on the precedent set by the python 3 matrix multiplication operator @, I think it is a requirement that this also be used for category composition. id
is a keyword or something in python unfortunately. For monoidal product, I feel like overloading power ** looks nice even if it is a nonsensical analogy, * is also not too bad. I went with * for now.
The graphviz graphs aren’t quite string diagrams. They make no promise to preserve the ordering of your operations, but they seem to tend to.
import random
import graphviz
class GraphCat():
GC = GraphCat
def __init__(self,res): # just hold the graphviz processor function
self.res = res
def fresh(n): # makes random numbers for fresh node labels
return list([str(random.randint(0,1e9)) for i in range(n)])
def idd(): # identity morhism. 1 input 1 output.
def res(g):
#nodes = GC.fresh(n)
node = GC.fresh(1)[0]
#for node in nodes:
g.node(node, shape="point")
return [node], [node]
return GraphCat(res)
def compose(g, f): # compose morphisms. Makes graphviz edges between node labels generated by g and f
f = f.res
g = g.res
def res(G):
a, b = f(G)
b1, c = g(G)
assert(len(b) == len(b1))
G.edges(zip(b,b1))
return a, c
return GraphCat(res)
def par(f, g): #monoidal product. Puts f and g in parallel
f = f.res
g = g.res
def res(G):
a, b = f(G)
c, d = g(G)
return a + c, b + d
return GraphCat(res)
def dump(): # dump deletes this edge with an empty circle
def res(G):
node = GC.fresh(1)[0]
G.node(node, shape="point", fillcolor="white")
return [node], []
return GraphCat(res)
def dup(): # duplicate this edge
def res(G):
node = GC.fresh(1)[0]
G.node(node, shape="point", fillcolor="green")
return [node], [node, node]
return GraphCat(res)
def converse(f): # turn input to output of this combinator
f = f.res
def res(G):
a, b = f(G)
return b, a
return GraphCat(res)
def named_simple(name): # a simple labeled 1 input 1 output object
def res(G):
node = GC.fresh(1)[0]
G.node(node,name)
return [node], [node]
return GraphCat(res)
def block(name, inputs, outputs): # an object with labelled ports
inputs_label = [f"<{inp}> {inp}" for inp in inputs]
outputs_label = [f"<{outp}> {outp}" for outp in outputs]
#return # use graphviz to build block with label and n ports
def res(G):
node = GC.fresh(1)[0]
inputs1 = [f"{node}:{inp}" for inp in inputs]
outputs1 = [f"{node}:{outp}" for outp in outputs]
grphstring = "{ {" + " | ".join(inputs_label) + "} | " + name + " | {" + "|".join(outputs_label) + "} }"
G.node(node,grphstring, shape="record")
return inputs1, outputs1
return GC(res)
def fst(): # project out first par of tuple. semnatically equal to (id * dump)
return GraphCat.block("fst", ["a","b"], ["a1"])
def snd(): # dump * id
return GraphCat.block("fst", ["a","b"], ["b1"])
def run(self): # will run the object on a fresh graphviz object
dot = graphviz.Digraph()
self.res(dot)
return dot
def __matmul__(self, rhs): # matrix multiplication is a natural analog of composition
return GC.compose(self, rhs)
def __mul__(self, rhs): # monoidal product
return GC.par(self, rhs)
def __add__(self,rhs): # hmm. What should addition be? Join?
pass
def const(label): # inject a constant in. A labelled node with 1 outport and no input
def res(G):
node = GraphCat.fresh(1)[0]
G.node(node, str(label))
return [], [node]
return GC(res)
def cup():
def res(G):
nodes = GraphCat.fresh(2)
for node in nodes:
G.node(node, shape="point")
G.edge(nodes[0],nodes[1])
return [], nodes
return GraphCat(res)
def cap():
return GraphCat.converse(GraphCat.cup())
Here’s some example usage
cup = GraphCat.cup()
cap = GraphCat.cap()
d = cap @ (I * I) @ cup #(I * cap) @ (I * I * I) @ (cup * I)
d.run()
d = plus @ (GC.const(1) * GC.const(2))
d = d.run()
GC = GraphCat
f = GraphCat.named_simple("f")
g = GraphCat.named_simple("g")
I = GraphCat.idd()
dump = GC.dump()
dup = GC.dup()
diagram = ((f * I) @ dup @ g @ (dump * I) @ (I * f) @ (f * f)) * g
diagram.run()
Class based overloading is the main paradigm of overloading in python. You overload a program into different categories, by making a program take in the appropriate category class as a parameter.
# by passing in different category classes, we can make polymorphic functions
# They have to have a uniform interface though, which is hard to constrain in python.
def polymorphic_prog(M):
idd = M.idd()
dump = M.dump()
dup = M.dup()
return (idd * dump) @ dup
polymorphic_prog(GraphCat).run()
For example something like this ought to work. Then you can get the graph of some matrix computation to go along with it’s numpy implementation
class FinVect(np.ndarray):
def compose(f,g):
return f @ g
def idd(n):
return np.eye(n)
def par(f,g):
return np.kron(f,g)
def __mult__(self,rhs):
return np.kron(f,g)
# and so on.
Maybe outputting tikz is promising? https://github.com/negrinho/sane_tikz