Skip to content

Commit e6af378

Browse files
Accept NumPy scalar coefficients across operator and representation types (#1361)
Fixes #1097. The actual problem behind #1097 is that NumPy's scalar types aren't subclasses of Python's `int`, `float`, or `complex`. `numpy.int64`, `numpy.float32`, `numpy.complex64` and the rest all fail an `isinstance(x, (int, float, complex))` check, which is the idiom the coefficient validation uses in a few places. (The one that slips through is `numpy.float64`, since it does subclass `float`, which is probably why this went unnoticed for a while.) The `SymbolicOperator` family already got fixed by adding `numbers.Number` to its accepted types, so `FermionOperator`, `QubitOperator` and so on are fine now. But the same `(int, float, complex)` pattern shows up in other spots that the change didn't reach, and those still reject NumPy scalars: - `PolynomialTensor` (so `InteractionOperator`, `InteractionRDM`, ...) - `DOCIHamiltonian` - `MajoranaOperator` - the `majorana_operator` builder in `special_operators.py` This adds `numbers.Number` to each of them, matching the existing fix. `numbers.Number` covers Python and NumPy numeric scalars equally, so a coefficient behaves the same whether it came from Python or NumPy, and the plain int/float/complex cases stay exactly as they were. One thing worth calling out: dividing or multiplying a real-dtype tensor by a complex scalar still raises, because NumPy won't do the in-place cast from complex to float. That happens with a plain Python `complex` too, so it's pre-existing and unrelated to this change, and I left it alone. Each affected module gets a test asserting that NumPy scalar coefficients (`int64`, `float32`, `complex64`) give the same result as the equivalent Python scalar, including the in-place operators on `MajoranaOperator`. The tests fail on `main` and pass with this change.
1 parent 85efc02 commit e6af378

9 files changed

Lines changed: 102 additions & 14 deletions

File tree

src/openfermion/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13+
import numbers
1314
import os
1415

1516
# Tolerance to consider number zero.
1617
EQ_TOLERANCE = 1e-8
1718

19+
# Numeric types accepted as operator and tensor coefficients. numbers.Number
20+
# covers Python and NumPy scalar types alike (NumPy scalars are not subclasses
21+
# of the built-in int/float/complex types).
22+
COEFFICIENT_TYPES = (int, float, complex, numbers.Number)
23+
1824
# Molecular data directory.
1925
THIS_DIRECTORY = os.path.dirname(os.path.realpath(__file__))
2026
DATA_DIRECTORY = os.path.realpath(os.path.join(THIS_DIRECTORY, 'testing/data'))

src/openfermion/hamiltonians/special_operators.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313

1414
from typing import Optional, Union, Tuple
1515

16+
import openfermion.config as config
1617
from openfermion.ops.operators import BosonOperator, FermionOperator
1718
from openfermion.utils.indexing import down_index, up_index
1819

20+
COEFFICIENT_TYPES = config.COEFFICIENT_TYPES
21+
1922

2023
def s_plus_operator(n_spatial_orbitals: int) -> FermionOperator:
2124
r"""Return the s+ operator.
@@ -236,7 +239,7 @@ def majorana_operator(
236239
Returns:
237240
FermionOperator
238241
"""
239-
if not isinstance(coefficient, (int, float, complex)):
242+
if not isinstance(coefficient, COEFFICIENT_TYPES):
240243
raise ValueError('Coefficient must be scalar.')
241244

242245
# If term is a string, convert it to a tuple

src/openfermion/hamiltonians/special_operators_test.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
"""testing angular momentum generators. _fermion_spin_operators.py"""
1313

1414
import unittest
15+
16+
import numpy
17+
1518
from openfermion.ops.operators import FermionOperator, BosonOperator
1619
from openfermion.utils import commutator
1720
from openfermion.transforms.opconversions import normal_ordered
@@ -204,3 +207,11 @@ def test_bad_term(self):
204207
majorana_operator('a')
205208
with self.assertRaises(ValueError):
206209
majorana_operator(2)
210+
211+
def test_builder_numpy_scalar_coefficient(self):
212+
"""The majorana_operator builder accepts NumPy scalar coefficients (issue #1097)."""
213+
cases = [(numpy.int64(2), 2), (numpy.float32(0.5), 0.5), (numpy.complex64(1 + 2j), 1 + 2j)]
214+
for numpy_scalar, python_scalar in cases:
215+
self.assertEqual(
216+
majorana_operator((1, 0), numpy_scalar), majorana_operator((1, 0), python_scalar)
217+
)

src/openfermion/ops/operators/majorana_operator.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@
1313

1414
import copy
1515
import itertools
16+
1617
import numpy
1718

19+
import openfermion.config as config
20+
21+
COEFFICIENT_TYPES = config.COEFFICIENT_TYPES
22+
1823

1924
class MajoranaOperator:
2025
r"""A linear combination of products of Majorana operators.
@@ -78,7 +83,7 @@ def from_dict(terms):
7883

7984
def commutes_with(self, other):
8085
"""Test commutation with another MajoranaOperator"""
81-
if isinstance(other, (int, float, complex)):
86+
if isinstance(other, COEFFICIENT_TYPES):
8287
return True
8388

8489
if not isinstance(other, type(self)):
@@ -145,7 +150,7 @@ def __iadd__(self, other):
145150
self.terms[term] += coefficient
146151
else:
147152
self.terms[term] = coefficient
148-
elif isinstance(other, (int, float, complex)):
153+
elif isinstance(other, COEFFICIENT_TYPES):
149154
self.constant += other
150155
else:
151156
raise TypeError("Cannot add invalid type to {}".format(type(self)))
@@ -163,7 +168,7 @@ def __isub__(self, other):
163168
self.terms[term] -= coefficient
164169
else:
165170
self.terms[term] = -coefficient
166-
elif isinstance(other, (int, float, complex)):
171+
elif isinstance(other, COEFFICIENT_TYPES):
167172
self.constant -= other
168173
else:
169174
raise TypeError("Cannot subtract invalid type from {}".format(type(self)))
@@ -175,10 +180,10 @@ def __sub__(self, other):
175180
return minuend
176181

177182
def __mul__(self, other):
178-
if not isinstance(other, (type(self), int, float, complex)):
183+
if not isinstance(other, _MAJORANA_MUL_TYPES):
179184
return NotImplemented
180185

181-
if isinstance(other, (int, float, complex)):
186+
if isinstance(other, COEFFICIENT_TYPES):
182187
terms = {term: coefficient * other for term, coefficient in self.terms.items()}
183188
return MajoranaOperator.from_dict(terms)
184189

@@ -194,30 +199,30 @@ def __mul__(self, other):
194199
return MajoranaOperator.from_dict(terms)
195200

196201
def __imul__(self, other):
197-
if not isinstance(other, (type(self), int, float, complex)):
202+
if not isinstance(other, _MAJORANA_MUL_TYPES):
198203
return NotImplemented
199204

200-
if isinstance(other, (int, float, complex)):
205+
if isinstance(other, COEFFICIENT_TYPES):
201206
for term in self.terms:
202207
self.terms[term] *= other
203208
return self
204209

205210
return self * other
206211

207212
def __rmul__(self, other):
208-
if not isinstance(other, (int, float, complex)):
213+
if not isinstance(other, COEFFICIENT_TYPES):
209214
return NotImplemented
210215
return self * other
211216

212217
def __truediv__(self, other):
213-
if not isinstance(other, (int, float, complex)):
218+
if not isinstance(other, COEFFICIENT_TYPES):
214219
return NotImplemented
215220

216221
terms = {term: coefficient / other for term, coefficient in self.terms.items()}
217222
return MajoranaOperator.from_dict(terms)
218223

219224
def __itruediv__(self, other):
220-
if not isinstance(other, (int, float, complex)):
225+
if not isinstance(other, COEFFICIENT_TYPES):
221226
return NotImplemented
222227

223228
for term in self.terms:
@@ -275,6 +280,12 @@ def __repr__(self):
275280
return 'MajoranaOperator.from_dict(terms={!r})'.format(self.terms)
276281

277282

283+
# Types accepted by MajoranaOperator multiplication: another MajoranaOperator or
284+
# a scalar coefficient. Defined here, after the class, so the tuple is built once
285+
# at import rather than on every __mul__/__imul__ call.
286+
_MAJORANA_MUL_TYPES = (MajoranaOperator,) + COEFFICIENT_TYPES
287+
288+
278289
def _sort_majorana_term(term):
279290
"""Sort a Majorana term.
280291

src/openfermion/ops/operators/majorana_operator_test.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,27 @@ def test_majorana_operator_str():
238238
def test_majorana_operator_repr():
239239
a = MajoranaOperator((0, 1, 5), 1.5)
240240
assert repr(a) == 'MajoranaOperator.from_dict(terms={(0, 1, 5): 1.5})'
241+
242+
243+
def test_majorana_operator_numpy_scalar_coefficients():
244+
"""NumPy scalar coefficients behave like Python scalars (issue #1097)."""
245+
op = MajoranaOperator((0, 1), 1.0) + MajoranaOperator((2, 3), 2.0)
246+
cases = [(numpy.int64(2), 2), (numpy.float32(0.5), 0.5), (numpy.complex64(1 + 2j), 1 + 2j)]
247+
for numpy_scalar, python_scalar in cases:
248+
assert op * numpy_scalar == op * python_scalar
249+
assert numpy_scalar * op == python_scalar * op
250+
assert op + numpy_scalar == op + python_scalar
251+
assert op - numpy_scalar == op - python_scalar
252+
assert op / numpy_scalar == op / python_scalar
253+
assert op.commutes_with(numpy.int64(5))
254+
255+
# In-place operators, using exact values so the comparison is not subject
256+
# to float32 round-off across the sequence of operations.
257+
for numpy_scalar, python_scalar in [(numpy.int64(2), 2), (numpy.float32(0.5), 0.5)]:
258+
numpy_op = MajoranaOperator((0, 1), 1.0) + MajoranaOperator((2, 3), 2.0)
259+
python_op = MajoranaOperator((0, 1), 1.0) + MajoranaOperator((2, 3), 2.0)
260+
numpy_op *= numpy_scalar
261+
python_op *= python_scalar
262+
numpy_op /= numpy_scalar
263+
python_op /= python_scalar
264+
assert numpy_op == python_op

src/openfermion/ops/representations/doci_hamiltonian.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313

1414
import numpy
1515

16+
import openfermion.config as config
1617
from openfermion.ops import QubitOperator
1718
from openfermion.ops.representations import PolynomialTensor, get_tensors_from_integrals
1819

19-
COEFFICIENT_TYPES = (int, float, complex)
20+
COEFFICIENT_TYPES = config.COEFFICIENT_TYPES
2021

2122

2223
class DOCIHamiltonian(PolynomialTensor):

src/openfermion/ops/representations/doci_hamiltonian_test.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,18 @@ def test_from_integrals_to_qubit(self):
277277
+ "Hamiltonian\n"
278278
+ str(sub_matrix)
279279
)
280+
281+
def test_numpy_scalar_coefficients(self):
282+
"""NumPy scalar coefficients behave like Python scalars (issue #1097)."""
283+
doci = DOCIHamiltonian(
284+
1.0,
285+
numpy.array([1.0, 2.0]),
286+
numpy.array([[0.0, 0.5], [0.5, 0.0]]),
287+
numpy.array([[0.0, 0.3], [0.3, 0.0]]),
288+
)
289+
cases = [(numpy.int64(2), 2), (numpy.float32(0.5), 0.5)]
290+
for numpy_scalar, python_scalar in cases:
291+
self.assertEqual(doci * numpy_scalar, doci * python_scalar)
292+
self.assertEqual(doci + numpy_scalar, doci + python_scalar)
293+
self.assertEqual(doci - numpy_scalar, doci - python_scalar)
294+
self.assertEqual(doci / numpy_scalar, doci / python_scalar)

