Unification, reification and miniKanren (optional)#

Note

The logical-unification, kanren, etuples and cons packages are optional dependencies. PyTensor’s built-in PatternNodeRewriter ships its own specialized matcher and does not require any of them. Install the extra explicitly to use the tools described on this page:

pip install pytensor[kanren]

or:

pip install logical-unification kanren etuples cons

Importing pytensor.graph.rewriting.kanren registers the dispatchers that let unification.unify() / unification.reify() and miniKanren relations walk PyTensor Apply nodes, Ops and Types. It also registers PatternVar with the unification.Var ABC, so isinstance(x, unification.Var) and isvar(x)() keep returning True for PyTensor pattern variables. Make sure that import happens once before calling unify(), reify() or kanren.run() on PyTensor graphs:

import pytensor.graph.rewriting.kanren  # noqa: F401  -- registers dispatchers

Unification and reification#

Unification and reification implement a more succinct and reusable form of “pattern matching and replacement”. Use of the unification and reification tools is preferable when a rewrite’s matching and replacement are non-trivial, so we will briefly explain them in the following.

PyTensor’s unification and reification tools are provided by the logical-unification package. The basic tools are unify(), reify(), and var. The class var construct logic variables, which represent the elements to be unified/matched, unify() performs the “matching”, and reify() performs the “replacements”.

See unification’s documentation for an introduction to unification and reification.

In order to use unify() and reify() with PyTensor graphs, we need an intermediate structure that will allow us to represent PyTensor graphs that contain vars, because PyTensor Ops and Apply nodes will not accept these foreign objects as inputs. The etuples library provides the ExpressionTuple (tuple-like, with caching for evaluation) that fills this role.

Here is an illustration of all the above components used together:

>>> import pytensor.graph.rewriting.kanren  # noqa: F401  -- registers dispatchers
>>> from unification import unify, reify, var
>>> from etuples import etuple
>>> y_lv = var()  # Create a logic variable
>>> y_lv
~_1
>>> s = unify(add(x, y), etuple(add, x, y_lv))
>>> s
{~_1: y}

In this example, unify() matched the PyTensor graph in the first argument with the “pattern” given by the etuple() in the second. The result is a dict mapping logic variables to the objects to which they were successfully unified. When a unify() doesn’t succeed, it will return False.

reify() uses dicts like the kind produced by unify() to replace logic variables within structures:

>>> res = reify(etuple(add, y_lv, y_lv), s)
>>> res
e(<pytensor.scalar.basic.Add at 0x7f54dfa5a350>, y, y)

Since ExpressionTuples can be evaluated, we can produce a complete PyTensor graph from these results as follows:

>>> res.evaled_obj
add.0
>>> pytensor.dprint(res.evaled_obj)
add [id A] ''
 |y [id B]
 |y [id B]

Because ExpressionTuples effectively model S-expressions, they can be used with the cons package to unify and reify graphs structurally.

Let’s say we want to match graphs that use the addOp but could have a varying number of arguments:

>>> from cons import cons
>>> op_lv = var()
>>> args_lv = var()
>>> s = unify(cons(op_lv, args_lv), add(x, y))
>>> s
{~_2: <pytensor.scalar.basic.Add at 0x7f54dfa5a350>, ~_3: e(x, y)}
>>> s = unify(cons(op_lv, args_lv), add(x, y, z))
>>> s
{~_2: <pytensor.scalar.basic.Add at 0x7f54dfa5a350>, ~_3: e(x, y, z)}

From here, we can check s[op_lv] == add to confirm that we have the correct Op and proceed with our rewrite.

>>> res = reify(cons(mul, args_lv), s)
>>> res
e(<pytensor.scalar.basic.Mul at 0x7f54dfa5ae10>, x, y, z)
>>> pytensor.dprint(res.evaled_obj)
mul [id A] ''
 |x [id B]
 |y [id C]
 |z [id D]

miniKanren#

Given that unification and reification are fully implemented for PyTensor objects via the unification package, the kanren package can be used with PyTensor graphs, as well. kanren implements the miniKanren domain-specific language for relational programming.

Refer to the links above for a proper introduction to miniKanren, but suffice it to say that miniKanren orchestrates the unification and reification operations described above, and it does so in the context of relational operators (e.g. equations like \(x + x = 2 x\)). This means that a relation that–say–represents \(x + x = 2 x\) can be utilized in both directions.

Currently, the node rewriter KanrenRelationSub provides a means of turning kanren relations into NodeRewriters; however, kanren can always be used directly from within a custom Rewriter, so KanrenRelationSub is not necessary.

The following is an example that distributes dot products across additions.

