Skip to content

Commit ce80793

Browse files
committed
Merge pull request #10953 from sklam/eval_math
Add support for math functions in eval()
2 parents 5dea811 + 44723d1 commit ce80793

File tree

4 files changed

+212
-19
lines changed

4 files changed

+212
-19
lines changed

doc/source/whatsnew/v0.17.0.txt

+20
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Highlights include:
4040
- Development support for benchmarking with the `Air Speed Velocity library <https://github.com/spacetelescope/asv/>`_ (:issue:`8316`)
4141
- Support for reading SAS xport files, see :ref:`here <whatsnew_0170.enhancements.sas_xport>`
4242
- Removal of the automatic TimeSeries broadcasting, deprecated since 0.8.0, see :ref:`here <whatsnew_0170.prior_deprecations>`
43+
- Support for math functions in .eval(), see :ref:`here <whatsnew_0170.matheval>`
4344

4445
Check the :ref:`API Changes <whatsnew_0170.api>` and :ref:`deprecations <whatsnew_0170.deprecations>` before updating.
4546

@@ -186,6 +187,25 @@ incrementally.
186187

187188
See the :ref:`docs <io.sas>` for more details.
188189

190+
.. _whatsnew_0170.matheval:
191+
192+
Support for Math Functions in .eval()
193+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
194+
195+
:meth:`~pandas.eval` now supports calling math functions.
196+
197+
.. code-block:: python
198+
199+
df = pd.DataFrame({'a': np.random.randn(10)})
200+
df.eval("b = sin(a)")
201+
202+
The support math functions are `sin`, `cos`, `exp`, `log`, `expm1`, `log1p`,
203+
`sqrt`, `sinh`, `cosh`, `tanh`, `arcsin`, `arccos`, `arctan`, `arccosh`,
204+
`arcsinh`, `arctanh`, `abs` and `arctan2`.
205+
206+
These functions map to the intrinsics for the NumExpr engine. For Python
207+
engine, they are mapped to NumPy calls.
208+
189209
.. _whatsnew_0170.enhancements.other:
190210

191211
Other enhancements

pandas/computation/expr.py

+36-15
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
_arith_ops_syms, _unary_ops_syms, is_term)
2121
from pandas.computation.ops import _reductions, _mathops, _LOCAL_TAG
2222
from pandas.computation.ops import Op, BinOp, UnaryOp, Term, Constant, Div
23-
from pandas.computation.ops import UndefinedVariableError
23+
from pandas.computation.ops import UndefinedVariableError, FuncNode
2424
from pandas.computation.scope import Scope, _ensure_scope
2525

2626

@@ -524,27 +524,48 @@ def visit_Call(self, node, side=None, **kwargs):
524524
elif not isinstance(node.func, ast.Name):
525525
raise TypeError("Only named functions are supported")
526526
else:
527-
res = self.visit(node.func)
527+
try:
528+
res = self.visit(node.func)
529+
except UndefinedVariableError:
530+
# Check if this is a supported function name
531+
try:
532+
res = FuncNode(node.func.id)
533+
except ValueError:
534+
# Raise original error
535+
raise
528536

529537
if res is None:
530538
raise ValueError("Invalid function call {0}".format(node.func.id))
531539
if hasattr(res, 'value'):
532540
res = res.value
533541

534-
args = [self.visit(targ).value for targ in node.args]
535-
if node.starargs is not None:
536-
args += self.visit(node.starargs).value
542+
if isinstance(res, FuncNode):
543+
args = [self.visit(targ) for targ in node.args]
544+
545+
if node.starargs is not None:
546+
args += self.visit(node.starargs)
547+
548+
if node.keywords or node.kwargs:
549+
raise TypeError("Function \"{0}\" does not support keyword "
550+
"arguments".format(res.name))
551+
552+
return res(*args, **kwargs)
553+
554+
else:
555+
args = [self.visit(targ).value for targ in node.args]
556+
if node.starargs is not None:
557+
args += self.visit(node.starargs).value
537558

538-
keywords = {}
539-
for key in node.keywords:
540-
if not isinstance(key, ast.keyword):
541-
raise ValueError("keyword error in function call "
542-
"'{0}'".format(node.func.id))
543-
keywords[key.arg] = self.visit(key.value).value
544-
if node.kwargs is not None:
545-
keywords.update(self.visit(node.kwargs).value)
559+
keywords = {}
560+
for key in node.keywords:
561+
if not isinstance(key, ast.keyword):
562+
raise ValueError("keyword error in function call "
563+
"'{0}'".format(node.func.id))
564+
keywords[key.arg] = self.visit(key.value).value
565+
if node.kwargs is not None:
566+
keywords.update(self.visit(node.kwargs).value)
546567

