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 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 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)
using legacy validation callback
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.6725000000000008
Error/Variance: 0.10504362406390137
Result: circuit 1, IBMDevice, AER_SIMULATOR
Expectation value: -3.5305
Error/Variance: 0.24999508809341145
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 import pI, pX, pY, pZ
A PauliString is based on the following hierarchy:
an atom is the most elemental building brick of pauli string, it it either
I,X,YorZ;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 as0.3 * PI@PZ@PY;a string is a sum of monomials, for instance
0.3 * PI@PZ@PY + PX@PX@PX.
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 = pI@pZ - 3 * pX@pY
print(f"{ps_1=}")
ps_1=pI@pZ - 3*pX@pY
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 * pI @ pX+ k * pZ@ pY
print(ps_symbol)
(θ)*pI@pX + (k)*pZ@pY
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*pI@pX - pZ@pY
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 = pI@pZ+ 2.555555555*pY@pI + pX@pZ- pX@pZ
print("ps_2 =",repr(ps_2))
print(" =",repr(ps_2.simplify()))
print(" ~=",repr(ps_2.round(1)))
print(" ~=",ps_2)
ps_2 = pI@pZ + 2.555555555*pY@pI + pX@pZ - pX@pZ
= 2.555555555*pY@pI + pI@pZ
~= pI@pZ + 2.6*pY@pI + pX@pZ - pX@pZ
~= pI@pZ + 2.5556*pY@pI
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@pZ}
({ps_1}) @ ({ps_2}) = {ps_1@ps_2}""")
Addition:
(pI@pZ - 3*pX@pY) + (pI@pZ + 2.6*pY@pI) = 2*pI@pZ - 3*pX@pY + 2.6*pY@pI
Subtraction:
(pI@pZ - 3*pX@pY) - (pI@pZ + 2.6*pY@pI) = -3*pX@pY - 2.6*pY@pI
Scalar product:
2 * (pI@pZ - 3*pX@pY) = 2*pI@pZ - 6*pX@pY
Scalar division:
(pI@pZ + 2.6*pY@pI) / 3 ~= 0.3333*pI@pZ + 0.8667*pY@pI
Tensor product:
(pI@pZ - 3*pX@pY) @ Z = pI@pZ@pZ - 3*pX@pY@pZ
(pI@pZ - 3*pX@pY) @ (pI@pZ + 2.6*pY@pI) = pI@pZ@pI@pZ + 2.6*pI@pZ@pY@pI - 3*pX@pY@pI@pZ - 7.8*pX@pY@pY@pI
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*pI@pI + 3.5*pI@pX + pI@pZ + 1.5*pX@pI + 4.5*pX@pX + 1.5*pX@pZ - 3.5*pY@pY - 1.5*pZ@pX + 2.5*pZ@pZ
`obs1` created with Pauli string:
Pauli string:
pI@pZ - 3*pX@pY
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.13400000000000004
Error/Variance: 0.09995464436898834
Result: circuit 1, IBMDevice, AER_SIMULATOR
Expectation value: 0.0019980019980019963
Error/Variance: 0.12642405977777235
Result: circuit 1, ATOSDevice, MYQLM_CLINALG
Expectation value: 0.06400000000000006
Error/Variance: 0.10003879127498373
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*pI@pI + 2.5*pI@pZ - pZ@pZ
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, 2, 3, 8], [ 2, -3, 1, 0], [ 3, 1, -1, 5], [ 8, 0, 5, 2]]), '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]]), 'observable_1'), Observable([ 1., -2., 3., -4.], 'observable_2')], 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: 10.763157894736842
Error/Variance: 0.23068246140404594
observable_1:
Expectation value: 0.14605263157894738
Error/Variance: 0.102511259771643
observable_2:
Expectation value: -1.4901315789473684
Error/Variance: 0.06412314742510124
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
[24]:
from mpqp import IBMSimulatedDevice
result = run(circuit, IBMSimulatedDevice.FakeKyiv)
print(result)
Result: IBMSimulatedDevice, FakeKyiv
observable_0:
Expectation value: 9.550657894736842
Error/Variance: 0.3734382228413706
observable_1:
Expectation value: 0.07105263157894737
Error/Variance: 0.10258158095180858
observable_2:
Expectation value: -1.2664473684210527
Error/Variance: 0.07894549448937653