Reduced Density Matrices

Reduced Density Matrices (RDMs) are a use case where measurement reduction in combination with computables is very useful. Naively, we can evaluate each term in the n-body RDM by evaluating many ExpectationValue computables independently on each spin-traced excitation operator. However, across the entire RDM, this would result in many redundant measurements, making it advantageous to create a single, composite computable for an RDM ndarray and use a protocol to build the optimal number of circuits for the whole RDM.

To build a 1-RDM operator, we can use InQuanto’s FermionSpace:

import numpy
from inquanto.spaces import FermionSpace

space = FermionSpace(4)
rdm_operators = space.construct_one_body_spatial_rdm_operators((FermionSpace.SPIN_UP, FermionSpace.SPIN_UP))
print(rdm_operators)
print(type(rdm_operators))
[[{((0, 1), (0, 0)): 1.0} {((0, 1), (2, 0)): 1.0}]
 [{((2, 1), (0, 0)): 1.0} {((2, 1), (2, 0)): 1.0}]]
<class 'numpy.ndarray'>

As a result, we obtain a numpy ndarray of FermionOperator objects. To compute the expectation values, we also need a specific state. In this example, we will use the TrotterAnsatz:

from inquanto.ansatzes import TrotterAnsatz
from inquanto.operators import QubitOperator, QubitOperatorList
from inquanto.states import QubitState

ansatz = TrotterAnsatz(
    QubitOperatorList.from_list([QubitOperator("Y0 X1 X2 X3", 1j)]),
    QubitState([1, 1, 0, 0]),
)

parameters = ansatz.state_symbols.construct_from_array([0.11])

With the ansatz and RDM operators, we can construct a single computable for the RDM:

from inquanto.computables import ExpectationValueNonHermitian
from inquanto.computables.primitive import ComputableNDArray

# We make a broadcast function over the ndarray that converts every element into
# an ExpectationValueNonHermitian computable
ndarray_broadcast = numpy.vectorize(
    lambda operator: ExpectationValueNonHermitian(ansatz, operator.qubit_encode())
)

# Then, we can convert the ndarray computables to a ComputableNDArray of computables
# to ensure that we can run and evaluate recursively.
rdm_computable = ComputableNDArray(ndarray_broadcast(rdm_operators))

print(rdm_computable)
print(type(rdm_computable))
[[ExpectationValueNonHermitian(state=<inquanto.ansatzes._trotter_ansatz.TrotterAnsatz, qubits=4, gates=3, symbols=1>, kernel={(): np.float64(0.5), (Zq[0]): np.float64(-0.5)})
  ExpectationValueNonHermitian(state=<inquanto.ansatzes._trotter_ansatz.TrotterAnsatz, qubits=4, gates=3, symbols=1>, kernel={(Yq[0], Zq[1], Xq[2]): (-0-0.25j), (Yq[0], Zq[1], Yq[2]): 0.25, (Xq[0], Zq[1], Xq[2]): 0.25, (Xq[0], Zq[1], Yq[2]): 0.25j})]
 [ExpectationValueNonHermitian(state=<inquanto.ansatzes._trotter_ansatz.TrotterAnsatz, qubits=4, gates=3, symbols=1>, kernel={(Yq[0], Zq[1], Xq[2]): 0.25j, (Xq[0], Zq[1], Xq[2]): 0.25, (Yq[0], Zq[1], Yq[2]): 0.25, (Xq[0], Zq[1], Yq[2]): (-0-0.25j)})
  ExpectationValueNonHermitian(state=<inquanto.ansatzes._trotter_ansatz.TrotterAnsatz, qubits=4, gates=3, symbols=1>, kernel={(): 0.5, (Zq[2]): -0.5})]]
<class 'inquanto.computables.primitive.collections._ndarray.ComputableNDArray'>

With this RDM computable, we can use protocols to create an evaluator function. We first use default_evaluate() to perform a statevector calculation as a test:

print(rdm_computable.default_evaluate(parameters))
[[(0.9879487246653031+0j) (1.7242389772158059e-18+3.489965711775505e-32j)]
 [(1.7242389772158059e-18-3.489965711775505e-32j)
  (0.012051275334697192+0j)]]

Now, we use the shot-based PauliAveraging protocol to evaluate the RDM computable:

from pytket.extensions.qiskit import AerBackend
from inquanto.protocols import PauliAveraging
from pytket.partition import PauliPartitionStrat

# We can use the PauliAveraging protocol to get an evaluator
evaluator = (
    PauliAveraging(
        AerBackend(),
        shots_per_circuit = 10000,
        pauli_partition_strategy=PauliPartitionStrat.CommutingSets
    )
    .build_from(parameters, rdm_computable)
    .compile_circuits()
    .run(seed=0)
    .get_evaluator()
)

rdm_measured = rdm_computable.evaluate(evaluator)

print(rdm_measured)
[[(0.9867+0j) (0.0029+0.0041j)]
 [(0.0029-0.0041j) (0.013299999999999979+0j)]]

InQuanto provides built-in support for RDM computables such as the above, for example with the RestrictedOneBodyRDMComputable class, which evaluates to a RestrictedOneBodyRDM. A suite of other density matrix composite computables are supported, for instance, the SpinlessNBodyRDMArrayRealComputable can build n-body RDMs and reduce measurement circuits further by taking symmetries into account:

from inquanto.ansatzes import FermionSpaceAnsatzChemicallyAwareUCCSD
from inquanto.computables.composite import SpinlessNBodyRDMArrayRealComputable
from inquanto.express import load_h5
from inquanto.mappings import QubitMappingJordanWigner

from inquanto.spaces import QubitSpace, FermionSpace
from inquanto.states import FermionState

qubit_hamiltonian = load_h5(
    "h2_631g_symmetry.h5", as_tuple=True
).hamiltonian_operator.qubit_encode()

space = FermionSpace(
    8,
    point_group="D2h",
    orb_irreps=numpy.asarray(["Ag", "Ag", "B1u", "B1u", "Ag", "Ag", "B1u", "B1u"]),
)

ansatz = FermionSpaceAnsatzChemicallyAwareUCCSD(
    fermion_space=space, fermion_state=FermionState([1, 1, 0, 0, 0, 0, 0, 0])
)

qubit_space = QubitSpace(space.n_spin_orb)

symmetry_operators = qubit_space.symmetry_operators_z2(qubit_hamiltonian)

rdm1 = SpinlessNBodyRDMArrayRealComputable(
    1,
    space,
    ansatz,
    QubitMappingJordanWigner(),
    symmetry_operators,
)

print(rdm1)
<inquanto.computables.composite.rdm._rdm_real.SpinlessNBodyRDMArrayRealComputable object at 0x7f4664d92850>

Above, we used the express module to load a Hamiltonian, and use a Fermion space to generate the Z2 symmetries of our Hamiltonian. The SpinlessNBodyRDMArrayRealComputable uses the symmetry information to filter for Z2 symmetry.