547-
return self.const_type(res(*args, **keywords), self.env)
568+
return self.const_type(res(*args, **keywords), self.env)
548569

549570
def translate_In(self, op):
550571
return op
@@ -587,7 +608,7 @@ def visitor(x, y):
587608
return reduce(visitor, operands)
588609

589610

590-
_python_not_supported = frozenset(['Dict', 'Call', 'BoolOp', 'In', 'NotIn'])
611+
_python_not_supported = frozenset(['Dict', 'BoolOp', 'In', 'NotIn'])
591612
_numexpr_supported_calls = frozenset(_reductions + _mathops)
592613

593614

pandas/computation/ops.py

+31-3
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616

1717

1818
_reductions = 'sum', 'prod'
19-
_mathops = ('sin', 'cos', 'exp', 'log', 'expm1', 'log1p', 'pow', 'div', 'sqrt',
20-
'inv', 'sinh', 'cosh', 'tanh', 'arcsin', 'arccos', 'arctan',
21-
'arccosh', 'arcsinh', 'arctanh', 'arctan2', 'abs')
19+
20+
_unary_math_ops = ('sin', 'cos', 'exp', 'log', 'expm1', 'log1p',
21+
'sqrt', 'sinh', 'cosh', 'tanh', 'arcsin', 'arccos',
22+
'arctan', 'arccosh', 'arcsinh', 'arctanh', 'abs')
23+
_binary_math_ops = ('arctan2',)
24+
_mathops = _unary_math_ops + _binary_math_ops
2225

2326

2427
_LOCAL_TAG = '__pd_eval_local_'
@@ -498,3 +501,28 @@ def return_type(self):
498501
(operand.op in _cmp_ops_dict or operand.op in _bool_ops_dict)):
499502
return np.dtype('bool')
500503
return np.dtype('int')
504+
505+
506+
class MathCall(Op):
507+
def __init__(self, func, args):
508+
super(MathCall, self).__init__(func.name, args)
509+
self.func = func
510+
511+
def __call__(self, env):
512+
operands = [op(env) for op in self.operands]
513+
return self.func.func(*operands)
514+
515+
def __unicode__(self):
516+
operands = map(str, self.operands)
517+
return com.pprint_thing('{0}({1})'.format(self.op, ','.join(operands)))
518+
519+
520+
class FuncNode(object):
521+
def __init__(self, name):
522+
if name not in _mathops:
523+
raise ValueError("\"{0}\" is not a supported function".format(name))
524+
self.name = name
525+
self.func = getattr(np, name)
526+
527+
def __call__(self, *args):
528+
return MathCall(self, args)

pandas/computation/tests/test_eval.py

+125-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
from pandas.computation.expr import PythonExprVisitor, PandasExprVisitor
2424
from pandas.computation.ops import (_binary_ops_dict,
2525
_special_case_arith_ops_syms,
26-
_arith_ops_syms, _bool_ops_syms)
26+
_arith_ops_syms, _bool_ops_syms,
27+
_unary_math_ops, _binary_math_ops)
2728

2829
import pandas.computation.expr as expr
2930
import pandas.util.testing as tm
@@ -1439,6 +1440,129 @@ def setUpClass(cls):
14391440
cls.arith_ops = expr._arith_ops_syms + expr._cmp_ops_syms
14401441

14411442

