Reducing circuit depth with the AQC-Tensor Qiskit addon

In this notebook, we will work through the steps of a Qiskit pattern while using approximate quantum compilation with tensor networks (AQC-Tensor) to achieve a lower circuit depth than would ordinarily be needed to perform Trotter evolution.

These are the steps that we will take:

  • Step 1: Map to quantum problem

    • Initialize our problem’s Hamiltonian and observable(s)

    • Generate a target tensor-network state for the initial portion of the circuit

    • Generate a low-depth circuit which approximates the portion being compressed

    • Generate a general ansatz from that circuit

    • Optimize the parameters to bring the ansatz as close as possible to the target

    • Add subsequent Trotter steps to the optimized ansatz

  • Step 2: Optimize for target hardware

    • Transpile the circuit for hardware

  • Step 3: Execute experiments

    • Use a fake backend for simplicity

  • Step 4: Reconstruct results

    • N/A; instead, we just output the measured observable

Step 1: Map to quantum circuit and operator

Set up a model Hamiltonian and observable

In this notebook, we use the Ising model on a circle of 10 sites:

\[\hat{\mathcal{H}}_{\text{Ising}} = \sum_{i=1}^{10} J_{i,(i+1)} Z_i Z_{(i+1)} + h_i X_i \, ,\]

where the periodic boundary conditions imply that for \(i=10\) we obtain \(i+1=11\rightarrow1\), \(J\) is the coupling strength between two sites and \(h\) is the external magnetic field.

[1]:
from qiskit.transpiler import CouplingMap
from qiskit_addon_utils.problem_generators import generate_xyz_hamiltonian

# Generate some coupling map to use for this example
coupling_map = CouplingMap.from_heavy_hex(3, bidirectional=False)

# Choose a 10-qubit circle on this coupling map
reduced_coupling_map = coupling_map.reduce([0, 13, 1, 14, 10, 16, 4, 15, 3, 9])

# Get a qubit operator describing the Ising field model
hamiltonian = generate_xyz_hamiltonian(
    reduced_coupling_map,
    coupling_constants=(0.0, 0.0, 1.0),
    ext_magnetic_field=(0.4, 0.0, 0.0),
)

The observable we will measure is the total magnetization.

[2]:
from qiskit.quantum_info import SparsePauliOp

L = reduced_coupling_map.size()
observable = SparsePauliOp.from_sparse_list([("Z", [i], 1 / L / 2) for i in range(L)], num_qubits=L)

Determine how much of the time evolution to simulate classically

Our overall goal is to simulate time evolution of the above model Hamiltonian. We do so by Trotter evolution, which we split into two portions:

  1. An initial portion that is simulable with matrix product states (MPS). We will “compile” this portion using AQC as presented in https://arxiv.org/abs/2301.08609.

  2. A subsequent portion of the circuit that will be executed on hardware.

Let’s plan to use AQC-Tensor to compress our time evolution circuit up to time \(t=4\), then evolve using ordinary Trotter steps up to \(t=5\).

Generate circuits before and after split

Now that we have chosen to split at \(t=4\), we will generate two circuits:

  1. A “target” circuit for the AQC portion of the evolution, from \(t_i=0\) to \(t_f=4\). Because this is being simulated by a tensor-network simulator, the number of layers affects execution time only by a constant factor, so we might as well use a generous number of layers to minimize Trotter error.

[3]:
from qiskit.synthesis import SuzukiTrotter
from qiskit_addon_utils.problem_generators import generate_time_evolution_circuit

aqc_evolution_time = 4.0
aqc_target_num_trotter_steps = 45

aqc_target_circuit = generate_time_evolution_circuit(
    hamiltonian,
    synthesis=SuzukiTrotter(reps=aqc_target_num_trotter_steps),
    time=aqc_evolution_time,
)
  1. A subsequent evolution circuit, which evolves from \(t_i=4\) to \(t_f=5\). Because this is being run on quantum hardware, it is desirable to use as few Trotter layers as possible.