src/openfermion/ops/representations/polynomial_tensor.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@
1818

1919
import numpy
2020

21-
from openfermion.config import EQ_TOLERANCE
21+
import openfermion.config as config
2222

23-
COEFFICIENT_TYPES = (int, float, complex)
23+
EQ_TOLERANCE = config.EQ_TOLERANCE
24+
COEFFICIENT_TYPES = config.COEFFICIENT_TYPES
2425

2526

2627
class PolynomialTensorError(Exception):

src/openfermion/ops/representations/polynomial_tensor_test.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,3 +536,19 @@ def do_rotate_basis_high_order(self, order):
536536
polynomial_tensor.rotate_basis(numpy.array([[rotation]]))
537537

538538
return polynomial_tensor, want_polynomial_tensor
539+
540+
def test_numpy_scalar_coefficients(self):
541+
"""NumPy scalar coefficients behave like Python scalars (issue #1097)."""
542+
tensor = PolynomialTensor(
543+
{
544+
(): 1.0,
545+
(1, 0): numpy.array([[1.0, 2.0], [3.0, 4.0]]),
546+
(1, 1, 0, 0): numpy.arange(16, dtype=float).reshape((2, 2, 2, 2)),
547+
}
548+
)
549+
cases = [(numpy.int64(2), 2), (numpy.int32(3), 3), (numpy.float32(0.5), 0.5)]
550+
for numpy_scalar, python_scalar in cases:
551+
self.assertEqual(tensor * numpy_scalar, tensor * python_scalar)
552+
self.assertEqual(tensor + numpy_scalar, tensor + python_scalar)
553+
self.assertEqual(tensor - numpy_scalar, tensor - python_scalar)
554+
self.assertEqual(tensor / numpy_scalar, tensor / python_scalar)

0 commit comments

Comments
 (0)