Mitigating old Variational Quantum Eigensolver results
In this example, we load a pre-trained VQE, connect to the WACQT device, and demonstrate how computing an updated noise map allows us to obtain a good energy approximation even after a significant time has elapsed since our original VQE training. Below, we introduce the basic concepts before diving into the code.
1. VQE in a nutshell
Before starting with our exercise, let us introduce the basic idea of Variational Quantum Eigensolvers, or VQEs.
A VQE is a well-known algorithm in which we construct a parametric quantum circuit capable of approximating the ground state of a given Hamiltonian.
VQE diagram
The required ingredients are
a parametric quantum circuit U_{\theta}, which depends indeed on some parameters \theta;
a target Hamiltonian H of which we want to reconstruct the ground state;
Let’s start by defining these two components using Qiskit’s interface.
# We need to import Qiskit's primitives to define our quantum circuitfrom qiskit.circuit import QuantumCircuit, Parameter# Let's define our quantum circuit as parametric# This allows us to update the angles latertheta0 = Parameter('theta0')theta1 = Parameter('theta1')vqe_circuit = QuantumCircuit(2, 2) # 2 qubits, 2 classical bitsvqe_circuit.ry(theta0, 0)vqe_circuit.ry(theta1, 1)vqe_circuit.cz(0, 1)vqe_circuit.measure([0, 1], [0, 1]) # Explicit measurementvqe_circuit.draw()
# We will be measuring the expectation value of Z1 + Z2# We can reconstruct this expectation value from the counts of the measurement resultsdef cost_function(circuit_result: dict) ->float:"""Takes the result of a quantum circuit and return the expectation value of Z1 + Z2""" energy =0for bitstring, count in circuit_result.items():# Bitstrings are indexed from right to left (MSB first)# bitstring[-1] is the rightmost bit (qubit 0 / classical bit 0)# bitstring[-2] is the second from right (qubit 1 / classical bit 1) z1 =1if bitstring[-1] =='0'else-1 z2 =1if bitstring[-2] =='0'else-1 energy += (z1 + z2) * countreturn energy /sum(circuit_result.values())
The training of a VQE
How do we train the VQE? As shown in the image above, we optimize a cost function that evaluates how well our parametric model performs a given task. In our case, the cost function is: C = \langle 0 | U^{\dagger}_{\theta} H U{\theta} | 0 \rangle We minimize this function to find optimal parameters that reduce the energy of the given Hamiltonian.
This optimization can be performed using any chosen optimizer. An optimizer is an algorithm that, given an initial set of parameters \theta^0, finds an (ideally) optimal set of parameters \theta^*.
A simple and practical choice is to employ gradient-based optimizers from classical machine learning. As shown in the figure, we compute the cost function’s gradient and take steps toward the minima of the cost landscape. This iterative process eventually converges to a good local minimum.
The training from several days ago
Now consider a VQE training we performed several days ago. While we won’t delve into the training details here, you can see the output in the following GIF.
VQE training
The training GIF displays two curves: raw results and mitigated ones. Error mitigation is a critical component of practical quantum computing since real quantum devices are inherently noisy—we cannot implement ideal theoretical operations exactly, only approximations with some error.
To address this limitation, we employ error mitigation strategies. One widely-used approach in near-term quantum applications is Quantum Error Mitigation.
Error mitigation in a few lines
Error mitigation encompasses a broad set of algorithms and methods that take noisy results from quantum hardware and post-process them into more reliable outputs. Rather than completely correcting errors, these techniques provide sufficient improvement to extract usable results from noisy devices. A particularly powerful family of error mitigation techniques is data-driven error mitigation.
Mitigation
Among the most effective data-driven approaches is Clifford Data Regression (CDR). CDR works by building a regression model that relates noisy results to classically-simulable Clifford circuits. This is done starting from a simple concept: we take a family of circuits which are similar in size to the original circuit we want to mitigate (same depth, same number of gates). We will call these circuits training circuits.
For a target circuit that produces noisy expectation value \langle O \rangle_{\text{noisy}}, we collect results from the training circuits (high branch in the figure above):
\langle O \rangle_{\text{noisy}} = a \cdot \langle O \rangle_{\text{Clifford}} + b
where a and b are regression coefficients learned from data. By fitting this linear relationship, we can extrapolate the ideal noiseless result (low branch in the figure above):
\langle O \rangle_{\text{ideal}} \approx a^{-1}(\langle O \rangle_{\text{noisy}} - b)
This approach effectively mitigates errors by leveraging the fact that Clifford circuits can be efficiently simulated classically, allowing us to learn how noise corrupts quantum results.
3. Retrieving today’s noise map using Clifford Data Regression
We need to keep in mind that a noise map computed in the past may not be reliable today. In fact, the status of the device can change over time, due to many factors. So, we will recompute the noise map which is representing the noise affecting the target qubits today.
from tergite import Tergiteimport loggingimport warnings# Suppressing some warnings for cleaner outputwarnings.filterwarnings('ignore')logging.getLogger('stevedore').setLevel(logging.CRITICAL)logging.getLogger('qiskit').setLevel(logging.CRITICAL)# We need to connect to the WACQT system API_URL ="https://api.qpu.wacqt.se/"API_TOKEN ="paste_your_api_token_here"BACKEND_NAME ="wacqt-25"SERVICE_NAME ="local"POLL_TIMEOUT =600# Constructing the backend using Tergiteprovider = Tergite.use_provider_account( service_name=SERVICE_NAME, url=API_URL, token=API_TOKEN)backend = provider.get_backend(BACKEND_NAME)backend.set_options(shots=1024)
Then, we need two functions: 1. one which executes our quantum circuit and retrieve the results; 2. one which perform the Clifford Data Regression logic.
import numpy as npimport qiskit.compiler as compiler def run_vqe_circuit(vqe_circuit, backend, shots=1024):"""Run a quantum circuit and return the energy expectation value using the cost_function"""# We need to transpile the circuit for the specific backend before running it# This will re-write the circuit in terms of native gates of our device and optimize it for better performance t_qc = compiler.transpile(vqe_circuit, backend=backend, optimization_level=0)# Set shots on the backend backend.set_options(shots=shots)# Check if this is a hardware backend (has meas_map) or simulatortry:# Try to access meas_map - hardware backends have this, simulators don't _ = backend.meas_map is_hardware =Trueexcept (NotImplementedError, AttributeError): is_hardware =Falseif is_hardware:# For Tergite hardware, we need to convert to pulse schedules schedules = compiler.schedule(t_qc, backend=backend) job = backend.run([schedules], meas_level=2, meas_return="single")else:# For simulators, just run the circuit directly job = backend.run(t_qc)# Wait for job completion if it's an async backendifhasattr(job, 'wait_for_final_state'): job.wait_for_final_state(timeout=600) result = job.result() counts = result.get_counts()ifisinstance(counts, list): counts = counts[0]# Use the predefined cost_function for consistency energy = cost_function(counts)return energydef calibrate_cdr_simple( reference_circuit, backend_noisy, backend_exact=None, n_training_samples=5, shots=1024 ):""" Calibrate Clifford Data Regression coefficients. Args: reference_circuit: Parametric QuantumCircuit with unbound parameters backend_noisy: Noisy backend (simulator or hardware) backend_exact: Exact backend (simulator). If None, uses AerSimulator() n_training_samples: Number of training samples to collect shots: Number of shots per measurement Returns: (a, b): Regression coefficients where ideal ≈ a * noisy + b data: Dictionary containing 'x_noisy' and 'y_exact' for analysis """if backend_exact isNone:from qiskit_aer import AerSimulator backend_exact = AerSimulator() x_noisy, y_exact = [], [] cliff_gates = [0, np.pi/2, np.pi, 3*np.pi/2]# Get circuit parameters params =list(reference_circuit.parameters)print(f"Calibrating CDR with {n_training_samples} training samples...")for i inrange(n_training_samples):# Generate random Clifford parameters rand_values = [np.random.choice(cliff_gates) for _ in params] param_dict =dict(zip(params, rand_values))# Create fresh copies of the circuit and bind parameters qc_exact = reference_circuit.copy().assign_parameters(param_dict) qc_noisy = reference_circuit.copy().assign_parameters(param_dict)# Measure on both backends e_exact = run_vqe_circuit(qc_exact, backend_exact, shots=shots) e_noisy = run_vqe_circuit(qc_noisy, backend_noisy, shots=shots) x_noisy.append(e_noisy) y_exact.append(e_exact)print(f" Sample {i+1}/{n_training_samples} | Noisy: {e_noisy:7.4f} | Exact: {e_exact:7.4f}")# Fit linear regression a, b = np.polyfit(x_noisy, y_exact, 1)if a <0: a =abs(a)print(f"\nCDR Calibration Complete!")print(f" a={a:.4f}, b={b:.4f}")return a, b, {'x_noisy': x_noisy, 'y_exact': y_exact}
3. Testing the result with non-optimal parameters
First, let’s compute the energy value if we use the random, initial parameters
Then, let’s see what happens if we set the parameters found in the end of our optimization performed some days ago.
# These are the optimal parameters after a first trainingoptimal_params = [3.134086403298594, 3.4921639408208387]# Update the original circuit with optimal parametersvqe_circuit_optimized = vqe_circuit.assign_parameters({theta0: optimal_params[0], theta1: optimal_params[1]})print(f"Optimized circuit created with params: {optimal_params}")vqe_circuit_optimized.draw()
Optimized circuit created with params: [3.134086403298594, 3.4921639408208387]
As we can see, the number is already closer to the correct one (-2), but still not a good approximation. We need to understand what is the current noise map. Let’s compute the CDR procedure.
# We compute the CDR coefficients using the original circuit as reference# and the hadware backend as noisy backendslope, intercept, training_data = calibrate_cdr_simple( reference_circuit=vqe_circuit, backend_noisy=backend, n_training_samples=4, shots=1024)
Calibrating CDR with 4 training samples...
Tergite: Job has been successfully submitted
Sample 1/4 | Noisy: -0.5195 | Exact: -0.9297
Tergite: Job has been successfully submitted
Sample 2/4 | Noisy: -1.1250 | Exact: -2.0000
Tergite: Job has been successfully submitted
Sample 3/4 | Noisy: 0.2910 | Exact: 0.0176
Tergite: Job has been successfully submitted
Sample 4/4 | Noisy: 0.2617 | Exact: -0.0469
CDR Calibration Complete!
a=1.3793, b=-0.3633
# Let's visualize the results of the CDR calibrationimport matplotlib.pyplot as pltplt.figure(figsize=(6, 6*6/8), dpi=120)plt.vlines(0, -2.5, 2.5, color='black', ls='-')plt.hlines(0, -2.5, 2.5, color='black', ls='-')plt.scatter(training_data['x_noisy'], training_data['y_exact'], color="#536BE3", label='Training Data', edgecolors="black", s=70)x_fit = np.linspace(-2, 2, 100)y_fit = slope * x_fit + interceptplt.plot(x_fit, y_fit, color='black', ls='--', label='CDR Fit')plt.plot(x_fit, x_fit, color='red', ls='-', label='Noiseless case fit (quadrants bisector)')plt.xlabel('Noisy Expectation Value')plt.ylabel('Exact Expectation Value')plt.title('CDR Calibration Results')plt.legend()plt.xlim(-2.25, 2.25)plt.ylim(-2.25, 2.25)plt.grid()plt.show()
# Using the found noise map to mitigate the VQE resultmitigated_expval = slope * noisy_expval + interceptprint(mitigated_expval)