Computing expectation values of observables
In addition of sampling the circuit in a basis or extracting the state-vector in the ideal case, MPQP also allows you to compute expectation values of observables with respect to the state generated by the circuit.
To demonstrate this, let us create a circuit generating a 2-qubit state.
[1]:
from mpqp import QCircuit
from mpqp.gates import *
circuit = QCircuit([H(0), CNOT(0,1), Ry(2.6, 0), Ry(-0.87, 1)])
print(circuit)
┌───┐ ┌─────────┐
q_0: ┤ H ├──■───┤ Ry(2.6) ├─
└───┘┌─┴─┐┌┴─────────┴┐
q_1: ─────┤ X ├┤ Ry(-0.87) ├
└───┘└───────────┘
We then import the two needed objects: Observable and ExpectationMeasure. The first takes as parameter a hermitian matrix (as a numpy array), and the second is the measure to be added to the circuit.
[2]:
from mpqp.measures import Observable, ExpectationMeasure
[3]:
import numpy as np
from mpqp.tools.maths import is_hermitian
matrix = np.array([[4, 2, 3, 8],
[2, -3, 1, 0],
[3, 1, -1, 5],
[8, 0, 5, 2]])
is_hermitian(matrix)
[3]:
True
[4]:
obs = Observable(matrix)
The ExpectationMeasure takes as parameter the list of qubits corresponding to the observable. The indices of qubits can be given non-ordered, non-contigous and restricted to some qubits, and MPQP will automatically operate on the circuit and the observable to handle that. One has also to precise the number of shots, when sampling the circuit to compute the expectation. If the number of shots is zero (by default), the exact value is returned.
[5]:
circuit.add(ExpectationMeasure(obs))
[6]:
from mpqp.execution import run, ATOSDevice, IBMDevice
results = run(circuit, [ATOSDevice.MYQLM_PYLINALG, IBMDevice.AER_SIMULATOR])
print(results)
for result in results:
print(f"expectation_value:", result.expectation_values)
BatchResult: 2 results
Result: circuit 1, ATOSDevice, MYQLM_PYLINALG
Expectation value: -3.5935083233096687
Error/Variance: 0.0
Result: circuit 1, IBMDevice, AER_SIMULATOR
Expectation value: -3.5935083233096723
Error/Variance: 0.0
expectation_value: -3.5935083233096687
expectation_value: -3.5935083233096723
[7]:
circuit = circuit.without_measurements()
circuit.add(ExpectationMeasure(obs, shots=2000))
[8]:
results = run(circuit, [ATOSDevice.MYQLM_PYLINALG, IBMDevice.AER_SIMULATOR])
print(results)
BatchResult: 2 results
Result: circuit 1, ATOSDevice, MYQLM_PYLINALG
Expectation value: -3.608
Error/Variance: 0.10518192153744461
Result: circuit 1, IBMDevice, AER_SIMULATOR
Expectation value: -3.6804999999999994
Error/Variance: 0.2486218520511826
Pauli String representation
An observable can also be represented by a Pauli string. This section will demonstrate how to create and manipulate PauliString objects.
[9]:
from mpqp.measures import I, X, Y, Z
⚠ pauli_string import: pauli atoms are named
I,X,Y, andZ. If you have conflicts with the gates of the same name, you can:
Rename Import:
from mpqp.measures import X as Pauli_X, Y as Pauli_Y ps = Pauli_X + Pauli_Y/2
Import the whole module:
from mpqp.measures import pauli_string ps = pauli_string.X + pauli_string.Y/2
A PauliString is based on the following hierarchy: - an atom is the most elemental building brick of pauli string, it it either I, X, Y or Z; - a monomial is a tensor product of atoms multiplied by a real coefficient, for instance 0.3 * I⊗Z⊗Y. In MPQP, the tensor product is denoted as @, so the previous example would be expressed as 0.3 * I@Z@Y; - a string is a sum of monomials, for instance 0.3 * I@Z@Y + X@X@X.
In practice, you never need to handle these types independently, you can express everything in term of expression based on the fundamental atoms. Let’s see how.
Creating and Manipulating PauliString Objects
Let’s illustrate how to create and manipulate PauliString objects:
[10]:
ps_1 = I@Z - 3 * X@Y
print(f"{ps_1=}")
ps_1=I@Z - 3*X@Y
Using Symbolic Variables in a PauliString
We can also define PauliString objects with symbolic coefficients using sympy:
[11]:
from sympy import symbols
theta, k = symbols("θ k")
ps_symbol = theta * I @ X + k * Z @ Y
print(ps_symbol)
(θ)*I@X + (k)*Z@Y
We can substitute numerical values for the symbolic coefficients:
[12]:
ps_symbol = ps_symbol.subs({theta: np.pi, k: -1})
print(ps_symbol)
3.1416*I@X - Z@Y
Simplifying and Rounding PauliStrings
When dealing with PauliStrings, simplification and rounding are common operations:
Simplify: We combine like terms and eliminate those with zero coefficients;
Round: We can round the coefficients to a specified number of decimals (5 by default).
stron aPauliStringwill call both methods:round()andsimplify()
[13]:
ps_2 = I@Z + 2.555555555*Y@I + X@Z - X@Z
print("ps_2 =",repr(ps_2))
print(" =",repr(ps_2.simplify()))
print(" ~=",repr(ps_2.round(1)))
print(" ~=",ps_2)
ps_2 = I@Z + 2.555555555*Y@I + X@Z - X@Z
= 2.555555555*Y@I + I@Z
~= I@Z + 2.6*Y@I + X@Z - X@Z
~= I@Z + 2.5556*Y@I
Arithmetic Operations
We can perform various arithmetic operations on PauliString objects, including addition, subtraction, scalar multiplication, scalar division, and matrix multiplication:
[14]:
ps_2 = ps_2.round(1).simplify()
print(f"""Addition:
({ps_1}) + ({ps_2}) = {ps_1 + ps_2}
Subtraction:
({ps_1}) - ({ps_2}) = {ps_1 - ps_2}
Scalar product:
2 * ({ps_1}) = {2 * ps_1}
Scalar division:
({ps_2}) / 3 ~= {ps_2 / 3}
Tensor product:
({ps_1}) @ Z = {ps_1@Z}
({ps_1}) @ ({ps_2}) = {ps_1@ps_2}""")
Addition:
(I@Z - 3*X@Y) + (I@Z + 2.6*Y@I) = 2*I@Z - 3*X@Y + 2.6*Y@I
Subtraction:
(I@Z - 3*X@Y) - (I@Z + 2.6*Y@I) = -3*X@Y - 2.6*Y@I
Scalar product:
2 * (I@Z - 3*X@Y) = 2*I@Z - 6*X@Y
Scalar division:
(I@Z + 2.6*Y@I) / 3 ~= 0.3333*I@Z + 0.8667*Y@I
Tensor product:
(I@Z - 3*X@Y) @ Z = I@Z@Z - 3*X@Y@Z
(I@Z - 3*X@Y) @ (I@Z + 2.6*Y@I) = I@Z@I@Z + 2.6*I@Z@Y@I - 3*X@Y@I@Z - 7.8*X@Y@Y@I
Create an Observable with a Pauli string
[15]:
obs1 = Observable(ps_1)
Since there is an equivalence between definition from a Pauli string or from a Hermitian matrix, both these observable wan can also be expressed in term of the mean through which it was not defined (Pauli for matrix and vice versa):
[16]:
from mpqp.tools import pprint
print("`obs` created with matrix:")
print("matrix:")
pprint(obs.matrix)
print("Pauli string:")
print(obs.pauli_string)
print("\n\n`obs1` created with Pauli string:")
print("Pauli string:")
print(obs1.pauli_string)
print("matrix:")
pprint(obs1.matrix)
`obs` created with matrix:
matrix:
[[4, 2 , 3 , 8],
[2, -3, 1 , 0],
[3, 1 , -1, 5],
[8, 0 , 5 , 2]]
Pauli string:
0.5*I@I + 3.5*I@X + I@Z + 1.5*X@I + 4.5*X@X + 1.5*X@Z - 3.5*Y@Y - 1.5*Z@X + 2.5*Z@Z
`obs1` created with Pauli string:
Pauli string:
I@Z - 3*X@Y
matrix:
[[1 , 0 , 0 , -3j],
[0 , -1 , 3j, 0 ],
[0 , -3j, 1 , 0 ],
[3j, 0 , 0 , -1 ]]
ExpectationMeasure
Next, we measure the expectation value of the observable by adding it to the circuit.
[17]:
circuit = circuit.without_measurements()
circuit.add(ExpectationMeasure(obs1, shots=1000))
[18]:
results = run(
circuit,
[
ATOSDevice.MYQLM_PYLINALG,
IBMDevice.AER_SIMULATOR,
ATOSDevice.MYQLM_CLINALG,
],
)
print(results)
BatchResult: 3 results
Result: circuit 1, ATOSDevice, MYQLM_PYLINALG
Expectation value: 0.08399999999999999
Error/Variance: 0.10001941753421624
Result: circuit 1, IBMDevice, AER_SIMULATOR
Expectation value: 0.0979020979020979
Error/Variance: 0.12636915584932398
Result: circuit 1, ATOSDevice, MYQLM_CLINALG
Expectation value: -0.07199999999999998
Error/Variance: 0.10003707020607619
Instantiating a diagonal Observable
In case of a diagonal observable, one can directly instantiate it using the list of diagonal elements of the associated matrix
[19]:
d_obs = Observable([1,-2,3,-4])
print(d_obs.is_diagonal)
print(d_obs.matrix)
print(d_obs.pauli_string)
True
[[ 1.+0.j 0.+0.j 0.+0.j 0.+0.j]
[ 0.+0.j -2.+0.j 0.+0.j 0.+0.j]
[ 0.+0.j 0.+0.j 3.+0.j 0.+0.j]
[ 0.+0.j 0.+0.j 0.+0.j -4.+0.j]]
-0.5*I@I + 2.5*I@Z - Z@Z
Evaulating the expectation value of several observables
One can also provide several observables to a single ExpectationMeasure to be measured together. This implies some Pauli grouping of Pauli operators, appearing in the decomposition of these observables based on commutativity relations, for reducing the number of measurements required.
[20]:
exp = ExpectationMeasure([obs, obs1, d_obs], shots=1520)
exp
[20]:
ExpectationMeasure([Observable(array([[ 4.+0.j, 2.+0.j, 3.+0.j, 8.+0.j], [ 2.+0.j, -3.+0.j, 1.+0.j, 0.+0.j], [ 3.+0.j, 1.+0.j, -1.+0.j, 5.+0.j], [ 8.+0.j, 0.+0.j, 5.+0.j, 2.+0.j]], dtype=complex64), 'observable_0'), Observable(array([[ 1.+0.j, 0.+0.j, 0.+0.j, 0.-3.j], [ 0.+0.j, -1.+0.j, 0.+3.j, 0.+0.j], [ 0.+0.j, 0.-3.j, 1.+0.j, 0.+0.j], [ 0.+3.j, 0.+0.j, 0.+0.j, -1.+0.j]], dtype=complex64), 'observable_0'), Observable([ 1., -2., 3., -4.], 'observable_1')], shots=1520)
[21]:
circuit = QCircuit([H(0), CNOT(0,1), exp])
print(circuit)
┌───┐
q_0: ┤ H ├──■──
└───┘┌─┴─┐
q_1: ─────┤ X ├
└───┘
[22]:
result = run(circuit, IBMDevice.AER_SIMULATOR)
print(result)
Result: IBMDevice, AER_SIMULATOR
observable_0:
Expectation value: 11.084210526315788
Error/Variance: 0.23074483188987932
observable_1:
Expectation value: 0.04078947368421053
Error/Variance: 0.10259248402893313
observable_2:
Expectation value: -1.4769736842105263
Error/Variance: 0.06412092702147147
Run the observable on a remote device
[23]:
result = run(circuit, IBMDevice.IBM_KYIV)
print(result)
Result: IBMDevice, IBM_KYIV
observable_0:
Expectation value: 10.718794588603448
Error/Variance: 0.12432673495731258
observable_1:
Expectation value: 0.030855517869115272
Error/Variance: 0.08328675390768145
observable_2:
Expectation value: -1.639708071833848
Error/Variance: 0.06492556195117463
[ ]:
from mpqp.execution import IBMSimulatedDevice
result = run(circuit, IBMSimulatedDevice.FakeKyiv)
print(result)
Result: IBMSimulatedDevice, FakeKyiv
observable_0:
Expectation value: 9.648026315789474
Error/Variance: 0.3721799182911548
observable_1:
Expectation value: 0.1394736842105263
Error/Variance: 0.10252736725618174
observable_2:
Expectation value: -1.205921052631579
Error/Variance: 0.07764093086863676