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:

  1. 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.

  2. 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

\[ H = g_x \sum_{i} X_i + g_{zz} \sum_{i} Z_i Z_{i+1}, \]

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:

  1. Standard L-BFGS on a fixed-depth circuit.

  2. Basin-hopping - a global optimisation method that helps escape local minima.

  3. 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);
../../_images/2e58450df3e40b022e44b3e062369a422dc97c2a8e8bb4f99f307636ce934c19.png

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);
../../_images/71fb530210685335b5dadfe1545713a46a4bc531652f8620eaa5df24c4684512.png

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.