Source code for qrisp.algorithms.vqe.vqe_problem

"""
\********************************************************************************
* Copyright (c) 2023 the Qrisp authors
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License, v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is
* available at https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
"""

import time

import numpy as np
from scipy.optimize import minimize
from sympy import Symbol

from qrisp import QuantumArray
from qrisp.algorithms.vqe.vqe_benchmark_data import VQEBenchmark
from qrisp.operators.qubit.measurement import QubitOperatorMeasurement
from qrisp.operators.fermionic import FermionicOperator

[docs] class VQEProblem: r""" Central structure to facilitate treatment of VQE problems. This class encapsulates the Hamiltonian, the ansatz, and the initial state preparation function for a specific VQE problem instance. Parameters ---------- hamiltonian : :ref:`QubitOperator` or :ref:`FermionicOperator` The problem Hamiltonian. ansatz_function : function A function receiving a :ref:`QuantumVariable` or :ref:`QuantumArray` and a parameter list. This function implements the unitary corresponding to one layer of the ansatz. num_params : int The number of parameters per layer. init_function : function, optional A function preparing the initial state. By default, the inital state is the $\ket{0}$ state. callback : bool, optional If ``True``, intermediate results are stored. The default is ``False``. Examples -------- For a quick demonstration, we show how to calculate the ground state energy of the $H_2$ molecule using VQE, as explained `here <https://arxiv.org/abs/2305.07092>`_. :: from qrisp import * from qrisp.operators.qubit import X,Y,Z # Problem Hamiltonian c = [-0.81054, 0.16614, 0.16892, 0.17218, -0.22573, 0.12091, 0.166145, 0.04523] H = c[0] \ + c[1]*Z(0)*Z(2) \ + c[2]*Z(1)*Z(3) \ + c[3]*(Z(3) + Z(1)) \ + c[4]*(Z(2) + Z(0)) \ + c[5]*(Z(2)*Z(3) + Z(0)*Z(1)) \ + c[6]*(Z(0)*Z(3) + Z(1)*Z(2)) \ + c[7]*(Y(0)*Y(1)*Y(2)*Y(3) + X(0)*X(1)*Y(2)*Y(3) + Y(0)*Y(1)*X(2)*X(3) + X(0)*X(1)*X(2)*X(3)) # Ansatz def ansatz(qv,theta): for i in range(4): ry(theta[i],qv[i]) for i in range(3): cx(qv[i],qv[i+1]) cx(qv[3],qv[0]) from qrisp.vqe.vqe_problem import * vqe = VQEProblem(hamiltonian = H, ansatz_function = ansatz, num_params=4, callback=True) energy = vqe.run(qarg = QuantumVariable(4), depth = 1, max_iter=50) print(energy) # Yields -1.864179046 Note that for comparing to the results in the aforementioned paper, we have to add the nuclear repulsion energy $E_{\text{nuc}}=0.72$ to the calculated electronic energy $E_{\text{el}}$. We visualize the optimization process: >>> vqe.visualize_energy(exact=True) .. figure:: /_static/vqeH2.png :alt: VQEH2 :scale: 80% :align: center """ def __init__(self, hamiltonian, ansatz_function, num_params, init_function = None, callback=False): if isinstance(hamiltonian, FermionicOperator): hamiltonian = hamiltonian.to_qubit_operator() self.hamiltonian = hamiltonian self.ansatz_function = ansatz_function self.num_params = num_params self.init_function = init_function self.cl_post_processor = None # parameters for callback self.callback = callback self.optimization_params = [] self.optimization_costs = [] def set_callback(self): """ Sets ``callback=True`` for saving intermediate results. """ self.callback = True
[docs] def set_init_function(self, init_function): """ Set the initial state preparation function for the VQE problem. Parameters ---------- init_function : function The initial state preparation function for the specific VQE problem instance. """ self.init_function = init_function
[docs] def compile_circuit(self, qarg, depth): """ Compiles the circuit that is evaluated by the :meth:`run <qrisp.vqe.VQEProblem.run>` method. Parameters ---------- qarg : :ref:`QuantumVariable` or :ref:`QuantumArray` The argument to which the VQE circuit is applied. depth : int The amount of VQE layers. Returns ------- compiled_qc : QuantumCircuit The parametrized, compiled QuantumCircuit without measurements. list[sympy.Symbol] A list of the parameters that appear in ``compiled_qc``. """ temp = list(qarg.qs.data) # Define parameters theta for VQE circuit theta = [Symbol("theta_" + str(i) + str(j)) for i in range(depth) for j in range(self.num_params)] # Prepare the initial state for particular problem instance, the default is the \ket{0} state if self.init_function is not None: self.init_function(qarg) # Apply p layers of the ansatz for i in range(depth): self.ansatz_function(qarg,[theta[self.num_params*i+j] for j in range(self.num_params)]) # Compile quantum circuit compiled_qc = qarg.qs.compile() qarg.qs.data = temp return compiled_qc, theta
def optimization_routine(self, qarg, depth, mes_kwargs, max_iter, init_type="random", init_point=None, measurement_data=None, optimizer="COBYLA"): """ Wrapper subroutine for the optimization method used in QAOA. The initial values are set and the optimization via ``COBYLA`` is conducted here. Parameters ---------- qarg : :ref:`QuantumVariable` or :ref:`QuantumArray` The argument the cost function is called on. depth : int The amont of VQE layers. mes_kwargs : dict The keyword arguments for the measurement function. max_iter : int The maximum number of iterations for the optimization method. init_type : string, optional Specifies the way the initial optimization parameters are chosen. Available is ``random``. The default is ``random``: Parameters are initialized uniformly at random in the interval $[0,\pi/2)]$. init_point : list[float], optional Specifies the initial optimization parameters. measurement_data : QubitOperatorMeasurement Cached data to accelerate the measurement procedure. Automatically generated by default. optimizer : str, optional Specifies the optimization routine. Available are ``COBYLA``, ``COBYQA``, ``Nelder-Mead``. The Default is "COBYLA". Returns ------- res_sample The optimized parameters of the problem instance. """ # Define optimization wrapper function to be minimized using VQE def optimization_wrapper(theta, qc, symbols, qarg, measurement_data, mes_kwargs): """ Wrapper function for the optimization method used in VQE. This function calculates the expected value of the Hamiltonian after post-processing if a post-processing function is set, otherwise it calculates the expected value of the Hamiltonian. Parameters ---------- theta : list The list of angle parameters for the VQE circuit. qc : QuantumCircuit The compiled quantum circuit. symbols : list The list of symbols used in the quantum circuit. qarg_dupl : :ref:`QuantumVariable` The duplicated quantum variable to which the quantum circuit is applied. mes_kwargs : dict The keyword arguments for the measurement function. Returns ------- float The expected value of the Hamiltonian. """ subs_dic = {symbols[i] : theta[i] for i in range(len(symbols))} expectation = self.hamiltonian.get_measurement(qarg, subs_dic = subs_dic, measurement_data = measurement_data, precompiled_qc = qc, **mes_kwargs) if self.callback: self.optimization_costs.append(expectation) if self.cl_post_processor is not None: return self.cl_post_processor(expectation) else: return expectation if init_point is None: # Set initial random values for optimization parameters if init_type=='random': init_point = np.pi * np.random.rand(depth * self.num_params)/2 #def optimization_cb(x): # self.optimization_params.append(x) # Perform optimization using specified method compiled_qc, symbols = self.compile_circuit(qarg, depth) res_sample = minimize(optimization_wrapper, init_point, method=optimizer, options={'maxiter':max_iter}, args = (compiled_qc, symbols, qarg, measurement_data, mes_kwargs)) return res_sample['x']
[docs] def run(self, qarg, depth, mes_kwargs = {}, max_iter = 50, init_type = "random", init_point=None, optimizer="COBYLA"): """ Run the specific VQE problem instance with given quantum arguments, depth of VQE circuit, measurement keyword arguments (mes_kwargs) and maximum iterations for optimization (max_iter). Parameters ---------- qarg : :ref:`QuantumVariable` or :ref:`QuantumArray` The argument to which the VQE circuit is applied. depth : int The amount of VQE layers. mes_kwargs : dict, optional The keyword arguments for the measurement function. Default is an empty dictionary. By default, the target ``precision`` is set to 0.01, and the maximum amount of ``shots`` is 100000. max_iter : int, optional The maximum number of iterations for the optimization method. Default is 50. init_type : string, optional Specifies the way the initial optimization parameters are chosen. Available is ``random``. The default is ``random``: Parameters are initialized uniformly at random in the interval $[0,\pi/2)]$. init_point : list[float], optional Specifies the initial optimization parameters. optimizer : str, optional Specifies the `optimization routine <https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html>`_. Available are, e.g., ``COBYLA``, ``COBYQA``, ``Nelder-Mead``. The Default is ``COBYLA``. Returns ------- energy : float The expected value of the Hamiltonian after applying the optimal VQE circuit to the quantum argument. """ # Delete callback self.optimization_params = [] self.optimization_costs = [] if not "shots" in mes_kwargs: mes_kwargs["shots"] = 1000000 if not "precision" in mes_kwargs: mes_kwargs["precision"] = 0.01 measurement_data = QubitOperatorMeasurement(self.hamiltonian) optimal_theta = self.optimization_routine(qarg, depth, mes_kwargs, max_iter, init_type, init_point, measurement_data=measurement_data, optimizer = optimizer) # Prepare the initial state for particular problem instance, the default is the \ket{0} state if self.init_function is not None: self.init_function(qarg) # Apply p layers of the ansatz for i in range(depth): self.ansatz_function(qarg,[optimal_theta[self.num_params*i+j] for j in range(self.num_params)]) opt_res = self.hamiltonian.get_measurement(qarg,**mes_kwargs, measurement_data=measurement_data) return opt_res
[docs] def train_function(self, qarg, depth, mes_kwargs = {}, max_iter = 50, init_type = "random", init_point=None, optimizer="COBYLA"): """ This function allows for training of a circuit with a given instance of a ``VQEProblem``. It will then return a function that can be applied to a :ref:`QuantumVariable`, such that it prepares the ground state of the problem Hamiltonian. The function therefore applies a circuit for the problem instance with optimized parameters. Parameters ---------- qarg : :ref:`QuantumVariable` The argument to which the VQE circuit is applied. depth : int The amount of VQE layers. mes_kwargs : dict, optional The keyword arguments for the measurement function. Default is an empty dictionary. By default, the target ``precision`` is set to 0.01, and the maximum amount of ``shots`` is 100000. max_iter : int, optional The maximum number of iterations for the optimization method. Default is 50. init_type : string, optional Specifies the way the initial optimization parameters are chosen. Available is ``random``. The default is ``random``: Parameters are initialized uniformly at random in the interval $[0,\pi/2)]$. init_point : list[float], optional Specifies the initial optimization parameters. optimizer : str, optional Specifies the `optimization routine <https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html>`_. Available are, e.g., ``COBYLA``, ``COBYQA``, ``Nelder-Mead``. The Default is "COBYLA". Returns ------- circuit_generator : function A function that can be applied to a :ref:`QuantumVariable`, with optimized parameters for the problem instance. The :ref:`QuantumVariable` then represents the ground state of the problem Hamiltonian. """ if not "shots" in mes_kwargs: mes_kwargs["shots"] = 1000000 if not "precision" in mes_kwargs: mes_kwargs["precision"] = 0.01 measurement_data = QubitOperatorMeasurement(self.hamiltonian) optimal_theta = self.optimization_routine(qarg, depth, mes_kwargs, max_iter, init_type, init_point, optimizer, measurement_data=measurement_data) def circuit_generator(qarg_gen): # Prepare the initial state for particular problem instance, the default is the \ket{0} state if self.init_function is not None: self.init_function(qarg_gen) for i in range(depth): self.ansatz_function(qarg_gen,[optimal_theta[self.num_params*i+j] for j in range(self.num_params)]) return circuit_generator
[docs] def benchmark(self, qarg, depth_range, shot_range, iter_range, optimal_energy, repetitions = 1, mes_kwargs = {}, init_type = "random"): """ This method enables convenient data collection regarding performance of the implementation. Parameters ---------- qarg : :ref:`QuantumVariable` or :ref:`QuantumArray` The quantum argument the benchmark is executed on. Compare to the :meth:`.run <qrisp.vqe.VQEProblem.run>` method. depth_range : list[int] A list of integers indicating, which depth parameters should be explored. Depth means the amount of VQE layers. shot_range : list[int] A list of integers indicating, which shots parameters should be explored. Shots means the amount of repetitions, the backend performs per iteration and per measurement setting. iter_range : list[int] A list of integers indicating, what iterations parameter should be explored. Iterations means the amount of backend calls, the optimizer is allowed to do. optimal_energy: float The exact ground state energy of the problem Hamiltonian. repetitions : int, optional The amount of repetitions, each parameter constellation should go though. Can be used to get a better statistical significance. The default is 1. mes_kwargs : dict, optional The keyword arguments, that are used for the ``qarg.get_spin_measurement``. The default is {}. init_type : string, optional Specifies the way the initial optimization parameters are chosen. Available is ``random``. The default is ``random``: Parameters are initialized uniformly at random in the interval $[0,\pi/2)]$. Returns ------- :ref:`VQEBenchmark` The results of the benchmark. Examples -------- We create a Heisenberg problem instance and benchmark several parameters: :: from networkx import Graph G =Graph() G.add_edges_from([(0,1),(1,2),(2,3),(3,4)]) from qrisp.vqe.problems.heisenberg import * vqe = heisenberg_problem(G,1,0) H = create_heisenberg_hamiltonian(G,1,0) benchmark_data = vqe.benchmark(qarg = QuantumVariable(5), depth_range = [1,2,3], shot_range = [5000,10000], iter_range = [25,50], optimal_energy = H.ground_state_energy(), repetitions = 2 ) We can investigate the data by calling ``visualize``: :: benchmark_data.visualize() .. image:: vqe_benchmark_plot.png The :ref:`VQEBenchmark` class contains a variety of methods to help you drawing conclusions from the collected data. Make sure to check them out! """ data_dict = {"layer_depth" : [], "circuit_depth" : [], "qubit_amount" : [], "shots" : [], "iterations" : [], "runtime" : [], "energy" : [] } for p in depth_range: for s in shot_range: for it in iter_range: for k in range(repetitions): if isinstance(qarg, QuantumArray): qarg_dupl = QuantumArray(qtype = qarg.qtype, shape = qarg.shape) else: qarg_dupl = qarg.duplicate() start_time = time.time() temp_mes_kwargs = dict(mes_kwargs) temp_mes_kwargs["shots"] = s energy = self.run(qarg=qarg_dupl, depth = p, max_iter = it, mes_kwargs = temp_mes_kwargs, init_type=init_type) final_time = time.time() - start_time compiled_qc = qarg_dupl.qs.compile() data_dict["layer_depth"].append(p) data_dict["circuit_depth"].append(compiled_qc.depth()) data_dict["qubit_amount"].append(compiled_qc.num_qubits()) data_dict["shots"].append(s) data_dict["iterations"].append(it) data_dict["energy"].append(energy) data_dict["runtime"].append(final_time) return VQEBenchmark(data_dict, optimal_energy, self.hamiltonian)
[docs] def visualize_energy(self,exact=False): """ Visualizes the energy during the optimization process. Parameters ---------- exact : Boolean If ``True``, the exact ground state energy of the spin operator is computed classically, and compared to the energy in the optimization process. The default is ``False``. """ import matplotlib.pyplot as plt if not self.callback: raise Exception("Visualization can only be performed for a VQE instance with callback=True") if exact: exact_solution = self.hamiltonian.ground_state_energy() plt.axhline(y=exact_solution, color="#6929C4", linestyle='--', linewidth=2, label='Exact energy') x = list(range(len(self.optimization_costs))) y = self.optimization_costs plt.scatter(x, y, color='#20306f',marker="o", linestyle='solid', linewidth=1, label='VQE energy') plt.xlabel("Iterations", fontsize=15, color="#444444") plt.ylabel("Energy", fontsize=15, color="#444444") plt.legend(fontsize=12, labelcolor="#444444") plt.tick_params(axis='both', labelsize=12) plt.grid() plt.show()