VQE Tutorial#
The Variational Quantum Eigensolver (VQE) is a hybrid quantum-classical algorithm for finding ground state energies of quantum systems. This tutorial provides comprehensive coverage of VQE implementation, ansatz design, optimization strategies, and practical applications.
Prerequisites#
Completed Beginner’s Tutorial and MPS Basics tutorials
Understanding of quantum gates and circuits
Familiarity with Hamiltonians and expectation values
Basic knowledge of optimization algorithms
Linear algebra background
Learning Objectives#
After completing this tutorial, you will understand:
VQE algorithm principles and workflow
Ansatz design and selection criteria
Classical optimization methods for VQE
Gradient computation techniques
Convergence monitoring and analysis
Noise-aware VQE strategies
Hardware-efficient circuit design
Practical VQE applications
Part 1: VQE Fundamentals#
What is VQE?#
VQE finds the ground state energy of a Hamiltonian H by optimizing a parameterized quantum state:
The algorithm alternates between:
Quantum: Prepare state |ψ(θ)⟩ and measure ⟨H⟩
Classical: Update parameters θ to minimize ⟨H⟩
Algorithm Workflow#
1. Initialize parameters θ
2. Prepare ansatz state |ψ(θ)⟩
3. Measure energy E(θ) = ⟨ψ(θ)|H|ψ(θ)⟩
4. Update θ using classical optimizer
5. Repeat until convergence
Why VQE Works#
The variational principle guarantees:
Any parameterized state gives an upper bound on the ground state energy. By minimizing E(θ), we approach E₀.
Basic VQE Implementation#
from atlas_q.mpo_ops import MPOBuilder
from atlas_q.vqe_qaoa import VQE, VQEConfig
import torch
# Build Hamiltonian - transverse field Ising model
n_sites = 8
H = MPOBuilder.ising_hamiltonian(
n_sites=n_sites,
J=1.0, # Coupling strength
h=0.5, # Transverse field
device='cuda'
)
# Configure VQE
config = VQEConfig(
ansatz='hardware_efficient',
n_layers=3,
optimizer='L-BFGS-B',
max_iter=100,
tol=1e-6
)
# Run optimization
vqe = VQE(H, config)
energy, params = vqe.optimize()
print(f"Ground state energy: {energy:.6f}")
print(f"Number of parameters: {len(params)}")
print(f"Optimization iterations: {len(vqe.energies)}")
Expected output:
Ground state energy: -9.234567
Number of parameters: 48
Optimization iterations: 23
Part 2: Ansatz Design#
What is an Ansatz?#
An ansatz is a parameterized quantum circuit that prepares trial states. Good ansätze balance:
Expressivity: Can represent states close to ground state
Trainability: Gradients don’t vanish
Efficiency: Shallow enough for NISQ devices
Hardware-Efficient Ansatz#
Designed for near-term hardware with limited gate fidelity:
from atlas_q.vqe_qaoa import VQE, VQEConfig
from atlas_q.mpo_ops import MPOBuilder
# Hardware-efficient ansatz structure:
# Layer = [R_y(θ) on all qubits] + [CNOT ladder] + [R_z(θ) on all qubits]
config = VQEConfig(
ansatz='hardware_efficient',
n_layers=3,
entangling_gate='cnot', # Options: 'cnot', 'cz', 'swap'
device='cuda'
)
H = MPOBuilder.heisenberg_hamiltonian(n_sites=10, device='cuda')
vqe = VQE(H, config)
energy, params = vqe.optimize()
Circuit structure visualization:
Layer 1:
q0: ─Ry(θ₀)─●───────Rz(φ₀)─
q1: ─Ry(θ₁)─┼───●───Rz(φ₁)─
q2: ─Ry(θ₂)─X───┼───Rz(φ₂)─
q3: ─Ry(θ₃)─────X───Rz(φ₃)─
Repeat for n_layers
Number of parameters: n_sites × 2 × n_layers
UCCSD Ansatz#
Unitary Coupled-Cluster Singles and Doubles - chemistry-aware ansatz:
# UCCSD ansatz for molecular systems
config = VQEConfig(
ansatz='uccsd',
n_electrons=4, # Number of electrons
n_orbitals=8, # Number of spatial orbitals
device='cuda'
)
# For H2 molecule
from atlas_q.mpo_ops import molecular_hamiltonian_from_specs
H_h2 = molecular_hamiltonian_from_specs(
molecule='H2',
basis='sto-3g',
bond_length=0.74, # Angstroms
device='cuda'
)
vqe = VQE(H_h2, config)
energy, params = vqe.optimize()
print(f"H2 ground state energy: {energy:.6f} Hartree")
UCCSD provides chemical accuracy for small molecules but requires O(n⁴) parameters for n orbitals.
Custom Ansatz#
Define custom circuit structure:
from atlas_q.adaptive_mps import AdaptiveMPS
import torch
import numpy as np
def custom_ansatz(n_qubits, params, device='cuda'):
"""
Custom ansatz with alternating RX and RZ rotations.
Args:
n_qubits: Number of qubits
params: Parameter array (length = 2*n_qubits + n_qubits-1)
device: Computation device
Returns:
AdaptiveMPS with ansatz applied
"""
mps = AdaptiveMPS(num_qubits=n_qubits, bond_dim=32, device=device)
idx = 0
# Layer 1: RX rotations
for q in range(n_qubits):
theta = params[idx]
RX = torch.tensor([
[np.cos(theta/2), -1j*np.sin(theta/2)],
[-1j*np.sin(theta/2), np.cos(theta/2)]
], dtype=torch.complex64).to(device)
mps.apply_single_qubit_gate(q, RX)
idx += 1
# Layer 2: CNOT ladder
CNOT = torch.tensor([
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 0, 1],
[0, 0, 1, 0]
], dtype=torch.complex64).reshape(4, 4).to(device)
for q in range(n_qubits - 1):
mps.apply_two_site_gate(q, CNOT)
# Layer 3: RZ rotations
for q in range(n_qubits):
phi = params[idx]
RZ = torch.diag(torch.tensor(
[1, np.exp(1j*phi)],
dtype=torch.complex64
)).to(device)
mps.apply_single_qubit_gate(q, RZ)
idx += 1
return mps
# Use custom ansatz with VQE
# (Would need to wrap in VQE-compatible interface)
Ansatz Selection Guidelines#
Choose ansatz based on problem:
def recommend_ansatz(problem_type, n_qubits):
"""Recommend ansatz based on problem characteristics."""
if problem_type == 'chemistry':
# Chemical systems - use UCCSD
return 'uccsd'
elif problem_type == 'optimization':
# Combinatorial problems - use QAOA
return 'qaoa'
elif problem_type == 'general' and n_qubits <= 15:
# Small systems - use hardware-efficient
return 'hardware_efficient'
elif problem_type == 'general' and n_qubits > 15:
# Large systems - use shallow ansatz
return 'hardware_efficient_shallow'
else:
return 'hardware_efficient'
# Example
ansatz = recommend_ansatz('chemistry', n_qubits=12)
print(f"Recommended: {ansatz}")
Part 3: Classical Optimization#
Optimizer Selection#
Different optimizers have different characteristics:
# Gradient-free optimizers
config_nelder = VQEConfig(
optimizer='Nelder-Mead',
max_iter=500
)
config_cobyla = VQEConfig(
optimizer='COBYLA',
max_iter=500
)
# Gradient-based optimizers
config_lbfgs = VQEConfig(
optimizer='L-BFGS-B',
max_iter=100
)
config_adam = VQEConfig(
optimizer='Adam',
learning_rate=0.01,
max_iter=1000
)
Optimizer comparison:
from atlas_q.mpo_ops import MPOBuilder
from atlas_q.vqe_qaoa import VQE, VQEConfig
import time
H = MPOBuilder.ising_hamiltonian(n_sites=10, J=1.0, h=0.5, device='cuda')
optimizers = ['Nelder-Mead', 'COBYLA', 'L-BFGS-B', 'Adam']
results = []
for opt_name in optimizers:
config = VQEConfig(
ansatz='hardware_efficient',
n_layers=2,
optimizer=opt_name,
max_iter=100
)
vqe = VQE(H, config)
start = time.time()
energy, params = vqe.optimize()
elapsed = time.time() - start
results.append({
'optimizer': opt_name,
'energy': energy,
'iterations': len(vqe.energies),
'time': elapsed
})
print(f"{opt_name:15s}: E={energy:.6f}, "
f"iter={len(vqe.energies)}, time={elapsed:.2f}s")
Expected output:
Nelder-Mead : E=-13.456789, iter=87, time=12.34s
COBYLA : E=-13.456234, iter=93, time=13.21s
L-BFGS-B : E=-13.456891, iter=23, time=3.45s
Adam : E=-13.456543, iter=100, time=8.76s
Gradient Computation#
For gradient-based optimizers, compute gradients via parameter shift:
def parameter_shift_gradient(vqe, params, param_idx, shift=np.pi/2):
"""
Compute gradient via parameter shift rule.
∂E/∂θᵢ = (E(θᵢ + π/2) - E(θᵢ - π/2)) / 2
Args:
vqe: VQE instance
params: Current parameters
param_idx: Index of parameter to differentiate
shift: Shift amount (default π/2)
Returns:
Gradient for parameter param_idx
"""
params_plus = params.copy()
params_plus[param_idx] += shift
params_minus = params.copy()
params_minus[param_idx] -= shift
energy_plus = vqe.compute_energy(params_plus)
energy_minus = vqe.compute_energy(params_minus)
gradient = (energy_plus - energy_minus) / 2
return gradient
# Compute full gradient
def compute_full_gradient(vqe, params):
"""Compute gradient for all parameters."""
gradient = np.zeros_like(params)
for i in range(len(params)):
gradient[i] = parameter_shift_gradient(vqe, params, i)
return gradient
# Example usage
from atlas_q.vqe_qaoa import VQE, VQEConfig
from atlas_q.mpo_ops import MPOBuilder
H = MPOBuilder.ising_hamiltonian(n_sites=6, device='cuda')
config = VQEConfig(ansatz='hardware_efficient', n_layers=2)
vqe = VQE(H, config)
# Initial parameters
params = np.random.randn(24) * 0.1
# Compute gradient
grad = compute_full_gradient(vqe, params)
print(f"Gradient norm: {np.linalg.norm(grad):.4f}")
Advanced: Natural Gradient#
Natural gradient accounts for parameter space geometry:
where F is the quantum Fisher information matrix.
def natural_gradient_step(vqe, params, learning_rate=0.01):
"""
Natural gradient descent step.
Args:
vqe: VQE instance
params: Current parameters
learning_rate: Step size
Returns:
Updated parameters
"""
# Compute gradient
grad = compute_full_gradient(vqe, params)
# Compute Fisher information matrix (simplified)
# Full implementation requires quantum Fisher information
# Here we use identity as approximation
F_inv = np.eye(len(params))
# Natural gradient update
natural_grad = F_inv @ grad
params_new = params - learning_rate * natural_grad
return params_new
Part 4: Convergence Monitoring#
Basic Monitoring#
from atlas_q.vqe_qaoa import VQE, VQEConfig
from atlas_q.mpo_ops import MPOBuilder
H = MPOBuilder.ising_hamiltonian(n_sites=10, device='cuda')
config = VQEConfig(
ansatz='hardware_efficient',
n_layers=3,
optimizer='L-BFGS-B',
max_iter=100,
callback=True # Enable iteration callbacks
)
vqe = VQE(H, config)
energy, params = vqe.optimize()
# Access convergence history
print(f"Initial energy: {vqe.energies[0]:.6f}")
print(f"Final energy: {vqe.energies[-1]:.6f}")
print(f"Energy improvement: {vqe.energies[0] - vqe.energies[-1]:.6f}")
# Plot convergence
import matplotlib.pyplot as plt
plt.figure(figsize=(10, 6))
plt.plot(vqe.energies, 'b-', linewidth=2)
plt.xlabel('Iteration', fontsize=12)
plt.ylabel('Energy', fontsize=12)
plt.title('VQE Convergence', fontsize=14)
plt.grid(True, alpha=0.3)
plt.savefig('vqe_convergence.png', dpi=150)
plt.close()
Advanced Monitoring#
Track multiple metrics:
class VQEMonitor:
"""Monitor VQE optimization progress."""
def __init__(self):
self.energies = []
self.grad_norms = []
self.param_norms = []
self.walltime = []
self.start_time = None
def callback(self, params, energy, gradient=None):
"""Called after each optimization step."""
import time
if self.start_time is None:
self.start_time = time.time()
self.energies.append(energy)
self.param_norms.append(np.linalg.norm(params))
self.walltime.append(time.time() - self.start_time)
if gradient is not None:
self.grad_norms.append(np.linalg.norm(gradient))
# Print progress
if len(self.energies) % 10 == 0:
print(f"Iteration {len(self.energies)}: E={energy:.6f}, "
f"time={self.walltime[-1]:.2f}s")
def plot_diagnostics(self, filename='vqe_diagnostics.png'):
"""Plot comprehensive diagnostics."""
import matplotlib.pyplot as plt
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
# Energy convergence
axes[0, 0].plot(self.energies, 'b-')
axes[0, 0].set_xlabel('Iteration')
axes[0, 0].set_ylabel('Energy')
axes[0, 0].set_title('Energy Convergence')
axes[0, 0].grid(True, alpha=0.3)
# Gradient norm
if self.grad_norms:
axes[0, 1].semilogy(self.grad_norms, 'r-')
axes[0, 1].set_xlabel('Iteration')
axes[0, 1].set_ylabel('Gradient Norm')
axes[0, 1].set_title('Gradient Magnitude')
axes[0, 1].grid(True, alpha=0.3)
# Parameter norm
axes[1, 0].plot(self.param_norms, 'g-')
axes[1, 0].set_xlabel('Iteration')
axes[1, 0].set_ylabel('Parameter Norm')
axes[1, 0].set_title('Parameter Evolution')
axes[1, 0].grid(True, alpha=0.3)
# Wall time
axes[1, 1].plot(self.walltime, self.energies, 'k-')
axes[1, 1].set_xlabel('Time (s)')
axes[1, 1].set_ylabel('Energy')
axes[1, 1].set_title('Time vs Energy')
axes[1, 1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(filename, dpi=150)
plt.close()
# Usage
monitor = VQEMonitor()
# Integrate with VQE
# (Would need custom callback in VQE implementation)
Convergence Criteria#
Determine when to stop optimization:
def check_convergence(energies, tol=1e-6, window=5):
"""
Check if VQE has converged.
Criteria:
1. Energy change < tol over window iterations
2. No improvement for window iterations
Args:
energies: List of energies
tol: Tolerance for energy change
window: Number of iterations to check
Returns:
True if converged
"""
if len(energies) < window + 1:
return False
# Check energy change
recent_energies = energies[-window-1:]
energy_changes = [abs(recent_energies[i+1] - recent_energies[i])
for i in range(window)]
max_change = max(energy_changes)
return max_change < tol
# Example
converged = check_convergence(vqe.energies, tol=1e-6, window=5)
if converged:
print("VQE has converged!")
else:
print("VQE has not converged, continue optimization")
Part 5: Noise-Aware VQE#
Error Mitigation#
Mitigate measurement errors:
from atlas_q.vqe_qaoa import VQE, VQEConfig
# Enable error mitigation
config = VQEConfig(
ansatz='hardware_efficient',
n_layers=2,
error_mitigation='zero_noise_extrapolation',
noise_factors=[1.0, 1.5, 2.0], # Noise scaling factors
device='cuda'
)
H = MPOBuilder.ising_hamiltonian(n_sites=8, device='cuda')
vqe = VQE(H, config)
energy, params = vqe.optimize()
print(f"Mitigated energy: {energy:.6f}")
Noisy Simulation#
Simulate VQE under noise:
from atlas_q.noise_models import DepolarizingNoise
from atlas_q.vqe_qaoa import VQE, VQEConfig
# Create noise model
noise = DepolarizingNoise(
single_qubit_error=0.001, # 0.1% error per gate
two_qubit_error=0.01 # 1% error per two-qubit gate
)
config = VQEConfig(
ansatz='hardware_efficient',
n_layers=2,
noise_model=noise,
device='cuda'
)
H = MPOBuilder.ising_hamiltonian(n_sites=8, device='cuda')
# Compare noiseless vs noisy
vqe_ideal = VQE(H, VQEConfig(ansatz='hardware_efficient', n_layers=2))
energy_ideal, _ = vqe_ideal.optimize()
vqe_noisy = VQE(H, config)
energy_noisy, _ = vqe_noisy.optimize()
print(f"Ideal energy: {energy_ideal:.6f}")
print(f"Noisy energy: {energy_noisy:.6f}")
print(f"Error: {abs(energy_noisy - energy_ideal):.6f}")
Part 6: Practical Applications#
Ising Model Ground State#
from atlas_q.mpo_ops import MPOBuilder
from atlas_q.vqe_qaoa import VQE, VQEConfig
# 1D Ising chain with periodic boundary conditions
n_sites = 12
H_ising = MPOBuilder.ising_hamiltonian(
n_sites=n_sites,
J=-1.0, # Ferromagnetic coupling
h=0.5, # Transverse field
periodic=True,
device='cuda'
)
config = VQEConfig(
ansatz='hardware_efficient',
n_layers=4,
optimizer='L-BFGS-B',
max_iter=200
)
vqe = VQE(H_ising, config)
energy, params = vqe.optimize()
# Compare to exact (for small systems)
# exact_energy = compute_exact_ground_state(H_ising)
# error = abs(energy - exact_energy)
print(f"VQE ground state energy: {energy:.6f}")
Heisenberg Model#
# Heisenberg XXZ model
H_heisenberg = MPOBuilder.heisenberg_hamiltonian(
n_sites=10,
Jx=1.0,
Jy=1.0,
Jz=1.5, # Anisotropic
device='cuda'
)
config = VQEConfig(
ansatz='hardware_efficient',
n_layers=5,
optimizer='L-BFGS-B'
)
vqe = VQE(H_heisenberg, config)
energy, params = vqe.optimize()
print(f"Heisenberg ground state energy: {energy:.6f}")
Custom Hamiltonian#
from atlas_q.mpo_ops import MPOBuilder
# Build custom spin chain
builder = MPOBuilder(n_sites=8, device='cuda')
# Add terms manually
for i in range(7):
# ZZ interaction
builder.add_two_site_term(i, i+1, 'ZZ', coefficient=-1.0)
# X field
builder.add_single_site_term(i, 'X', coefficient=0.5)
# Add final site X field
builder.add_single_site_term(7, 'X', coefficient=0.5)
H_custom = builder.build()
vqe = VQE(H_custom, VQEConfig(ansatz='hardware_efficient', n_layers=3))
energy, params = vqe.optimize()
print(f"Custom Hamiltonian ground state: {energy:.6f}")
Part 7: Troubleshooting#
Convergence Issues#
Problem: VQE not converging or stuck at local minimum
Solutions:
Try different random initializations:
best_energy = float('inf') best_params = None for trial in range(10): vqe = VQE(H, config) energy, params = vqe.optimize() if energy < best_energy: best_energy = energy best_params = params print(f"Best energy over 10 trials: {best_energy:.6f}")
Increase ansatz depth:
config = VQEConfig(ansatz='hardware_efficient', n_layers=5) # More layers
Use different optimizer:
config = VQEConfig(optimizer='Nelder-Mead') # Gradient-free
Barren Plateaus#
Problem: Gradients vanish with deep circuits
Solutions:
Use hardware-efficient ansatz with shallow depth
Initialize parameters near identity (small values)
Use layer-wise training
Poor Accuracy#
Problem: VQE energy far from true ground state
Solutions:
Increase bond dimension for MPS simulation:
# Higher χ for more accurate MPS mps = AdaptiveMPS(num_qubits=n, bond_dim=128, device='cuda')
Tighten convergence criteria:
config = VQEConfig(tol=1e-8, max_iter=500)
Use chemistry-aware ansatz for molecules (UCCSD)
Summary#
You have learned:
✅ VQE algorithm principles and variational principle
✅ Ansatz design (hardware-efficient, UCCSD, custom)
✅ Classical optimizer selection and configuration
✅ Gradient computation via parameter shift
✅ Convergence monitoring and diagnostics
✅ Noise-aware VQE and error mitigation
✅ Practical applications to spin models
✅ Troubleshooting common issues
Next Steps#
Molecular VQE Tutorial - Apply VQE to quantum chemistry
QAOA Tutorial - Combinatorial optimization with QAOA
TDVP Tutorial - Time evolution methods
How to Optimize Performance - Performance optimization
Algorithms - Algorithm theory
Practice Exercises#
Implement VQE for the XY model and compare to Heisenberg
Test different optimizers and compare convergence rates
Implement natural gradient descent
Study how ansatz depth affects accuracy vs convergence
Compare VQE to exact diagonalization for small systems
See Also#
atlas_q.vqe_qaoa - VQE/QAOA API reference
atlas_q.mpo_ops - Hamiltonian construction
atlas_q.adaptive_mps - MPS for VQE state preparation