1443+
class TestMathPythonPython(tm.TestCase):
1444+
@classmethod
1445+
def setUpClass(cls):
1446+
super(TestMathPythonPython, cls).setUpClass()
1447+
tm.skip_if_no_ne()
1448+
cls.engine = 'python'
1449+
cls.parser = 'pandas'
1450+
cls.unary_fns = _unary_math_ops
1451+
cls.binary_fns = _binary_math_ops
1452+
1453+
@classmethod
1454+
def tearDownClass(cls):
1455+
del cls.engine, cls.parser
1456+
1457+
def eval(self, *args, **kwargs):
1458+
kwargs['engine'] = self.engine
1459+
kwargs['parser'] = self.parser
1460+
kwargs['level'] = kwargs.pop('level', 0) + 1
1461+
return pd.eval(*args, **kwargs)
1462+
1463+
def test_unary_functions(self):
1464+
df = DataFrame({'a': np.random.randn(10)})
1465+
a = df.a
1466+
for fn in self.unary_fns:
1467+
expr = "{0}(a)".format(fn)
1468+
got = self.eval(expr)
1469+
expect = getattr(np, fn)(a)
1470+
pd.util.testing.assert_almost_equal(got, expect)
1471+
1472+
def test_binary_functions(self):
1473+
df = DataFrame({'a': np.random.randn(10),
1474+
'b': np.random.randn(10)})
1475+
a = df.a
1476+
b = df.b
1477+
for fn in self.binary_fns:
1478+
expr = "{0}(a, b)".format(fn)
1479+
got = self.eval(expr)
1480+
expect = getattr(np, fn)(a, b)
1481+
np.testing.assert_allclose(got, expect)
1482+
1483+
def test_df_use_case(self):
1484+
df = DataFrame({'a': np.random.randn(10),
1485+
'b': np.random.randn(10)})
1486+
df.eval("e = arctan2(sin(a), b)",
1487+
engine=self.engine,
1488+
parser=self.parser)
1489+
got = df.e
1490+
expect = np.arctan2(np.sin(df.a), df.b)
1491+
pd.util.testing.assert_almost_equal(got, expect)
1492+
1493+
def test_df_arithmetic_subexpression(self):
1494+
df = DataFrame({'a': np.random.randn(10),
1495+
'b': np.random.randn(10)})
1496+
df.eval("e = sin(a + b)",
1497+
engine=self.engine,
1498+
parser=self.parser)
1499+
got = df.e
1500+
expect = np.sin(df.a + df.b)
1501+
pd.util.testing.assert_almost_equal(got, expect)
1502+
1503+
def check_result_type(self, dtype, expect_dtype):
1504+
df = DataFrame({'a': np.random.randn(10).astype(dtype)})
1505+
self.assertEqual(df.a.dtype, dtype)
1506+
df.eval("b = sin(a)",
1507+
engine=self.engine,
1508+
parser=self.parser)
1509+
got = df.b
1510+
expect = np.sin(df.a)
1511+
self.assertEqual(expect.dtype, got.dtype)
1512+
self.assertEqual(expect_dtype, got.dtype)
1513+
pd.util.testing.assert_almost_equal(got, expect)
1514+
1515+
def test_result_types(self):
1516+
self.check_result_type(np.int32, np.float64)
1517+
self.check_result_type(np.int64, np.float64)
1518+
self.check_result_type(np.float32, np.float32)
1519+
self.check_result_type(np.float64, np.float64)
1520+
# Did not test complex64 because DataFrame is converting it to
1521+
# complex128. Due to https://github.com/pydata/pandas/issues/10952
1522+
self.check_result_type(np.complex128, np.complex128)
1523+
1524+
def test_undefined_func(self):
1525+
df = DataFrame({'a': np.random.randn(10)})
1526+
with tm.assertRaisesRegexp(ValueError,
1527+
"\"mysin\" is not a supported function"):
1528+
df.eval("mysin(a)",
1529+
engine=self.engine,
1530+
parser=self.parser)
1531+
1532+
def test_keyword_arg(self):
1533+
df = DataFrame({'a': np.random.randn(10)})
1534+
with tm.assertRaisesRegexp(TypeError,
1535+
"Function \"sin\" does not support "
1536+
"keyword arguments"):
1537+
df.eval("sin(x=a)",
1538+
engine=self.engine,
1539+
parser=self.parser)
1540+
1541+
1542+
class TestMathPythonPandas(TestMathPythonPython):
1543+
@classmethod
1544+
def setUpClass(cls):
1545+
super(TestMathPythonPandas, cls).setUpClass()
1546+
cls.engine = 'python'
1547+
cls.parser = 'pandas'
1548+
1549+
1550+
class TestMathNumExprPandas(TestMathPythonPython):
1551+
@classmethod
1552+
def setUpClass(cls):
1553+
super(TestMathNumExprPandas, cls).setUpClass()
1554+
cls.engine = 'numexpr'
1555+
cls.parser = 'pandas'
1556+
1557+
1558+
class TestMathNumExprPython(TestMathPythonPython):
1559+
@classmethod
1560+
def setUpClass(cls):
1561+
super(TestMathNumExprPython, cls).setUpClass()
1562+
cls.engine = 'numexpr'
1563+
cls.parser = 'python'
1564+
1565+
14421566
_var_s = randn(10)
14431567

14441568

0 commit comments

Comments
 (0)