import pytensor
import pytensor.tensor as pt
from pytensor.graph.rewriting.kanren import KanrenRelationSub
from pytensor.graph.rewriting.basic import EquilibriumGraphRewriter
from pytensor.graph.rewriting.utils import rewrite_graph
from pytensor.tensor.math import _dot
from etuples import etuple
from kanren import conso, eq, fact, heado, tailo
from kanren.assoccomm import assoc_flatten, associative
from kanren.core import lall
from kanren.graph import mapo
from unification import vars as lvars


# Make the graph pretty printing results a little more readable
pytensor.pprint.assign(
    _dot, pytensor.printing.OperatorPrinter("@", -1, "left")
)

# Tell `kanren` that `add` is associative
fact(associative, pt.add)


def dot_distributeo(in_lv, out_lv):
    """A `kanren` goal constructor relation for the relation ``A.dot(a + b ...) == A.dot(a) + A.dot(b) ...``."""
    A_lv, add_term_lv, add_cdr_lv, dot_cdr_lv, add_flat_lv = lvars(5)

    return lall(
        # Make sure the input is a `_dot`
        eq(in_lv, etuple(_dot, A_lv, add_term_lv)),
        # Make sure the term being `_dot`ed is an `add`
        heado(pt.add, add_term_lv),
        # Flatten the associative pairings of `add` operations
        assoc_flatten(add_term_lv, add_flat_lv),
        # Get the flattened `add` arguments
        tailo(add_cdr_lv, add_flat_lv),
        # Add all the `_dot`ed arguments and set the output
        conso(pt.add, dot_cdr_lv, out_lv),
        # Apply the `_dot` to all the flattened `add` arguments
        mapo(lambda x, y: conso(_dot, etuple(A_lv, x), y), add_cdr_lv, dot_cdr_lv),
    )


dot_distribute_rewrite = EquilibriumGraphRewriter([KanrenRelationSub(dot_distributeo)], max_use_ratio=10)

Below, we apply dot_distribute_rewrite to a few example graphs. First we create simple test graph:

>>> x_at = pt.vector("x")
>>> y_at = pt.vector("y")
>>> A_at = pt.matrix("A")
>>> test_at = A_pt.dot(x_at + y_at)
>>> print(pytensor.pprint(test_at))
(A @ (x + y))

Next we apply the rewrite to the graph:

>>> res = rewrite_graph(test_at, include=[], custom_rewrite=dot_distribute_rewrite, clone=False)
>>> print(pytensor.pprint(res))
((A @ x) + (A @ y))

We see that the dot product has been distributed, as desired. Now, let’s try a few more test cases:

>>> z_at = pt.vector("z")
>>> w_at = pt.vector("w")
>>> test_at = A_pt.dot((x_at + y_at) + (z_at + w_at))
>>> print(pytensor.pprint(test_at))
(A @ ((x + y) + (z + w)))
>>> res = rewrite_graph(test_at, include=[], custom_rewrite=dot_distribute_rewrite, clone=False)
>>> print(pytensor.pprint(res))
(((A @ x) + (A @ y)) + ((A @ z) + (A @ w)))
>>> B_at = pt.matrix("B")
>>> w_at = pt.vector("w")
>>> test_at = A_pt.dot(x_at + (y_at + B_pt.dot(z_at + w_at)))
>>> print(pytensor.pprint(test_at))
(A @ (x + (y + ((B @ z) + (B @ w)))))
>>> res = rewrite_graph(test_at, include=[], custom_rewrite=dot_distribute_rewrite, clone=False)
>>> print(pytensor.pprint(res))
((A @ x) + ((A @ y) + ((A @ (B @ z)) + (A @ (B @ w)))))

This example demonstrates how non-trivial matching and replacement logic can be neatly expressed in miniKanren’s DSL, but it doesn’t quite demonstrate miniKanren’s relational properties.

To do that, we will create another Rewriter that simply reverses the arguments to the relation dot_distributeo() and apply it to the distributed result in res:

>>> dot_gather_rewrite = EquilibriumGraphRewriter([KanrenRelationSub(lambda x, y: dot_distributeo(y, x))], max_use_ratio=10)
>>> rev_res = rewrite_graph(res, include=[], custom_rewrite=dot_gather_rewrite, clone=False)
>>> print(pytensor.pprint(rev_res))
(A @ (x + (y + (B @ (z + w)))))

As we can see, the kanren relation works both ways, just like the underlying mathematical relation does.

miniKanren relations can be used to explore rewrites of graphs in sophisticated ways. It also provides a framework that more directly maps to the mathematical identities that drive graph rewrites. For some simple examples of relational graph rewriting in kanren see here. For a high-level overview of miniKanren’s use as a tool for symbolic computation see “miniKanren as a Tool for Symbolic Computation in Python”.