Reducing the Trotter error of Hamiltonian dynamics with multi-product formulas¶
In this notebook, you will learn how to use a Multi-Product Formula (MPF) to achieve a lower Trotter error on our observable compared to the one incurred by the deepest Trotter circuit that we will actually execute. You will do so by working through the steps of a Qiskit pattern:
Step 1: Map to quantum problem
Initialize our problem’s Hamiltonian
Use an MPF to generate the Trotterized time-evolution circuits
Step 2: Optimize the problem
Here we transpile our circuits for a GenericBackendV2
Step 3: Execute experiments
Use a StatevectorEstimator for sake of simplicity in this notebook
Step 4: Reconstruct results
Compute the MPF expectation value
Step 1: Map to quantum problem¶
1a: Setting up our Hamiltonian¶
We use the Ising model on a line of 10 sites:
where
The qiskit_addon_utils package provides some reusable functionalities for various purposes.
Its qiskit_addon_utils.problem_generators module provides functions to generate Heisenberg-like Hamiltonians on a given connectivity graph. This graph can be either a rustworkx.PyGraph or a CouplingMap making it easy to use in Qiskit-centric workflows.
In the following, we create a simple line of 10 qubits using the CouplingMap.from_line
method.
[1]:
from qiskit.transpiler import CouplingMap
# Generate some coupling map to use for this example
coupling_map = CouplingMap.from_line(10, bidirectional=False)
[2]:
from rustworkx.visualization import graphviz_draw
graphviz_draw(coupling_map.graph, method="circo")
[2]:

Next, we generate the SparsePauliOp on the provided connectivity with the desired constants.
[3]:
from qiskit_addon_utils.problem_generators import generate_xyz_hamiltonian
# Get a qubit operator describing the Ising field model
hamiltonian = generate_xyz_hamiltonian(
coupling_map,
coupling_constants=(0.0, 0.0, 1.0),
ext_magnetic_field=(0.4, 0.0, 0.0),
)
print(hamiltonian)
SparsePauliOp(['IIIIIIIZZI', 'IIIIIZZIII', 'IIIZZIIIII', 'IZZIIIIIII', 'IIIIIIIIZZ', 'IIIIIIZZII', 'IIIIZZIIII', 'IIZZIIIIII', 'ZZIIIIIIII', 'IIIIIIIIIX', 'IIIIIIIIXI', 'IIIIIIIXII', 'IIIIIIXIII', 'IIIIIXIIII', 'IIIIXIIIII', 'IIIXIIIIII', 'IIXIIIIIII', 'IXIIIIIIII', 'XIIIIIIIII'],
coeffs=[1. +0.j, 1. +0.j, 1. +0.j, 1. +0.j, 1. +0.j, 1. +0.j, 1. +0.j, 1. +0.j,
1. +0.j, 0.4+0.j, 0.4+0.j, 0.4+0.j, 0.4+0.j, 0.4+0.j, 0.4+0.j, 0.4+0.j,
0.4+0.j, 0.4+0.j, 0.4+0.j])
The observable that we will be measuring is the total magnetization which we can simply construct as shown below:
[4]:
from qiskit.quantum_info import SparsePauliOp
L = coupling_map.size()
observable = SparsePauliOp.from_sparse_list([("Z", [i], 1 / L / 2) for i in range(L)], num_qubits=L)
print(observable)
SparsePauliOp(['IIIIIIIIIZ', 'IIIIIIIIZI', 'IIIIIIIZII', 'IIIIIIZIII', 'IIIIIZIIII', 'IIIIZIIIII', 'IIIZIIIIII', 'IIZIIIIIII', 'IZIIIIIIII', 'ZIIIIIIIII'],
coeffs=[0.05+0.j, 0.05+0.j, 0.05+0.j, 0.05+0.j, 0.05+0.j, 0.05+0.j, 0.05+0.j,
0.05+0.j, 0.05+0.j, 0.05+0.j])
1b: Multi-Product Formulas¶
MPFs reduce the Trotter error of Hamiltonian dynamics through a weighted combination of several circuit executions.
To make this more concrete, we define an MPF as:
where
The key here is that the remaining Trotter error is smaller than the Trotter error that one would obtain by simply using the largest
You can view the usefulness of MPF from two perspectives:
For a fixed budget of Trotter steps that you are able to execute, you can obtain results with a Trotter error that is smaller in total.
For a number of Trotter steps which results in deep circuits, you can use MPF to find several shorter-depth circuits to run which result in a similar Trotter error.
An introduction to static MPFs¶
Static MPFs are those where the
Determining the static MPF coefficients for a given set of
We can find a solution for
In the following, you will learn how to do all of this.
Choosing ¶
The choice of
Here, we will simply pick some fixed values for
[5]:
time = 8.0
trotter_steps = (8, 12, 19)
Setting up the LSE¶
Now that we have chosen our symmetric=True
. However, this is not required as shown by Zhuk et al.,
2023.
Here, we are going to use a second-order Suzuki-Trotter formula yielding order=2
and we will set symmetric=True
.
[6]:
from qiskit_addon_mpf.static import setup_static_lse
lse = setup_static_lse(trotter_steps, order=2, symmetric=True)
print(lse)
LSE(A=array([[1.00000000e+00, 1.00000000e+00, 1.00000000e+00],
[1.56250000e-02, 6.94444444e-03, 2.77008310e-03],
[2.44140625e-04, 4.82253086e-05, 7.67336039e-06]]), b=array([1., 0., 0.]))
Solving for analytically¶
As mentioned before, we can find
[7]:
import numpy as np
coeffs_analytical = lse.solve()
print(coeffs_analytical)
[ 0.17239057 -1.19447005 2.02207947]
Optimizing for using an exact model¶
Alternatively to computing
In the next section, it will be clear why this interface exists.
[8]:
from qiskit_addon_mpf.costs import setup_exact_problem
model_exact, coeffs_exact = setup_exact_problem(lse)
model_exact.solve()
print(coeffs_exact.value)
[ 0.17239057 -1.19447005 2.02207947]
As an indicator whether an MPF constructed with these coefficients will yield good results, we can use the L1-norm (see also Carrera Vazquez et al., 2023).
[9]:
print(np.linalg.norm(coeffs_exact.value, ord=1))
3.3889400921655914
Optimizing for using an approximate model¶
It may happen that the L1 norm for the chosen set of
To do so, simply use setup_sum_of_squares_problem to construct a different cvxpy.Problem instance which constrains the L1-norm to a chosen threshold while minimizing the difference of
[10]:
from qiskit_addon_mpf.costs import setup_sum_of_squares_problem
model_approx, coeffs_approx = setup_sum_of_squares_problem(lse, max_l1_norm=3.0)
model_approx.solve()
print(coeffs_approx.value)
print(np.linalg.norm(coeffs_approx.value, ord=1))
[-0.40454257 0.57553173 0.8290123 ]
1.8090865903790838
Note, that you have complete freedom over how to solve this optimization problem, meaning that you can change the optimization solver, its convergence thresholds, and so on. Check out the respective guide on How to use the approximate model.
1c: Setting up the Trotter circuits¶
At this point, we have found our expansion coefficients,
[11]:
from qiskit.synthesis import SuzukiTrotter
from qiskit_addon_utils.problem_generators import generate_time_evolution_circuit
circuits = []
for k in trotter_steps:
circ = generate_time_evolution_circuit(
hamiltonian,
synthesis=SuzukiTrotter(order=2, reps=k),
time=time,
)
circuits.append(circ)
[12]:
circuits[0].draw("mpl", fold=-1)
[12]:

