From 77b9b6103cb0a938b2000e6dd7995f10942b66a6 Mon Sep 17 00:00:00 2001 From: "Kevin J. Sung" Date: Wed, 10 Jul 2024 18:53:39 -0400 Subject: [PATCH] add qiskit jordan-wigner (#281) --- python/ffsim/qiskit/__init__.py | 2 + python/ffsim/qiskit/jordan_wigner.py | 77 ++++++++++++++++++ python/ffsim/random/__init__.py | 4 + python/ffsim/random/random.py | 96 ++++++++++++++++++++++- tests/python/qiskit/jordan_wigner_test.py | 77 ++++++++++++++++++ 5 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 python/ffsim/qiskit/jordan_wigner.py create mode 100644 tests/python/qiskit/jordan_wigner_test.py diff --git a/python/ffsim/qiskit/__init__.py b/python/ffsim/qiskit/__init__.py index 3c6bb0113..fa9ab9370 100644 --- a/python/ffsim/qiskit/__init__.py +++ b/python/ffsim/qiskit/__init__.py @@ -34,6 +34,7 @@ UCJOpSpinlessJW, UCJOpSpinUnbalancedJW, ) +from ffsim.qiskit.jordan_wigner import jordan_wigner from ffsim.qiskit.sampler import FfsimSampler from ffsim.qiskit.sim import final_state_vector from ffsim.qiskit.transpiler_passes import DropNegligible, MergeOrbitalRotations @@ -75,6 +76,7 @@ "UCJOpSpinlessJW", "ffsim_vec_to_qiskit_vec", "final_state_vector", + "jordan_wigner", "pre_init_passes", "qiskit_vec_to_ffsim_vec", ] diff --git a/python/ffsim/qiskit/jordan_wigner.py b/python/ffsim/qiskit/jordan_wigner.py new file mode 100644 index 000000000..ba897adbe --- /dev/null +++ b/python/ffsim/qiskit/jordan_wigner.py @@ -0,0 +1,77 @@ +# (C) Copyright IBM 2024. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Jordan-Wigner transformation.""" + +from __future__ import annotations + +import functools + +from qiskit.quantum_info import SparsePauliOp + +from ffsim.operators import FermionOperator + + +def jordan_wigner(op: FermionOperator, n_qubits: int | None = None) -> SparsePauliOp: + """Jordan-Wigner transformation. + + Transform a fermion operator to a qubit operator using the Jordan-Wigner + transformation. + + Args: + op: The fermion operator to transform. + n_qubits: The number of qubits to include in the output qubit operator. If not + specified, the minimum number of qubits needed to accommodate the fermion + operator will be used. Must be non-negative. + + Returns: + The qubit operator as a Qiskit SparsePauliOp. + + Raises: + ValueError: Number of qubits was negative. + ValueError: Number of qubits was not enough to accommodate the fermion operator. + """ + if n_qubits and n_qubits < 0: + raise ValueError(f"Number of qubits must be non-negative. Got {n_qubits}.") + if not op: + return SparsePauliOp.from_sparse_list([("", [], 0.0)], num_qubits=n_qubits or 0) + + norb = 1 + max(orb for term in op for _, _, orb in term) + if n_qubits is None: + n_qubits = 2 * norb + if n_qubits < 2 * norb: + raise ValueError( + "Number of qubits is not enough to accommodate the fermion operator. " + f"The fermion operator has {norb} spatial orbitals, so at least {2 * norb} " + f"qubits is needed, but got {n_qubits}." + ) + + qubit_terms = [SparsePauliOp.from_sparse_list([("", [], 0.0)], num_qubits=n_qubits)] + for term, coeff in op.items(): + qubit_op = SparsePauliOp.from_sparse_list( + [("", [], coeff)], num_qubits=n_qubits + ) + for action, spin, orb in term: + qubit_op @= _qubit_action(action, orb + spin * norb, n_qubits) + qubit_terms.append(qubit_op) + + return SparsePauliOp.sum(qubit_terms) + + +@functools.cache +def _qubit_action(action: bool, qubit: int, n_qubits: int): + qubits = list(range(qubit + 1)) + return SparsePauliOp.from_sparse_list( + [ + ("Z" * qubit + "X", qubits, 0.5), + ("Z" * qubit + "Y", qubits, -0.5j if action else 0.5j), + ], + num_qubits=n_qubits, + ) diff --git a/python/ffsim/random/__init__.py b/python/ffsim/random/__init__.py index 55e6189fc..adbe339a3 100644 --- a/python/ffsim/random/__init__.py +++ b/python/ffsim/random/__init__.py @@ -13,6 +13,8 @@ from ffsim.random.random import ( random_antihermitian, random_double_factorized_hamiltonian, + random_fermion_hamiltonian, + random_fermion_operator, random_hermitian, random_molecular_hamiltonian, random_orthogonal, @@ -32,6 +34,8 @@ __all__ = [ "random_antihermitian", "random_double_factorized_hamiltonian", + "random_fermion_hamiltonian", + "random_fermion_operator", "random_hermitian", "random_molecular_hamiltonian", "random_orthogonal", diff --git a/python/ffsim/random/random.py b/python/ffsim/random/random.py index cce61b61b..8a341cb8b 100644 --- a/python/ffsim/random/random.py +++ b/python/ffsim/random/random.py @@ -11,11 +11,12 @@ from __future__ import annotations import itertools +from collections import defaultdict import numpy as np from typing_extensions import deprecated -from ffsim import hamiltonians, variational +from ffsim import hamiltonians, operators, variational @deprecated( @@ -508,3 +509,96 @@ def random_double_factorized_hamiltonian( constant=constant, z_representation=z_representation, ) + + +def random_fermion_operator( + norb: int, n_terms: int | None = None, max_term_length: int | None = None, seed=None +) -> operators.FermionOperator: + """Sample a random fermion operator. + + Args: + norb: The number of spatial orbitals. + n_terms: The number of terms to include in the operator. If not specified, + `norb` is used. + max_term_length: The maximum length of a term. If not specified, `norb` is used. + seed: A seed to initialize the pseudorandom number generator. + Should be a valid input to ``np.random.default_rng``. + + Returns: + The sampled fermion operator. + """ + rng = np.random.default_rng(seed) + if n_terms is None: + n_terms = norb + if max_term_length is None: + max_term_length = norb + coeffs: defaultdict[tuple[tuple[bool, bool, int], ...], complex] = defaultdict( + complex + ) + for _ in range(n_terms): + term_length = int(rng.integers(1, max_term_length + 1)) + actions = [bool(i) for i in rng.integers(2, size=term_length)] + spins = [bool(i) for i in rng.integers(2, size=term_length)] + indices = [int(i) for i in rng.integers(norb, size=term_length)] + coeff = rng.standard_normal() + 1j * rng.standard_normal() + term = tuple(zip(actions, spins, indices)) + coeffs[term] += coeff + return operators.FermionOperator(coeffs) + + +def random_fermion_hamiltonian( + norb: int, n_terms: int | None = None, seed=None +) -> operators.FermionOperator: + """Sample a random fermion Hamiltonian. + + A fermion Hamiltonian is hermitian and conserves particle number and spin Z. + + Args: + norb: The number of spatial orbitals. + n_terms: The number of terms to include in the operator. If not specified, + `norb` is used. + seed: A seed to initialize the pseudorandom number generator. + Should be a valid input to ``np.random.default_rng``. + + Returns: + The sampled fermion Hamiltonian. + """ + rng = np.random.default_rng(seed) + if n_terms is None: + n_terms = norb + coeffs: defaultdict[tuple[tuple[bool, bool, int], ...], complex] = defaultdict( + complex + ) + for _ in range(n_terms): + n_excitations = int(rng.integers(1, norb + 1)) + term = _random_num_and_spin_z_conserving_term(norb, n_excitations, seed=rng) + term_adjoint = _adjoint_term(term) + coeff = rng.standard_normal() + 1j * rng.standard_normal() + coeffs[term] += coeff + coeffs[term_adjoint] += coeff.conjugate() + return operators.FermionOperator(coeffs) + + +def _random_num_and_spin_z_conserving_term( + norb: int, n_excitations: int, seed=None +) -> tuple[tuple[bool, bool, int], ...]: + rng = np.random.default_rng(seed) + term = [] + for _ in range(n_excitations): + spin = bool(rng.integers(2)) + orb_1, orb_2 = [int(x) for x in rng.integers(norb, size=2)] + action_1, action_2 = [ + bool(x) for x in rng.choice([True, False], size=2, replace=False) + ] + term.append(operators.FermionAction(action_1, spin, orb_1)) + term.append(operators.FermionAction(action_2, spin, orb_2)) + return tuple(term) + + +def _adjoint_term( + term: tuple[tuple[bool, bool, int], ...], +) -> tuple[tuple[bool, bool, int], ...]: + return tuple( + operators.FermionAction(bool(1 - action), spin, orb) + for action, spin, orb in reversed(term) + ) diff --git a/tests/python/qiskit/jordan_wigner_test.py b/tests/python/qiskit/jordan_wigner_test.py new file mode 100644 index 000000000..b79979f0b --- /dev/null +++ b/tests/python/qiskit/jordan_wigner_test.py @@ -0,0 +1,77 @@ +# (C) Copyright IBM 2024. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Tests for Jordan-Wigner transformation.""" + +import numpy as np +import pytest + +import ffsim +import ffsim.random.random + + +@pytest.mark.parametrize("norb, nelec", ffsim.testing.generate_norb_nelec(range(5))) +def test_random(norb: int, nelec: tuple[int, int]): + """Test on random fermion Hamiltonian.""" + rng = np.random.default_rng(4482) + op = ffsim.random.random_fermion_hamiltonian(norb, seed=rng) + linop = ffsim.linear_operator(op, norb=norb, nelec=nelec) + vec = ffsim.random.random_state_vector(ffsim.dim(norb, nelec), seed=rng) + + qubit_op = ffsim.qiskit.jordan_wigner(op) + qubit_op_sparse = qubit_op.to_matrix(sparse=True) + actual_result = qubit_op_sparse @ ffsim.qiskit.ffsim_vec_to_qiskit_vec( + vec, norb, nelec + ) + expected_result = ffsim.qiskit.ffsim_vec_to_qiskit_vec(linop @ vec, norb, nelec) + np.testing.assert_allclose(actual_result, expected_result, atol=1e-12) + + qubit_op = ffsim.qiskit.jordan_wigner(op, n_qubits=2 * norb) + qubit_op_sparse = qubit_op.to_matrix(sparse=True) + actual_result = qubit_op_sparse @ ffsim.qiskit.ffsim_vec_to_qiskit_vec( + vec, norb, nelec + ) + np.testing.assert_allclose(actual_result, expected_result, atol=1e-12) + + +def test_bad_qubit_number(): + """Test passing bad number of qubits raises errors.""" + op = ffsim.FermionOperator({(ffsim.cre_a(3),): 1.0}) + with pytest.raises(ValueError, match="non-negative"): + _ = ffsim.qiskit.jordan_wigner(op, n_qubits=-1) + with pytest.raises(ValueError, match="enough"): + _ = ffsim.qiskit.jordan_wigner(op, n_qubits=7) + + +def test_hubbard(): + """Test on Hubbard model""" + rng = np.random.default_rng(7431) + norb_x = 2 + norb_y = 2 + norb = norb_x * norb_y + nelec = (norb // 2, norb // 2) + op = ffsim.fermi_hubbard_2d( + norb_x=norb_x, + norb_y=norb_y, + tunneling=rng.uniform(-10, 10), + interaction=rng.uniform(-10, 10), + chemical_potential=rng.uniform(-10, 10), + nearest_neighbor_interaction=rng.uniform(-10, 10), + periodic=False, + ) + linop = ffsim.linear_operator(op, norb=norb, nelec=nelec) + vec = ffsim.random.random_state_vector(ffsim.dim(norb, nelec), seed=rng) + qubit_op = ffsim.qiskit.jordan_wigner(op) + qubit_op_sparse = qubit_op.to_matrix(sparse=True) + actual_result = qubit_op_sparse @ ffsim.qiskit.ffsim_vec_to_qiskit_vec( + vec, norb, nelec + ) + expected_result = ffsim.qiskit.ffsim_vec_to_qiskit_vec(linop @ vec, norb, nelec) + np.testing.assert_allclose(actual_result, expected_result)