[4]:
subsequent_evolution_time = 1.0
subsequent_num_trotter_steps = 5

subsequent_circuit = generate_time_evolution_circuit(
    hamiltonian,
    synthesis=SuzukiTrotter(reps=subsequent_num_trotter_steps),
    time=subsequent_evolution_time,
)

For the sake of later comparison, let’s also generate a third circuit: one that evolves for aqc_evolution_time but which has the same evolution time per Trotter step as the subsequent circuit. This is the circuit we would have working with had we not used a generous number of Trotter steps for the target circuit. We will refer to this as the comparison circuit.

[5]:
aqc_comparison_num_trotter_steps = int(
    subsequent_num_trotter_steps / subsequent_evolution_time * aqc_evolution_time
)
aqc_comparison_num_trotter_steps
[5]:
20
[6]:
comparison_circuit = generate_time_evolution_circuit(
    hamiltonian,
    synthesis=SuzukiTrotter(reps=aqc_comparison_num_trotter_steps),
    time=aqc_evolution_time,
)

Generate an ansatz and initial parameters from a Trotter circuit with fewer steps

First, we construct a “good” circuit that has the same evolution time as the target circuit, but with fewer Trotter steps (and thus fewer layers).

Then we pass this “good” circuit to AQC-Tensor’s generate_ansatz_from_circuit function. This function analyzes the two-qubit connectivity of the circuit and returns two things: 1. a general, parametrized ansatz circuit with the same two-qubit connectivity as the input circuit; and, 2. parameters that, when plugged into the ansatz, yield the input (good) circuit.

Soon we will take these parameters and iteratively adjust them to bring the ansatz circuit as close as possible to the target MPS.

[7]:
from qiskit_addon_aqc_tensor import generate_ansatz_from_circuit

aqc_ansatz_num_trotter_steps = 5

aqc_good_circuit = generate_time_evolution_circuit(
    hamiltonian,
    synthesis=SuzukiTrotter(reps=aqc_ansatz_num_trotter_steps),
    time=aqc_evolution_time,
)

aqc_ansatz, aqc_initial_parameters = generate_ansatz_from_circuit(
    aqc_good_circuit, qubits_initially_zero=True
)
aqc_ansatz.draw("mpl", fold=-1)
[7]:
../_images/tutorials_01_initial_state_aqc_15_0.png
[8]:
print(f"Comparison circuit: depth {comparison_circuit.depth()}")
print(f"Target circuit: depth {aqc_target_circuit.depth()}")
print(f"Ansatz circuit: depth {aqc_ansatz.depth()}, with {len(aqc_initial_parameters)} parameters")
Comparison circuit: depth 120
Target circuit: depth 270
Ansatz circuit: depth 23, with 515 parameters

Choose settings for tensor network simulation

Here, we use the Qiskit Aer matrix-product state (MPS) simulator, which is currently the only supported tensor network backend.

[9]:
from qiskit_aer import AerSimulator

simulator_settings = AerSimulator(
    method="matrix_product_state",
    matrix_product_state_max_bond_dimension=100,
)

Construct matrix-product state representation of the AQC target state

Next, we build a matrix-product representation of the state to be approximated by AQC.

[10]:
from qiskit_addon_aqc_tensor.simulation import tensornetwork_from_circuit

aqc_target_mps = tensornetwork_from_circuit(aqc_target_circuit, simulator_settings)

Note that because we chose a generous number of Trotter steps for the target state, it actually has less Trotter error than the comparison circuit. We can calculate the fidelity (\(| \langle \psi_1 | \psi_2 \rangle |^2\)) of the state prepared by the comparison circuit vs. the target state:

[11]:
from qiskit_addon_aqc_tensor.simulation import compute_overlap

comparison_mps = tensornetwork_from_circuit(comparison_circuit, simulator_settings)
comparison_fidelity = abs(compute_overlap(comparison_mps, aqc_target_mps)) ** 2
comparison_fidelity
[11]:
0.9997111919739353

