I’m looking for a Julia package that can rewrite symbolic expressions involving functions that don’t have a fixed number of arguments. For example, it should be easy to declare that some function f
is multilinear (= linear in each argument if all other arguments are kept fixed) without having to create a separate transformation rule for each possible case (“linear in the k
-th argument out of n
”).
The package shouldn’t call Python under the hood. I once tried out SymPy as well as pure Python and found them way too slow. Any suggestions?
SymbolicUtils.jl, which underlies Symbolics.jl, has a rewrite engine, but I cannot find support for sequence patterns in the documentation (here the sequence would be the list of arguments). This kind of task is easy using sequence patterns in Wolfram Mathematica, but I’d love to learn about a Julia solution if it exists! (I’m trying to move some of my computing tasks from Mathematica to Julia.) Here’s the Mathematica solution, just to make it clear what I meant by sequence patterns etc.
Input:
f[arguments1___, coeff_?NumericQ * variable_, arguments2___] :=
coeff * f[arguments1, variable, arguments2];
f[x, 2*y]
Output:
2 f[x, y]
Input:
f[x, 2*y, 3*z, w]
Output:
Out[3]= 6 f[x, y, z, w]
Just use Symbolics.jl with arrays.
Can you give an example? For instance, how would the rule that @greatpet has given in Mathematica above look like in Symbolics.jl? I would be interested in that because it’s exactly of the kind I’m looking for.
I’ve cooked up a solution using Symbolics.jl. The function is here, and some example usage follows,
function expand_multilinear(expr)
# check if expr is a single term before proceeding
if !isa(expr, Num) || !isa(expr.val, SymbolicUtils.Term)
return expr
end
function_name = expr.val.f
function_arguments = deepcopy(expr.val.arguments)
overall_coeff = 1
for i in 1:length(function_arguments)
# check if this function argument is a product of things
if isa(function_arguments[i], SymbolicUtils.Mul)
current_coeff = function_arguments[i].coeff
# absorb nontrivial coefficient into an overall factor
if current_coeff != 1
overall_coeff *= current_coeff
function_arguments[i] = function_arguments[i] / current_coeff
end
end
end
overall_coeff * function_name(function_arguments...)
end
Example usage:
julia> using Symbolics
julia> @variables x,y,z,g(..);
julia> expand_multilinear(g(x, 2*y, 3*z))
6g(x, y, z)
julia> expand_multilinear(g(1/x, 2*y*z))
2g(x^-1, y*z)
The main limitation is that this only works for a single term. It gives up if you run e.g. expand_multilinear(g(2*x,y) + g(3*z, y))
. If you have a general expression, presumably you can look at all sub-expressions (i.e. visiting tree nodes of the interal representation) and apply the above function, but I know far too little about Symbolics.jl to figure out how to do this efficiently.
Thanks a lot! Although the code doesn’t quite reach the elegance of Mathematica’s rule-based approach …
It seems that such patters exist under the name of segment patterns, see this part of the documentation. Example:
using SymbolicUtils
@syms x y z
r = @rule(+(~x,~~y) => +(~~y...))
r(x+y+z) # gives y+z
This could be the basis of a very clean solution without writing as much code as in the function expand_multilinear
above. However, while arithmetic operators like +
accept a variable number of arguments, I don’t know if it’s possible to define a new symbolic function with this property. Does anybody else know?
UPDATE: The following seems to work:
using SymbolicUtils
@syms w x y z
f = SymbolicUtils.Sym{(SymbolicUtils.FnType){Tuple, Number}}(:f)
r = @rule(f(~~a, ~x + ~y, ~~b) => f(~~a..., ~x, ~~b...) + f(~~a..., ~y, ~~b...))
r( f(x+y) ) # f(x) + f(y)
r( f(x+y, z) ) # f(x, z) + f(y, z)
r( f(w, x+y, z) ) # f(w, x, z) + f(w, y, z)
The crucial point is that by defining f
without the help of @syms
, one can specify a general Tuple
type instead of one with a specific number of parameters (as in Tuple{Number,Number}
). (I’ve got this idea from the @variables f(..)
macro of Symbolics.jl.)
Nicely done, but on further testing, your code returns nothing
when you call it with 3 variables added together in an argument slot, like r( f(w, x+y+z, z) )
. I haven’t been able to fix it, but here’s an attempt:
r1 = @rule(f(~~a, ~x + ~~y, ~~b) => f(~~a..., ~x, ~~b...) + f(~~a..., +(~~y...), ~~b...))
r1(f(w, x+y+z, z)) # returns f(w, x, z) + f(w, y + z, z)
Do you known a good way to apply the rule recursively to finish up the job?
An unrelated question / feature request for Symbolics.jl: can associative-commutative pattern matching, i.e. @acrule
, be applied to arbitrary user-defined associative / commutative functions, rather than Base.+
and Base.*
?
Yes, it was not a full solution. Here is what I have at the moment:
using SymbolicUtils, SymbolicUtils.Rewriters
f = SymbolicUtils.Sym{SymbolicUtils.FnType{Tuple, Number}}(:f)
r1 = @rule(f(~~a, +(~~b), ~~c) => sum([f(~~a..., x, ~~c...) for x in ~~b]))
r2 = @rule(f(~~a, ~c::(x -> x isa Int) * ~x, ~~b) => ~c * f(~~a..., ~x, ~~b...))
r = Fixpoint(Postwalk(Chain([r1, r2])))
I haven’t tested it fully, but it seems to work:
julia> @syms w x y z;
julia> r( f(2*w, x-y+z, f(w-3*z,x)) )
2f(w, x, f(w, x)) + 6f(w, y, f(z, x)) + 2f(w, z, f(w, x)) - (6f(w, x, f(z, x))) - (2f(w, y, f(w, x))) - (6f(w, z, f(z, x)))
I’m only beginning to use SymbolicUtils.jl, so I don’t know if I’m using the right combination of rewriters.
This version is about 10 times slower than some Maple code I have (which is similar in spirit to expand_multilinear
from @greatpet’s example above). Maybe something like expand_multilinear
is the better approach, for example because the Fixpoint
rewriter makes the Postwalk
rewriter unnecessarily traverse the whole expression tree over and over again, instead of moving its way up from the bottom level only once. It would nevertheless be interesting to benchmark this Julia code also against @greatpet’s Mathematica example.
Regarding @acrule
: From reading the documentation, I don’t fully understand what it does. It is not clear to me which functions are affected if I use @acrule
for a pattern involving more than one function.
Regarding performance, I timed your code with a large-ish input,
r(f(u+2v+3w+4x+5y+6z, u+2v+3w+4x+5y+6z, u+2v+3w+4x+5y+6z,
u+2v+3w+4x+5y+6z, u+2v+3w+4x+5y+6z))
It took about 11 seconds on my laptop after a JIT warmup run. An equivalent Mathematica version runs more than 50 times faster, but interestingly the performance is similar to your Julia code if I run it with an open-source clone of Mathematica, expreduce (the software is very incomplete, but still good enough for this situation). I suppose the conclusion is just that Mathematica’s rewriting engine has been heavily optimized.
My experience with Maple is that due to a lack of built-in pattern matching capabilities, I have resort to custom code a lot, but unfortunately for
loops are slow in Maple, so Julia is much nicer when you have to code up something from scratch.
OK, so this Julia code is too slow. I need to try something else. For the record, the following Maple code is at least 100 times faster than the Julia code on your example.
f := proc()
local a, i, x, y;
a := [args];
for i to nargs do
x := a[i];
if type(x, `+`)
then return `+`(seq(procname(op(1..i-1, a), y, op(i+1..-1, a)), y = x))
elif type(x, `*`)
then return `*`(op(1..-2, x), procname(op(1..i-1, a), op(-1, x), op(i+1..-1, a)))
fi
od;
'procname'(args)
end;
It would be great to have a Julia solution with a performance comparable to Maple and Mathematica.
Here’s a hand-coded Julia version which is 20 times faster than the version using rewrite rules, though it’ll need further optimization to be competitive. It’s a little long, but I’ll just dump the 60 lines of code here.
using SymbolicUtils
import SymbolicUtils.Add, SymbolicUtils.Mul, SymbolicUtils.Term
@syms u v w x y z argument_position(a,b)
f = SymbolicUtils.Sym{(SymbolicUtils.FnType){Tuple, Number}}(:f)
function expand1(term)
if !isa(term, Term) || !(term.f === f)
return term
end
args = deepcopy(term.arguments)
for i in 1:length(args)
if isa(args[i], Add)
added = summands(args[i])
args[i] = sum([argument_position(i, added[j]) for j in 1:length(added)])
else
args[i] = argument_position(i, args[i])
end
end
# For example, for term=f(x+2y, z), so far
# args == [argument_position(1,x)*argument_position(2,z) + argument_position(1,2y)*argument_position(2,z)
allterms = summands(expand(*(args...))) # summands() is defined below
sum(map(expand_multilinear, map(to_function, allterms))) # to_function() and expand_multilinear() are defined below
end
# For example, summands(x+2y) == [x, 2y]
function summands(x::Add)
d = x.dict
[term[1]*term[2] for term in d]
end
# Any other type
summands(x) = [x]
# For example, to_function(argument_position(1, x) * argument_position(2, y)) == f(x,y)
function to_function(x::Mul)
d = x.dict
args = Vector{Any}(undef, length(d))
for term in d
@assert term[2] == 1
@assert term[1].f == argument_position
args[term[1].arguments[1]] = term[1].arguments[2]
end
f(args...)
end
# For example, expand_multilinear(f(x,2y)) == 2f(x,y)
function expand_multilinear(expr)
function_arguments = Vector{Any}(deepcopy(expr.arguments))
overall_coeff = 1
for i in 1:length(function_arguments)
# check if this function argument is a product of things
if isa(function_arguments[i], Mul)
current_coeff = function_arguments[i].coeff
# absorb nontrivial coefficient into an overall factor
if current_coeff != 1
overall_coeff *= current_coeff
function_arguments[i] = function_arguments[i] / current_coeff
end
end
end
overall_coeff * f(function_arguments...)
end
@time expand1(f(u+2v+3w+4x+5y+6z, u+2v+3w+4x+5y+6z, u+2v+3w+4x+5y+6z, u+2v+3w+4x+5y+6z, u+2v+3w+4x+5y+6z));
# It takes about 0.64 seconds on my laptop
Here is a version that only needs 115ms for your example, just 20% slower than Maple. (My laptop seems to be about as fast as yours.) So one can get a performance similar to Maple and Mathematica, and maybe even better with additional tweaking.
So far, the multilinear function f
is hard-coded, and there may be other things one could improve. Note that all occurrences of f
in an expression are expanded:
julia> expand_multilinear( f(f(x+2*y), f(3*z-w)) )
3f(f(x), f(z)) + 6f(f(y), f(z)) - f(f(x), f(w)) - (2f(f(y), f(w)))
Code (updated)
using SymbolicUtils
using SymbolicUtils: Add, Mul, Term, Symbolic
const f = SymbolicUtils.Sym{(SymbolicUtils.FnType){Tuple, Number}}(:f)
function expand_multilinear_arg(t::Add, i::Int)
sum(c * expand_multilinear_arg(x, i) for (x, c) in t.dict)
end
function expand_multilinear_arg(t::Mul, i::Int)
a, _ = first(t.dict)
# by construction, t has only one factor, and that has exponent 1
t.coeff * expand_multilinear_arg(a, i)
end
function expand_multilinear_arg(t::Term{T}, i::Int) where T
f = t.f
b = convert(Vector{Any}, t.arguments)
# this is needed for the assignment of one(T) to b[i] later one
# note that the type of FnType.arguments is Any anyway
x = b[i]
if x isa Add
d = Dict((b[i] = y; f(b...)) => c for (y, c) in x.dict)
c = x.coeff
if !iszero(c)
b[i] = one(T)
d[f(b...)] = c
end
Add(T, zero(T), d)
elseif x isa Mul
p = x.dict
b[i] = length(p) == 1 ? first(p).first : Mul(T, one(T), p)
Mul(T, x.coeff, Dict(f(b...) => one(T)))
else
t
end
end
function expand_multilinear_allargs(t)
# the function f is hard-coded here
if t isa Term && t.f == f
foldl(expand_multilinear_arg, 1:length(t.arguments); init = t)
else
t
end
end
expand_multilinear = Rewriters.Postwalk(expand_multilinear_allargs)
A minor bug fix for your code: the line
y = length(p) == 1 ? first(p) : Mul(Number, 1, p)
should probably be
y = length(p) == 1 ? first(p)[1] : Mul(Number, 1, p)
That’s needed for getting correct results for expand_multilinear(f(2x, y))
, at least on my machine. Anyway, I’m really excited about doing symbolic calculations in Julia. No Python package would offer competitive performance for this kind of user-programmed symbolic manipulations, unless someone implements an underlying pattern matching / term rewriting engine in C++, or Julia
Thanks! I’ve updated the code. I’ve thought a bit about making it even faster, but I didn’t see anything.
EDIT: On larger examples the Julia/SymbolicUtils code above is again much slower than Maple. So there is still a long way to go.
Yeah, we’ll follow up in the performance in an issue. Can you share the benchmarks to Maple and Mathematica in the issue as well?
I’ve added Julia/Maple benchmarks to the GitHub issue.
@greatpet: Could you add Mathematica timings?
I just added the Mathematica timings to your Github issue (my Github name is zengmao).
That’s all super helpful, thanks!