10. Variational circuit preparation I¶
In this tutorial, we demonstrate two complementary classical pre-processing techniques to prepare a quantum circuit that approximates the ground state of a given Hamiltonian. Such a circuit can be used as an initial state in quantum phase estimation (QPE) or other quantum algorithms.
We consider the 1D transverse-field Ising model and build a brick-wall circuit of general two-qubit gates, where each of them can be parametrized by at most 3 CNOT gates and 15 elementary one-qubit gates (for more details, read Vatan and Williams, Phys. Rev. A 69, 032315 (2004)).
The two approaches are:
Global optimization - all parameters of the circuit are optimized simultaneously using gradient-based methods (L-BFGS) via auto-differentiation. The parameters are the 15 real entries of the SU(4) gates, and the unitarity is enforced by construction.
Local optimization - the circuit is represented as a tensor network, and the individual tensor entries are optimized locally. The optimization is performed by sweeping through the tensors, similar to DMRG, and often yields high-fidelity states.
We compare the performance of both strategies and also illustrate a sequential depth optimisation that starts from a shallow circuit and progressively increases the depth, reusing parameters from shallower layers.
The target state is obtained by DMRG (via quimb.tensor.DMRG2), which provides a reference energy and the numerically exact ground state.
import os
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["NUMBA_NUM_THREADS"] = "1"
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
os.environ["JAX_ENABLE_X64"] = "True"
import autoray
import matplotlib.pyplot as plt
import numpy as np
import quimb.tensor as qtn
from tqdm import notebook as tqdm
# Local imports from qpe_toolbox
from qpe_toolbox.circuit import ansatz_circuit_su4, su4swap_gate_param_gen, tn_fit
from qpe_toolbox.hamiltonian import Hamiltonian
10.1. Hamiltonian: 1D Transverse-Field Ising Model¶
We consider a chain of \(n\) spins with open boundaries. The Hamiltonian reads
with \(g_x = -1.1\) and \(g_{zz} = -1.0\). We take \(n = 8\) sites.
First, we compute the ground state using DMRG (via qtn.DMRG2). This gives us the target state GS and the exact energy.
# --- Hamiltonian definition ---
n_qubits = 8
gx, gzz = -1.1, -1.0
terms = []
for x in range(n_qubits):
terms.append((gx, "x", [x])) # transverse field
for x in range(n_qubits - 1):
terms.append((gzz, "zz", [x, x + 1])) # nearest-neighbour coupling
ham = Hamiltonian(terms, n_qubits)
mpo = ham.to_mpo()
# --- DMRG reference ground state ---
dmrg = qtn.DMRG2(mpo)
dmrg.solve(max_sweeps=16, tol=1e-8, bond_dims=64, verbosity=0)
GS = dmrg.state
dmrg_energy = np.real(dmrg.energy)
print(f"*** DMRG reference energy: {dmrg_energy:12.10f}\n")
*** DMRG reference energy: -10.4564576523
10.2. Global Optimization¶
The circuit is built from SU(4) gates, each parameterized by 15 real numbers. We define a loss function that computes the expectation value of the Hamiltonian MPO with respect to the state produced by the circuit. The gradient is obtained via automatic differentiation (JAX). Ref: Haghshenas et al. Phys. Rev. X 12, 011047 (2022), Tensor Network Training of Quantum Circuits
Three global optimization strategies are compared:
Standard L-BFGS on a fixed-depth circuit.
Basin-hopping - a global optimisation method that helps escape local minima.
Sequential layer-wise optimization, where a shallower circuit is optimized first, and its parameters are used to initialize a deeper one.
def loss_circ(circ, mpo):
"""
Loss function: expectation value of the MPO Hamiltonian with respect to
the state produced by the circuit.
"""
psi = circ.psi
psiH = psi.H
norm_tn = psiH & psi
psi.align_(mpo, psiH)
energy_tn = psiH & mpo & psi
energy = autoray.do("real", energy_tn.contract())
norm = autoray.do("real", norm_tn.contract())
return (energy / norm).real
def make_circuit_optimizer(circ, mpo):
"""
Factory to create a TNOptimizer for a circuit with the given MPO.
Uses L-BFGS-B (or basin-hopping) and JAX for gradients.
"""
return qtn.TNOptimizer(
circ,
loss_circ,
loss_constants={"mpo": mpo},
autodiff_backend="jax",
optimizer="L-BFGS-B",
progbar=False,
)
10.2.1. Direct L-BFGS Optimization¶
print("*** Global L-BFGS optimization")
depth = 6
circ = ansatz_circuit_su4(n_qubits, depth)
circ_opt = make_circuit_optimizer(circ, mpo)
optimal_circ = circ_opt.optimize(n=10000, tol=1e-8)
ovlp = (dmrg.state.H & optimal_circ.psi).contract()
print(
f" # parameters = {circ_opt.d: 4d}",
f" Energy = {circ_opt.loss: >12.8f}",
f" Error = {np.abs(1 - circ_opt.loss / dmrg_energy): >10.3e}",
f" 1-F = {1 - np.abs(ovlp) ** 2: >10.3e}\n",
)
*** Global L-BFGS optimization
# parameters = 630 Energy = -10.45636972 Error = 8.409e-06 1-F = 1.612e-05
10.2.2. Basin-hopping Optimization¶
Basin-hopping performs random steps in parameter space followed by a local minimization. Here we use 5000 iterations with 10 hops per step and a temperature of 0.1.
print("*** Global L-BFGS optimization + Basin-hopping ")
depth = 6
circ = ansatz_circuit_su4(n_qubits, depth)
circ_opt = make_circuit_optimizer(circ, mpo)
optimal_circ = circ_opt.optimize_basinhopping(n=5000, nhop=10, temperature=0.1)
ovlp = (dmrg.state.H & optimal_circ.psi).contract()
print(
f" # parameters = {circ_opt.d: 4d}",
f" Energy = {circ_opt.loss: >12.8f}",
f" Error = {np.abs(1 - circ_opt.loss / dmrg_energy): >10.3e}",
f" 1-F = {1 - np.abs(ovlp) ** 2: >10.3e}\n",
)
*** Global L-BFGS optimization + Basin-hopping
# parameters = 630 Energy = -10.45643483 Error = 2.183e-06 1-F = 2.415e-06
10.2.3. Sequential Layer-wise Optimization¶
We start from depth 1 and increase the depth one layer at a time, always reusing the parameters from the shallower circuit as initial guess.
print("*** Global L-BFGS sequential optimization ")
rng = np.random.default_rng(42)
optimal_circ = ansatz_circuit_su4(n_qubits, 0)
errors_global = []
for ii in tqdm.tqdm(range(depth)):
# grow the optimized circuit by one SU4SWAP layer initialized close to identity
for start in range(2):
for q in range(start, n_qubits - 1, 2):
optimal_circ.apply_gate(
"SU4SWAP",
*(0.1 * rng.random(15)),
q,
q + 1,
gate_round=ii,
parametrize=True,
)
circ_opt = make_circuit_optimizer(optimal_circ, mpo)
optimal_circ = circ_opt.optimize(n=10000, tol=1e-8)
ovlp = (dmrg.state.H & optimal_circ.psi).contract()
err = np.abs(1 - circ_opt.loss / dmrg_energy)
errors_global.append(err)
print(
f" # parameters = {circ_opt.d: 3d}",
f" Energy = {circ_opt.loss: >12.8f}",
f" Error = {err: >10.3e}",
f" 1-F = {1 - np.abs(ovlp) ** 2: >10.3e}",
)
print()
*** Global L-BFGS sequential optimization
# parameters = 105 Energy = -10.26158447 Error = 1.864e-02 1-F = 2.614e-01
# parameters = 210 Energy = -10.44128930 Error = 1.451e-03 1-F = 6.536e-03
# parameters = 315 Energy = -10.45632037 Error = 1.313e-05 1-F = 3.128e-05
# parameters = 420 Energy = -10.45636461 Error = 8.898e-06 1-F = 2.053e-05
# parameters = 525 Energy = -10.45637599 Error = 7.810e-06 1-F = 1.820e-05
# parameters = 630 Energy = -10.45641136 Error = 4.427e-06 1-F = 9.338e-06
10.2.3.1. Plot: Error vs Depth for Global Sequential Optimization¶
fig, ax = plt.subplots(figsize=(6, 4), layout="tight")
ax.semilogy(np.arange(1, depth + 1), errors_global, marker="o", linestyle="-")
ax.set_xlabel("Circuit depth")
ax.set_ylabel("Energy error $|1 - E/E_{DMRG}|$")
ax.set_title("Global L-BFGS sequential optimisation")
ax.grid(visible=True, alpha=0.3);
10.3. Local Optimization¶
Ref: Causer et al. Phys. Rev. Research 6, 033062 (2024), Gibbs and Cincio, Quantum 9, 1789 (2025).
In this approach, the circuit is represented as a tensor network (each two-qubit gate is a 4-leg tensor). We optimize the tensor entries directly using a local (sweeping) optimization based on the polar decomoposition that maximize the fidelity.
We use the function tn_fit from qpe_toolbox.circuit, which performs a local optimisation of the tensor network.
We first optimise a fixed depth-6 circuit, and then demonstrate a sequential depth optimisation that starts from depth 1 and increases depth, reusing the tensor entries from the previous depth.
Note that tn_fit implements the same local gate update as the
mpo_to_circuit tutorial. The difference is the handling of the environment:
here, we use a general-purpose routine that works for any tensor network topology. This comes at the cost
of re-contracting each tensor’s full environment from scratch every sweep. On the other hand,
mpo_to_circuit’s implementation is specialized for a 1D topology and caches left/right partial
contractions instead, updating only the environment adjacent to the gate just optimized.
def evaluate_fit(dmrg, mpo, tn, *, depth=1):
ovlp = (dmrg.state.H & tn).contract()
tnH = tn.H
tn.align_(mpo, tnH)
energy_tn = tnH & mpo & tn
ene = autoray.do("real", energy_tn.contract(all))
err = np.abs(1 - ene / np.real(dmrg.energy))
print(
f"Depth = {depth:2d}",
f"Energy = {ene:12.8f}",
f"Error = {err:10.3e}",
f"1-F = {1 - np.abs(ovlp) ** 2:10.3e}",
sep=" ",
)
return ene, err, ovlp
# --- Fixed depth 6 ---
print("*** Local optimization")
depth = 6
circ = ansatz_circuit_su4(
n_qubits=n_qubits, depth=depth, param_scaling=1.0, parametrize=False
)
tn = circ.psi
tn_fit(tn, GS, tags="SU4SWAP", steps=10000, tol=1e-8)
print("Completed!")
ene, err, ovlp = evaluate_fit(dmrg, mpo, tn, depth=depth)
*** Local optimization
Completed!
Depth = 6 Energy = -10.45631135 Error = 1.399e-05 1-F = 1.573e-05
10.3.1. Sequential Depth Optimization (local)¶
Starting from a depth-1 circuit, we optimise it, then add a layer, reusing the tensor entries from the previous circuit as initialisation.
print("*** Local sequential optimization")
tn = qtn.MPS_computational_state("0" * n_qubits)
new_layer_eps = 1e-2
rng = np.random.default_rng(42)
errors_local = []
for ii in range(depth):
# grow the optimized network by one brick-wall layer of SU4SWAP gates,
# initialized close to the identity (small parameters)
tags = ["SU4SWAP", f"ROUND_{ii}"]
for start in range(2):
for q in range(start, n_qubits - 1, 2):
gate = su4swap_gate_param_gen(new_layer_eps * rng.random(15))
tn.gate_(gate, (q, q + 1), tags=tags, contract=False)
tn_fit(tn, GS, tags="SU4SWAP", steps=10000, tol=1e-8)
ene, err, ovlp = evaluate_fit(dmrg, mpo, tn, depth=ii + 1)
errors_local.append(err)
*** Local sequential optimization
Depth = 1 Energy = -10.18106129 Error = 2.634e-02 1-F = 8.268e-02
Depth = 2 Energy = -10.44499538 Error = 1.096e-03 1-F = 3.466e-03
Depth = 3 Energy = -10.45641450 Error = 4.126e-06 1-F = 6.897e-06
Depth = 4 Energy = -10.45643028 Error = 2.618e-06 1-F = 4.460e-06
Depth = 5 Energy = -10.45643633 Error = 2.039e-06 1-F = 3.423e-06
Depth = 6 Energy = -10.45643802 Error = 1.877e-06 1-F = 3.014e-06
10.3.1.1. Plot: Error vs Depth for Local Sequential Optimization¶
fig, ax = plt.subplots(figsize=(6, 4), layout="tight")
ax.semilogy(
np.arange(1, depth + 1), errors_local, marker="s", linestyle="-", color="green"
)
ax.set_xlabel("Circuit depth")
ax.set_ylabel("Energy error $|1 - E/E_{DMRG}|$")
ax.set_title("Local sequential optimisation (tensor-network fitting)")
ax.grid(visible=True, alpha=0.3);
10.4. Discussion¶
The results above show that:
Global optimisation with L-BFGS can already find a good approximation, but basin-hopping and sequential layer-wise initialization often improve the fidelity and energy.
Local optimisation (tensor-network fitting) yields very high fidelity (low infidelity) and energies very close to the DMRG reference. The sequential depth variant further improves convergence.
Both approaches provide a classical pre-processing step to generate a high-quality initial state for quantum algorithms such as QPE. The choice between them depends on the available infrastructure (automatic differentiation, global vs local optimizers) and the desired accuracy
10.4.1. Contraction Cost¶
Both local-optimization sweeps above contract the exact environment of each gate down to a dense matrix at every single-gate update. For a shallow circuit this is cheap, but as the circuit grows deeper, the environment itself is a nontrivial tensor network with no fixed bond dimension. As contracting a generic tensor network is hard, this becomes the actual bottleneck for scaling local optimization to deep circuits, independent of how efficiently the sweep itself is implemented.
The circuit_preparation_approximate_local_opt tutorial addresses this directly. Instead of contracting each gate’s environment exactly, it approximates the rest of the circuit as a finite-bond-dimension MPS before each layer update, capping the per-step cost at a chosen bond dimension rather than letting it grow with depth.