Julia Sympy Apply To Both Sides Of Equation

Julia Sympy is great! Many thanks to the people that worked on it.

I’m wondering about how one might implement a strategy like the one described here so that I could work through calculations myself for problem sets.

Take for example a simple algebra 1 problem:

using SymPy
@vars x
eq = Eq(x^2-4x,2x+16)

Obviously, I could use solveset(eq) but if I wanted to work it through myself it would be cumbersome and something like:

eq = Eq(eq.lhs // 2,
        eq.rhs // 2)
eq = Eq(eq.lhs - 8,
        eq.rhs - 8)
eq = Eq(eq.lhs - x,
        eq.rhs - x)
eq = Eq(factor(eq.lhs)*2,
        eq.rhs)

I’m just starting to learn metaprogramming, but I wonder if there is a relatively simple way to set up a macro (or function?) to take in the equation and the operation you wish to perform and return the result as an equation.

This would be similar to ApplySides in Mathematica.

I’m picturing something like @eqApply sin eq or @eqApply \\2 eq.

Thanks!

You certainly don’t need metaprogramming, though it might be helpful to simplify the syntax. The thing is like a broadcast or map and is basically done by:

mapeq(f, eq) = Eq(f.((lhs(eq), rhs(eq)))...) # or f(lhs(eq)) ~ f(rhs(eq))

The issue is an equation is typed in Python, not Julia, so it isn’t easy to make this use some generic function.

1 Like
julia> macro eq(ex)  esc(:(Eq($(ex.args[1]), $(ex.args[2]))))  end
@eq (macro with 1 method)

julia> macro step(eqname, ex)
           rep!(ex, r) = (ex isa Expr && (map(e->rep!(e,r), ex.args); replace!(ex.args, r)); ex)
           lhs = rep!(deepcopy(ex), eqname=>:($eqname.lhs))
           rhs = rep!(deepcopy(ex), eqname=>:($eqname.rhs))
           esc(:($eqname = Eq($lhs, $rhs)))
       end
@step (macro with 1 method)

julia> eqn = @eq x^2-4x = 2x+16
 2
x  - 4⋅x = 2⋅x + 16

julia> @step eqn eqn//2
 2
x
── - 2⋅x = x + 8
2

julia> @step eqn eqn-8
 2
x
── - 2⋅x - 8 = x
2

julia> @step eqn eqn-x
 2
x
── - 3⋅x - 8 = 0
2

julia> @step eqn factor(eqn)*2
(x - 8)⋅(x + 2) = 0

I didn’t fully read the request; I just jumped straight into writing a macro for fun, and I came up with @step because that felt most natural to me :sweat_smile: I’m sure you’ll find a better name for it.

Whereas the macro I wrote allows you to execute arbitrary expressions, Mathematica’s ApplySides requests a function. We can do this too if preferred, using partially-applied functions:

julia> apply_sides(f, eq) = Eq(f(lhs(eq)), f(rhs(eq)))
apply_sides (generic function with 1 method)

julia> eqn = @eq x^2-4x = 2x+16
 2
x  - 4⋅x = 2⋅x + 16

julia> eqn = apply_sides(Base.Fix2(//, 2), eqn)
 2
x
── - 2⋅x = x + 8
2

julia> eqn = apply_sides(Base.Fix2(-, 8), eqn)
 2
x
── - 2⋅x - 8 = x
2

julia> eqn = apply_sides(Base.Fix2(-, x), eqn)
 2
x
── - 3⋅x - 8 = 0
2

julia> eqn = apply_sides(factor, eqn)
(x - 8)⋅(x + 2)
─────────────── = 0
       2

julia> eqn = apply_sides(Base.Fix2(*, 2), eqn)
(x - 8)⋅(x + 2) = 0

There is a proposal, PR#24990, that would make the construction of partially-applied functions easier: using underscore _ to represent unfilled arguments to a function. By that proposal, the above expressions would be:

eqn = apply_sides(_//2, eqn)
eqn = apply_sides(_-8, eqn)
eqn = apply_sides(_-x, eqn)
eqn = apply_sides(factor, eqn)
eqn = apply_sides(_*2, eqn)

but that proposal has been stuck in purgatory.

@j_verzani, thanks! This makes sense. It was the differences between typing in Python and Julia that were tripping me up.

@uniment, thanks appreciate it greatly! That is exactly what I wanted. If anyone else looks at this and implements Uniment’s solution on the equation construction macro @eq larger expressions need to be wrapped in parentheses.

For example on my side at least:

eq = @Eq (x^2 + 3x -4) = 0 # works
eq = @Eq x^2 + 3x -4 = 0 # fails

It’s not any difference between Python and Julia that’s making things difficult here, but the fact that the SymPy library is sloppy and loses type information. For example, I was expecting Eq would be a constructor that returns an object of type Eq; we would’ve been able overload the arithmetic operators (e.g. addition, Base.:+(a::Eq, b) = Eq(lhs(a)+b, rhs(a)+b)) as the article suggested. If this were any other Julia library, we could (and actually easier than Python since we wouldn’t need to make a wrapper class; we would just overload the methods directly).

But instead Eq is just a function that returns a Sym object, which can’t be told apart from any other SymPy symbolic expression. This a) is not Julian (because idiomatic Julia requests that CamelCase be reserved for types and constructors, and that function and object names should be written in snake_case), and b) means that we can’t overload the arithmetic operators like the article was suggesting.

All to say: Most of your surprise is due to SymPy, not Julia.

This is actually not a result of the size of the expression, but a result of how you use the - minus operator. :sweat_smile:

When calling macros, we have two options: a) use parentheses and delimit expressions with commas (e.g., @my_macro(expr1, expr2, expr3)—just like a regular function call but on expressions) or b) use spaces to delimit the arguments (e.g., @my_macro expr1 expr2 expr3).

