Expression example

Expression is a fundamental type in DynamicExpressions that represents a mathematical expression as a tree structure. It combines an AbstractExpressionNode (typically a Node) with metadata like operators and variable names.

using DynamicExpressions, Random

First, let's define our operators and variable names:

operators = OperatorEnum(;
    binary_operators=(+, -, *, /), unary_operators=(sin, cos, exp)
)
OperatorEnum{Tuple{typeof(+), typeof(-), typeof(*), typeof(/)}, Tuple{typeof(sin), typeof(cos), typeof(exp)}}((+, -, *, /), (sin, cos, exp))
variable_names = ["x", "y"]
2-element Vector{String}:
 "x"
 "y"

Now, let's create an Expression manually:

x = Node{Float64}(; feature=1)
x_expr = Expression(x; operators, variable_names)
x

We can build up more complex expressions using these basic building blocks:

y = Node{Float64}(; feature=2)
c = Node{Float64}(; val=2.0)
complex_node = Node(; op=3, l=x, r=Node(; op=1, l=y, r=c))
x1 * (x2 + 2.0)

where the 3 indicates * and 1 indicates +.

complex_expr = Expression(complex_node; operators, variable_names)
x * (y + 2.0)

This expression includes its own metadata: the operators and variable names, and so there are no scope issues as with raw AbstractExpressionNode types which depend on the last-used metadata for convenience functions like printing. In other words, you can print this expression, or evaluate it, directly:

rng = Random.MersenneTwister(0)
complex_expr(randn(rng, 2, 5))
5-element Vector{Float64}:
  1.9207966001371324
 -0.6584103858505296
  1.3476564362750507
  0.12281468518647647
 -1.837957730208208

While creating expressions manually is faster, and should be preferred within packages, it can be cumbersome for quickly writing more complex expressions. DynamicExpressions provides a more convenient way to create expressions using the parse_expression function, which directly parses a Julia object:

parsed_expr = parse_expression(
    :(sin(2.0 * x + exp(y + 5.0))); operators=operators, variable_names=variable_names
)
sin((2.0 * x) + exp(y + 5.0))

We can convert an expression into the primitive AbstractExpressionNode type with get_tree:

tree = get_tree(parsed_expr)
sin((2.0 * x1) + exp(x2 + 5.0))

Some AbstractExpression types may choose to store their expression in a different way than simply saving it as one of the fields. For any expression, you can get the raw contents with get_contents:

get_contents(parsed_expr)
sin((2.0 * x1) + exp(x2 + 5.0))

Similarly, you can get the metadata for an expression with get_metadata:

get_metadata(parsed_expr)
Metadata((operators = OperatorEnum{Tuple{typeof(+), typeof(-), typeof(*), typeof(/)}, Tuple{typeof(sin), typeof(cos), typeof(exp)}}((+, -, *, /), (sin, cos, exp)), variable_names = ["x", "y"]))

These can be used with with_contents and with_metadata to create new expressions based on the original:

with_contents(parsed_expr, Node(; op=2, l=get_contents(parsed_expr)))
cos(sin((2.0 * x) + exp(y + 5.0)))

Expression objects support various operations defined on regular trees, which permits us to overload specific methods with modified behavior. For example, we can count the number of nodes, which simply forwards to the method as it is defined on Node:

node_count = count_nodes(parsed_expr)
println("Number of nodes: $node_count")
Number of nodes: 9

The [tree_mapreduce] will by default call get_tree to get the tree, so it can be used with any expression type that overloads this method. For example, we can compute the depth of a tree:

tree_mapreduce(
    leaf -> 1, branch -> 1, (parent, child...) -> parent + max(child...), parsed_expr
)
5

We can also perform more complex operations, like simplification:

complex_expr = parse_expression(
    :((2.0 + x) + 3.0); operators=operators, variable_names=["x"]
)
simplified_expr = combine_operators(copy(complex_expr))
println("Original: ", complex_expr)
println("Simplified: ", simplified_expr)
Original: (2.0 + x) + 3.0
Simplified: x + 5.0

AbstractExpression types also have many operators in Base defined, which will automatically look up the matching index in the stored OperatorEnum. This means we can combine expressions like so:

xs = [Expression(Node{Float64}(; feature=i); operators, variable_names) for i in 1:5]

xs[1] + xs[2]
x + y

These have the same type – they simply combine their AbstractExpressionNode objects and ensure the metadata is the same.

typeof(xs[1] + xs[2])
Expression{Float64, Node{Float64}, @NamedTuple{operators::OperatorEnum{Tuple{typeof(+), typeof(-), typeof(*), typeof(/)}, Tuple{typeof(sin), typeof(cos), typeof(exp)}}, variable_names::Vector{String}}}

This gives us an easy way to quickly construct expressions with minimal memory overhead, and fast evaluation speed:

ex = xs[1] * 2.1 - exp(3 * xs[2])
(x * 2.1) - exp(3.0 * y)

Evaluation:

X = randn(rng, 5, 2)
ex(X)
2-element Vector{Float64}:
 -1.5481324250439796
 -0.40195516191437586

Or, if we have loaded Zygote, we can differentiate with respect to the variables:

using Zygote
ex'(X)
5×2 Matrix{Float64}:
  2.1        2.1
 -0.304282  -0.0241576
  0.0        0.0
  0.0        0.0
  0.0        0.0

Or the constants of the expression:

ex'(X; variable=Val(false))
2×2 Matrix{Float64}:
 -0.688907   -0.187573
  0.0773693   0.0129425

Which can be used for optimization.


This page was generated using Literate.jl.