Skip to content

Commit

Permalink
Improve casting and error message for ParameterExpression (#10244) (#…
Browse files Browse the repository at this point in the history
…10268)

* Improve casting and error message for `ParameterExpression`

Previously, we assumed that the only reason a cast of
`ParameterExpression` to `complex`/`float`/`int` could fail was because
of unbound parameters, and emitted an error accordingly.  This is not
the case; a fully bound complex value will still fail to convert to
`float`.

This PR improves the error messages in these cases, and works around a
difference of Sympy and Symengine, where the latter will fail to convert
real-valued expressions that were symbollically complex at some point in
their binding history to `float`.  Sympy more reliably reduces values
down to real-only values when the imaginary part is exactly cancelled,
which is a use-case our users tend to expect.

* Fix typo in test

* Update releasenotes/notes/parameter-float-cast-48f3731fec5e47cd.yaml



---------

Co-authored-by: Jake Lishman <[email protected]>
Co-authored-by: Kevin Hartman <[email protected]>
  • Loading branch information
3 people authored Jun 16, 2023
1 parent e781147 commit 31f26d2
Show file tree
Hide file tree
Showing 4 changed files with 76 additions and 16 deletions.
41 changes: 29 additions & 12 deletions qiskit/circuit/parameterexpression.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,30 +464,47 @@ def __complex__(self):
return complex(self._symbol_expr)
# TypeError is for sympy, RuntimeError for symengine
except (TypeError, RuntimeError) as exc:
raise TypeError(
"ParameterExpression with unbound parameters ({}) "
"cannot be cast to a complex.".format(self.parameters)
) from exc
if self.parameters:
raise TypeError(
"ParameterExpression with unbound parameters ({}) "
"cannot be cast to a complex.".format(self.parameters)
) from None
raise TypeError("could not cast expression to complex") from exc

def __float__(self):
try:
return float(self._symbol_expr)
# TypeError is for sympy, RuntimeError for symengine
except (TypeError, RuntimeError) as exc:
raise TypeError(
"ParameterExpression with unbound parameters ({}) "
"cannot be cast to a float.".format(self.parameters)
) from exc
if self.parameters:
raise TypeError(
"ParameterExpression with unbound parameters ({}) "
"cannot be cast to a float.".format(self.parameters)
) from None
try:
# In symengine, if an expression was complex at any time, its type is likely to have
# stayed "complex" even when the imaginary part symbolically (i.e. exactly)
# cancelled out. Sympy tends to more aggressively recognise these as symbolically
# real. This second attempt at a cast is a way of unifying the behaviour to the
# more expected form for our users.
cval = complex(self)
if cval.imag == 0.0:
return cval.real
except TypeError:
pass
raise TypeError("could not cast expression to float") from exc

def __int__(self):
try:
return int(self._symbol_expr)
# TypeError is for sympy, RuntimeError for symengine
except (TypeError, RuntimeError) as exc:
raise TypeError(
"ParameterExpression with unbound parameters ({}) "
"cannot be cast to an int.".format(self.parameters)
) from exc
if self.parameters:
raise TypeError(
"ParameterExpression with unbound parameters ({}) "
"cannot be cast to an int.".format(self.parameters)
) from None
raise TypeError("could not cast expression to int") from exc

def __hash__(self):
return hash((frozenset(self._parameter_symbols), self._symbol_expr))
Expand Down
6 changes: 2 additions & 4 deletions qiskit/circuit/quantumcircuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2832,8 +2832,7 @@ def _assign_parameter(self, parameter: Parameter, value: ParameterValueType) ->
if new_param._symbol_expr.is_integer and new_param.is_real():
val = int(new_param)
elif new_param.is_real():
# Workaround symengine not supporting float(<ComplexDouble>)
val = complex(new_param).real
val = float(new_param)
else:
# complex values may no longer be supported but we
# defer raising an exception to validdate_parameter
Expand Down Expand Up @@ -2899,8 +2898,7 @@ def _assign_calibration_parameters(
if new_param._symbol_expr.is_integer:
new_param = int(new_param)
else:
# Workaround symengine not supporting float(<ComplexDouble>)
new_param = complex(new_param).real
new_param = float(new_param)
new_cal_params.append(new_param)
else:
new_cal_params.append(p)
Expand Down
13 changes: 13 additions & 0 deletions releasenotes/notes/parameter-float-cast-48f3731fec5e47cd.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
fixes:
- |
Improved the error messages returned when an attempt to convert a fully bound
:class:`.ParameterExpression` into a concrete ``float`` or ``int`` failed, for example because
the expression was naturally a complex number.
- |
Fixed ``float`` conversions for :class:`.ParameterExpression` values which had, at some point in
their construction history, an imaginary component that had subsequently been cancelled. When
using Sympy as a backend, these conversions would usually already have worked. When using
Symengine as the backend, these conversions would often fail with type errors, despite the
result having been symbolically evaluated to be real, and :meth:`.ParameterExpression.is_real`
being true.
32 changes: 32 additions & 0 deletions test/python/circuit/test_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1238,6 +1238,22 @@ def test_compare_to_value_when_bound(self):
bound_expr = x.bind({x: 2.3})
self.assertEqual(bound_expr, 2.3)

def test_cast_to_complex_when_bound(self):
"""Verify that the cast to complex works for bound objects."""
x = Parameter("x")
y = Parameter("y")
bound_expr = (x + y).bind({x: 1.0, y: 1j})
self.assertEqual(complex(bound_expr), 1 + 1j)

def test_raise_if_cast_to_complex_when_not_fully_bound(self):
"""Verify raises if casting to complex and not fully bound."""

x = Parameter("x")
y = Parameter("y")
bound_expr = (x + y).bind({x: 1j})
with self.assertRaisesRegex(TypeError, "unbound parameters"):
complex(bound_expr)

def test_cast_to_float_when_bound(self):
"""Verify expression can be cast to a float when fully bound."""

Expand All @@ -1252,6 +1268,22 @@ def test_cast_to_float_when_underlying_expression_bound(self):
expr = x - x + 2.3
self.assertEqual(float(expr), 2.3)

def test_cast_to_float_intermediate_complex_value(self):
"""Verify expression can be cast to a float when it is fully bound, but an intermediate part
of the expression evaluation involved complex types. Sympy is generally more permissive
than symengine here, and sympy's tends to be the expected behaviour for our users."""
x = Parameter("x")
bound_expr = (x + 1.0 + 1.0j).bind({x: -1.0j})
self.assertEqual(float(bound_expr), 1.0)

def test_cast_to_float_of_complex_fails(self):
"""Test that an attempt to produce a float from a complex value fails if there is an
imaginary part, with a sensible error message."""
x = Parameter("x")
bound_expr = (x + 1.0j).bind({x: 1.0})
with self.assertRaisesRegex(TypeError, "could not cast expression to float"):
float(bound_expr)

def test_raise_if_cast_to_float_when_not_fully_bound(self):
"""Verify raises if casting to float and not fully bound."""

Expand Down

0 comments on commit 31f26d2

Please sign in to comment.