A consequence of that is: What happens when you happen upon an operator which can be considered either a binary operator or a unary operator? (namely: minus!)

You get this weirdness:

julia> macro what_did_I_do(exprs...)
           for expr ∈ exprs
               show(expr); println()
           end
       end
@what_did_I_do (macro with 1 method)

julia> @what_did_I_do 1-2 3
:(1 - 2)
3

julia> @what_did_I_do 1 -2 3
1
-2
3

julia> @what_did_I_do 1 - 2 3
:(1 - 2)
3

julia> @what_did_I_do(1 - 2, 3)
:(1 - 2)
3

julia> @what_did_I_do(1 -2, 3)
:(1 - 2)
3

julia> @what_did_I_do(x^2 + 3x -4 = 0)
:((x ^ 2 + 3x) - 4 = begin
          #= REPL[215]:1 =#
          0
      end)

julia> @what_did_I_do x^2 + 3x -4 = 0
:(x ^ 2 + 3x)
:(-4 = 0)

julia> @what_did_I_do x^2 + 3x - 4 = 0
:((x ^ 2 + 3x) - 4 = begin
          #= REPL[217]:1 =#
          0
      end)

Nominally, Julia treats 1 -2 as 1 - 2. But in contexts where expressions will be space-delimited, they result in different expressions.

The other context in which we will space-delimit expressions is in building matrices, and we have the same behavior:

julia> [1-2 3]
1×2 Matrix{Int64}:
 -1  3

julia> [1 -2 3]
1×3 Matrix{Int64}:
 1  -2  3

julia> [1 - 2 3]
1×2 Matrix{Int64}:
 -1  3

julia> [1-2, 3]
2-element Vector{Int64}:
 -1
  3

julia> [1 -2, 3]
ERROR: syntax: unexpected comma in array expression

This behavior is necessary so that we can build matrices with negative entries without writing a bajillion parentheses.

All to say: when you’re hoping to use - as a binary operator, you should adopt a habit of writing it symmetrically spaced so it’s clear you wish to call the binary operator, not the unary operator. If that constraint cannot be enforced, then use the parenthesized version of macro calls so that expressions are comma-delimited,

eqn = @eq(x^2 + 3x -4 = 0)

Sloppy isn’t really apt, nothing is lost. While theoretically possible to re-build the SymPy type class system on the Julia side (cf Symbolics) that isn’t really worth the effort here. The constructors like Eq just dispatch to the same-named constructors in the the Python library so shouldn’t be too surprising, as they are documented there (e.g., @doc sympy.Eq). They may not follow a Julia idiom, but the package proves quite useful, even if just a stop gap until Symbolics gains more features.

It may not be lost to the Python instance, but to the Julia instance there’s information loss. When I call Eq, it is known in that Julia expression that I’d like to construct an equation. However, SymPy calls the Eq constructor in Python and stores the associated object in a Sym object on the Julia side, which is the same type which refers to other expressions, making it impossible to create Julia type-specialized methods for Eq objects.

julia> x^2+x+1 |> typeof
Sym

julia> Eq(x^2+x+1, 0) |> typeof
Sym

Of course, each time you do anything to a Sym, the memory of that interaction is stored Python-side and not Julia-side, so at least this Julia-side information loss is coherent with the rest of SymPy’s behavior. A policy of declaring some Julia-side types here and there could decrease this coherence, and the only way to get full coherence back might be to implement the entire thing in Julia (an effort which is well underway) :sweat_smile:

Agreed. It’s very functional as-is, and tidying it up is probably not worth it. But that doesn’t mean its departure from Julian idioms isn’t sloppy! :wink:

I look forward to your TidySymPy package, but, until then this lightweight style of wrapping an external library will have to do.

1 Like

I didn’t realize that I’m literally speaking with SymPy’s creator :cold_sweat: I need to communicate more thoughtfully, or go anon :sweat_smile:

I apologize if I was offensive; I didn’t mean to be.

Many thanks to you both for the conversation, macro and package, all of which were are much appreciated.