Don Hatch
Don Hatch

Reputation: 5537

idiom for protecting against inexact native division in code that uses sympy?

I am developing some code whose variables and function params are sometimes native python numeric types (int, float) and sometimes sympy types (sympy.core.numbers.Integer, sympy.core.numbers.Rational, sympy.core.symbol.Symbol, sympy.core.add.Add, etc.). And I sometimes want to express division (/), but I'm having trouble finding a non-error-prone way to express it.

Here is some very simple representative example code, which works fine, until it doesn't:

import sympy

def MyAverageOfThreeNumbers(a, b, c):
  return (a + b + c) / 3

print(MyAverageOfThreeNumbers(0, 1, 2))
# 1.0
print(type(MyAverageOfThreeNumbers(0, 1, 2)))
#<class 'float'>

print(MyAverageOfThreeNumbers(sympy.Integer(0), 1, 2))
# 1
print(type(MyAverageOfThreeNumbers(sympy.Integer(0), 1, 2)))
#<class 'sympy.core.numbers.One'>

x = sympy.symbols("x")
print(MyAverageOfThreeNumbers(x, 1, 2))
# x/3 + 1
print(type(MyAverageOfThreeNumbers(x, 1, 2)))
# <class 'sympy.core.add.Add'>
print(MyAverageOfThreeNumbers(x, x, x))
# x
print(type(MyAverageOfThreeNumbers(x, x, x)))
# <class 'sympy.core.symbol.Symbol'>

So far so good; but then...

print(MyAverageOfThreeNumbers(1, 1, 2))
# 1.3333333333333333   <-- bad!  I want 4/3
print(type(MyAverageOfThreeNumbers(1, 1, 2)))
# <class 'float'>  <-- bad!  I want sympy.core.numbers.Rational or equivalent

print(sympy.Rational(MyAverageOfThreeNumbers(1, 1, 2)))
# 6004799503160661/4503599627370496  <-- bad!  I want 4/3
print(type(sympy.Rational(MyAverageOfThreeNumbers(1, 1, 2))))
# <class 'sympy.core.numbers.Rational'>

Solutions I've considered:

(1) Whenever I type '/' in my code, make sure at least one of the operands is a sympy type rather than native. E.g. one way to safely rewrite my function would be as follows:

      def MyAverageOfThreeNumbers(a, b, c):
        return (a + b + c) * sympy.core.numbers.One() / 3

      print(MyAverageOfThreeNumbers(1, 1, 2))
      # 4/3  <-- good!

(2) Avoid/prohibit use of '/' in my code entirely, except in this helper function:

      def MySafeDivide(a, b):
        return a * sympy.core.numbers.One() / b

(in fact I could avoid it there too, using operator.truediv instead of the / operator). Then I'd rewrite my function as:

      def MyAverageOfThreeNumbers(a, b, c):
        return MySafeDivide(a + b + c, 3)

(3) Whenever I write a function designed to accept both native types and sympy times, always convert to sympy types at the beginning of the function body: E.g. I'd rewrite my function as:

      def MyAverageOfThreeNumbers(a, b, c):
        # In case any or all of a,b,c are native types...
        a *= sympy.core.numbers.One()
        b *= sympy.core.numbers.One()
        c *= sympy.core.numbers.One()
        # Now a,b,c can be used safely in subsequent arithmetic
        # that may involve '/', without having to scrutinize the code too closely.
        return (a + b + c) / 3

All three of the above solutions seem ugly and (more importantly) error prone, and they require me to periodically audit my code to make sure I haven't mistakenly added any new unsafe uses of '/'. Also, I'm finding that it's too tempting to leave the following very frequent kind of expression as-is, since it's safe:

   some_python_expression/2

rather than rewriting it as one of:

   (some_python_expression * sympy.core.numbers.One()) / 2

or:

   MySafeDivide(some_python_expression, 2)

but then that makes my code harder to audit for mistakes, since some_python_expression/2 is safe but some_python_expression/3 isn't. (Nit: actually even some_python_expression/2 isn't completely safe, e.g. 2**-1074/2 yields 0.0)

So I'm looking for a robust maintainable solution that will bulletproof my code from this kind of mistake. Ideally I'd like to either:

Are either of these things possible in python? Note, I want to stick with standard python3 as the interpreter, which rules out solutions that require help from a nonstandard interpreter or compiler.

Upvotes: 1

Views: 77

Answers (2)

Don Hatch
Don Hatch

Reputation: 5537

Here's a way to prohibit division in a python file, thus preventing the mistake in question:

import assert_division_operator_not_used_in_this_file
assert_division_operator_not_used_in_this_file.assert_division_operator_not_used_in_this_file()

where assert_division_operator_not_used_in_this_file.py contains:

import inspect
import ast
def assert_division_operator_not_used_in_this_file():
  caller_frame = inspect.currentframe().f_back
  caller_module = inspect.getmodule(caller_frame)
  caller_file_contents = inspect.getsource(caller_module)
  tree = ast.parse(caller_file_contents)
  for node in ast.walk(tree):
    assert not (type(node) == ast.BinOp and type(node.op) == ast.Div), f"{caller_frame.f_code.co_filename}({node.lineno}): forbidden operator '/' used!"

More nuanced implementations are possible, e.g. permit the division when the denominator is a literal (int or float) power of 2.

Upvotes: 0

smichr
smichr

Reputation: 19125

If you want a SymPy expression returned from a function and don't want to do anything hackish within the function, start by sympifying your arguments as Oscar has said:

from sympy import sympify
def MyAverageOfThreeNumbers(a, b, c):
  a,b,c = map(sympify, (a,b,c))
  return (a + b + c) / 3

Your other idea (more hackish) of including a SymPy One as a factor before dividing is ok, too. You could make this into a function that can be used whenever you want to divide and get a SymPy result in return.

def safe_div(a, b):
    return a*S.One/b

But the idea of being able to write expressions naturally and most transparently would favor the first method. You can think of that as clearly indicating at the start that you want a SymPy expression so you are making all inputs SymPy objects. And then the machinery of Python and SymPy allow you to use normal operators after that in a "safe" manner. The only time in subsequent code that you would have to be careful is when you try add a fraction to a number, e.g. 2/3 + x would need to be written as S(2)/3 + x where from sympy import S has been executed.

Upvotes: 0

Related Questions