[13]:
circuits[1].draw("mpl", fold=-1)
[13]:

[14]:
circuits[2].draw("mpl", fold=-1)
[14]:

Step 2: Optimize the problem¶
Normally, this is the step in the pattern during which you optimize your circuits for execution on hardware. Here, since we only use a noiseless simulator, we simply transpile our circuit for a GenericBackendV2.
[15]:
from qiskit.providers.fake_provider import GenericBackendV2
from qiskit.transpiler import generate_preset_pass_manager
backend = GenericBackendV2(num_qubits=10)
transpiler = generate_preset_pass_manager(optimization_level=2, backend=backend)
transpiled_circuits = [transpiler.run(circ) for circ in circuits]
Step 3: Execute quantum experiments¶
As explained in the very beginning, we will skip the optimization step 2 because we are simply going to compute our target observable’s expectation values using a noise-free simulator, namely the StatevectorEstimator.
[16]:
from qiskit.primitives import StatevectorEstimator
estimator = StatevectorEstimator()
job = estimator.run([(circ, observable) for circ in transpiled_circuits])
result = job.result()
Step 4: Reconstruct results¶
First, we extract the individual expectation values obtained for each of the Trotter circuits:
[17]:
evs = [res.data.evs for res in result]
print(evs)
[array(0.23799162), array(0.35754312), array(0.38649906)]
Next, we simply recombine them with our MPF coefficients to yield the total expectation values of the MPF. Below, we do so for each of the different ways by which we have computed
[18]:
print("Analytical solution:", evs @ coeffs_analytical)
print("Exact model solution:", evs @ coeffs_exact.value)
print("Approx. model solution:", evs @ coeffs_approx.value)
Analytical solution: 0.3954847855980006
Exact model solution: 0.39548478559800204
Approx. model solution: 0.42991214253489807
Finally, for this small problem we can compute the exact reference value using scipy.linalg.expm as follows:
[19]:
from scipy.linalg import expm
exp_H = expm(-1j * time * hamiltonian.to_matrix())
initial_state = np.zeros(exp_H.shape[0])
initial_state[0] = 1.0
time_evolved_state = exp_H @ initial_state
exact_obs = time_evolved_state.conj() @ observable.to_matrix() @ time_evolved_state
print(exact_obs.real)
0.40060242487899755
We can clearly see that the MPF has reduced the Trotter error compared to the one obtained with the deepest individual PF with