Optimize the parameters of the ansatz using MPS calculations

Here, we minimize the simplest possible cost function, OneMinusFidelity, by using the L-BFGS optimizer from scipy.

We choose a stopping point for the fidelity such that it will be above what the comparison circuit would have been, without using AQC. Once this is reached, the compressed circuit has less Trotter error and less depth than the original circuit. Given more processing time, further optimization steps can be performed to bring the fidelity even higher.

[12]:
from scipy.optimize import OptimizeResult, minimize

from qiskit_addon_aqc_tensor.objective import OneMinusFidelity

objective = OneMinusFidelity(aqc_target_mps, aqc_ansatz, simulator_settings)

stopping_point = 1 - comparison_fidelity


def callback(intermediate_result: OptimizeResult):
    print(f"Intermediate result: Fidelity {1 - intermediate_result.fun:.8}")
    if intermediate_result.fun < stopping_point:
        # Good enough for now
        raise StopIteration


result = minimize(
    objective,
    aqc_initial_parameters,
    method="L-BFGS-B",
    jac=True,
    options={"maxiter": 100},
    callback=callback,
)
if result.status not in (
    0,
    1,
    99,
):  # 0 => success; 1 => max iterations reached; 99 => early termination via StopIteration
    raise RuntimeError(f"Optimization failed: {result.message} (status={result.status})")

print(f"Done after {result.nit} iterations.")
aqc_final_parameters = result.x
Intermediate result: Fidelity 0.95082597
Intermediate result: Fidelity 0.98411317
Intermediate result: Fidelity 0.99143467
Intermediate result: Fidelity 0.99521567
Intermediate result: Fidelity 0.99567027
Intermediate result: Fidelity 0.99649923
Intermediate result: Fidelity 0.99683247
Intermediate result: Fidelity 0.99720231
Intermediate result: Fidelity 0.99761524
Intermediate result: Fidelity 0.99808815
Intermediate result: Fidelity 0.9983816
Intermediate result: Fidelity 0.9986169
Intermediate result: Fidelity 0.99874427
Intermediate result: Fidelity 0.99892512
Intermediate result: Fidelity 0.99908005
Intermediate result: Fidelity 0.9991768
Intermediate result: Fidelity 0.99925451
Intermediate result: Fidelity 0.99933212
Intermediate result: Fidelity 0.99947267
Intermediate result: Fidelity 0.99956172
Intermediate result: Fidelity 0.99964328
Intermediate result: Fidelity 0.9996727
Intermediate result: Fidelity 0.99968642
Intermediate result: Fidelity 0.99974864
Done after 24 iterations.

Construct the final circuit to pass to the transpiler

[13]:
final_circuit = aqc_ansatz.assign_parameters(aqc_final_parameters)
final_circuit.compose(subsequent_circuit, inplace=True)
final_circuit.draw("mpl", fold=-1)
[13]:
../_images/tutorials_01_initial_state_aqc_26_0.png

Step 2: Transpile for execution on target hardware

In Step 2 of a Qiskit pattern, we transpile this circuit and any desired observable(s) for execution on a target device. Here we are using a fake backend provided by qiskit-ibm-runtime.

[14]:
from qiskit import transpile
from qiskit_ibm_runtime.fake_provider import FakeMelbourneV2

backend = FakeMelbourneV2()

isa_circuit = transpile(final_circuit, backend)
isa_observable = observable.apply_layout(isa_circuit.layout)

The resulting ISA circuit can then be sent for execution on the backend (step 3 of a Qiskit pattern).

Step 3: Execute on quantum hardware

[15]:
from qiskit_ibm_runtime import EstimatorV2 as Estimator

estimator = Estimator(backend)
job = estimator.run([(isa_circuit, isa_observable)])
pub_result = job.result()[0]

Step 4: Reconstruct

Reconstruction is not necessary in our case. We can just look at the result.

[16]:
pub_result.data.evs[()]
[16]:
np.float64(0.1667236328125)