diff --git a/tangelo/algorithms/variational/__init__.py b/tangelo/algorithms/variational/__init__.py index 448ade83a..9d46fddcc 100644 --- a/tangelo/algorithms/variational/__init__.py +++ b/tangelo/algorithms/variational/__init__.py @@ -16,3 +16,4 @@ from .vqe_solver import VQESolver, BuiltInAnsatze from .sa_vqe_solver import SA_VQESolver from .sa_oo_vqe_solver import SA_OO_Solver +from .iqcc_solver import iQCC_solver diff --git a/tangelo/algorithms/variational/iqcc_solver.py b/tangelo/algorithms/variational/iqcc_solver.py new file mode 100644 index 000000000..cd90459e3 --- /dev/null +++ b/tangelo/algorithms/variational/iqcc_solver.py @@ -0,0 +1,273 @@ +# Copyright 2021 Good Chemistry Company. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This module implements the iterative qubit coupled cluster (iQCC)-VQE +procedure of Ref. 1. It is a variational approach that utilizes the +the QCC ansatz to produce shallow circuits. The iterative procedure +allows a small number (1—10) of generators to be used for the QCC +This results in even shallower circuits and fewer quantum resources +for the iQCC approach relative to the native QCC method. A caveat +is that after each iteration, the qubit Hamiltonian is dressed with +the generators and optimal parameters, the result of which is an +exponential growth of the number of terms. A technique also described +in Ref. 1 can be utilized to address this issue by discarding some +terms based on the Frobenius norm of the Hamiltonian. + +Refs: + 1. I. G. Ryabinkin, R. A. Lang, S. N. Genin, and A. F. Izmaylov. + J. Chem. Theory Comput. 2020, 16, 2, 1055–1063. +""" + +from tangelo.linq import Simulator +from tangelo.toolboxes.ansatz_generator.qcc import QCC +from tangelo.algorithms.variational.vqe_solver import VQESolver +from tangelo.toolboxes.ansatz_generator._qubit_cc import qcc_op_dress + + +class iQCC_solver: + """The iQCC-VQE solver class combines the QCC ansatz and VQESolver classes + to perform an iterative and variational procedure to compute the total QCC + energy for a given Hamiltonian. The algorithm is outlined below: + + (0) Prepare a qubit Hamiltonian, initialize QMF parameters, construct the + DIS, select QCC generators, and initialize QCC amplitudes. + (1) Simulate the QCC energy through VQE minimization. + (2) Check if the energy is lowered relative to the previous iteration. + (3) If the energy is lowered, proceed to (4); else, keep the QCC generators, + re-initialize the amplitudes, and re-compute the energy. If after several + attempts the energy is not lowered, set all QCC amplitudes to zero and + use the QMF parameters from the previous iteration to compute the energy. + This is guaranteed to yield a lower energy. + (4) Check termination criteria: terminate if the change in energy is below a + threshold, the DIS is empty, or the maximum number of iterations is reached. + (5) If not terminated, dress the qubit Hamiltonian with the current QCC + generators and optimal amplitudes. + (6) Purify the QMF parameters, rebuild the DIS, and select generators for + the next iteration; return to (1) and repeat until termination. + + Attributes: + molecule (SecondQuantizedMolecule): The molecular system. + qubit_mapping (str): One of the supported qubit mapping identifiers. Default, "jw". + up_then_down (bool): Change basis ordering putting all spin up orbitals first, + followed by all spin down. Default, False. + initial_var_params (str or array-like): Initial values of the variational parameters + for the classical optimizer. + backend_options (dict): Parameters to build the tangelo.linq Simulator + class. + penalty_terms (dict): Parameters for penalty terms to append to target + qubit Hamiltonian (see penaly_terms for more details). + ansatz_options (dict): Parameters for the chosen ansatz (see given ansatz + file for details). + qubit_hamiltonian (QubitOperator-like): Self-explanatory. + deqcc_thresh (float): threshold for the difference in iQCC energies between + consecutive iterations required for convergence of the algorithm. + Default, 1e-5 Hartree. + max_iqcc_iter (int): maximum number of iQCC iterations allowed before termination. + Default, 100. + max_iqcc_retries (int): if the iQCC energy for a given iteration is not lower than + the value from the previous iteration, the iQCC parameters are reinitialized + and the VQE procedure will be attempted up to max_iqcc_retries times. If unsuccessful + after max_iqcc_retries attempts, the iQCC parameters are all set to 0 and the QMF + Bloch angles from the previous iteration are used. Default, 10. + compress_qubit_ham (bool): controls whether the qubit Hamiltonian is compressed + after dressing with the current set of generators at the end of each iQCC iteration. + Default, False. + compress_eps (float): parameter required for compressing intermediate iQCC Hamiltonians + using the Froebenius norm. Discarding terms in this manner will not alter the + eigenspeectrum of intermediate Hamiltonians by more than compress_eps. + Default, 1.59e-3 Hartree. + verbose (bool): Flag for verbosity of iQCCsolver. Default, False. + """ + + def __init__(self, opt_dict): + + default_backend_options = {"target": None, "n_shots": None, "noise_model": None} + default_options = {"molecule": None, + "qubit_mapping": "jw", + "up_then_down": False, + "initial_var_params": None, + "backend_options": default_backend_options, + "penalty_terms": None, + "ansatz_options": dict(), + "qubit_hamiltonian": None, + "deqcc_thresh": 1e-5, + "max_iqcc_iter": 100, + "max_iqcc_retries": 10, + "compress_qubit_ham": False, + "compress_eps": 1.59e-3, + "verbose": False} + + # Initialize with default values + self.__dict__ = default_options + # Overwrite default values with user-provided ones, if they correspond to a valid keyword + for param, val in opt_dict.items(): + if param in default_options: + setattr(self, param, val) + else: + raise KeyError(f"Keyword :: {param}, not available in iQCCsolver") + + if not self.molecule: + raise ValueError("An instance of SecondQuantizedMolecule is required for initializing iQCCsolver.") + + # initialize variables and lists to store useful data from each iQCC-VQE iteration + self.energies = [] + self.iteration = 0 + self.converged = False + self.qmf_energy = None + self.qcc_ansatz = None + self.vqe_solver = None + self.vqe_solver_options = None + self.final_optimal_energy = None + self.final_optimal_qmf_params = None + self.final_optimal_qcc_params = None + + def build(self): + """Builds the underlying objects required to run the iQCC-VQE algorithm.""" + + # instantiate the QCC ansatz but do not build it here because vqe_solver builds it + self.qcc_ansatz = QCC(self.molecule, self.qubit_mapping, self.up_then_down, **self.ansatz_options) + + # build an instance of VQESolver with options that remain fixed during the iQCC-VQE routine + self.vqe_solver_options = {"molecule": self.molecule, + "qubit_mapping": self.qubit_mapping, + "ansatz": self.qcc_ansatz, + "initial_var_params": self.initial_var_params, + "backend_options": self.backend_options, + "penalty_terms": self.penalty_terms, + "up_then_down": self.up_then_down, + "qubit_hamiltonian": self.qubit_hamiltonian, + "verbose": self.verbose} + self.vqe_solver = VQESolver(self.vqe_solver_options) + self.vqe_solver.build() + + def simulate(self): + """Executes the iQCC-VQE algorithm. During each iteration, + QCC-VQE minimization is performed.""" + + # initialize quantities; compute the QMF energy and set this as eqcc_old + sim = Simulator() + self.qmf_energy = sim.get_expectation_value(self.qcc_ansatz.qubit_ham, self.qcc_ansatz.qmf_circuit) + e_qcc, eqcc_old, delta_eqcc = 0., self.qmf_energy, self.deqcc_thresh + + if self.verbose: + print(f"The qubit mean field energy = {self.qmf_energy}") + + while not self.converged and self.iteration < self.max_iqcc_iter: + # check that the DIS has at least one generator to use; otherwise terminate + if self.qcc_ansatz.dis and self.qcc_ansatz.var_params.any(): + e_qcc = self.vqe_solver.simulate() + delta_eqcc = e_qcc - eqcc_old + eqcc_old = e_qcc + else: + self.converged = True + if self.verbose: + print("Terminating the iQCC-VQE solver: the DIS of QCC generators is empty.") + + # check if unsuccessful: energy is not lowered and energy is not converged. + if delta_eqcc > 0. and delta_eqcc >= self.deqcc_thresh: + n_retry = 0 + if self.verbose: + print(f"The energy at iteration {self.iteration} is greater than the energy " + f"from the previous iteration. Making {self.max_iqcc_retries} attempts " + f"to find a lower energy solution") + + # make several attempts to obtain a lower energy + while e_qcc > eqcc_old and n_retry < self.max_iqcc_retries: + self.qcc_ansatz.var_params = None + self.qcc_ansatz.update_var_params("random") + self.vqe_solver.initial_var_params = self.qcc_ansatz.var_params + e_qcc = self.vqe_solver.simulate() + n_retry += 1 + + # check if energy was lowered; else zero the amplitudes and recompute + if e_qcc < eqcc_old: + delta_eqcc = e_qcc - eqcc_old + eqcc_old = e_qcc + else: + self.qcc_ansatz.var_params = None + self.qcc_ansatz.update_var_params("qmf_state") + self.vqe_solver.initial_var_params = self.qcc_ansatz.var_params + eqcc_old = e_qcc + e_qcc = self.vqe_solver.simulate() + delta_eqcc = e_qcc - eqcc_old + + # update simulation data and check convergence + if not self.converged: + self._update_iqcc_solver(delta_eqcc) + + return self.energies[-1] + + def get_resources(self): + """Returns the quantum resource estimates for the final + iQCC-VQE iteration.""" + + return self.vqe_solver.get_resources() + + def _update_iqcc_solver(self, delta_eqcc): + """This function serves several purposes after successful iQCC-VQE + iterations: + (1) updates/stores the energy, generators, QMF Bloch angles, + QCC amplitudes, circuits, number of qubit Hamiltonian terms, + and quantum resource estimates; + (2) dresses/compresses the qubit Hamiltonian with the current + generators and optimal amplitudes; + (3) prepares for the next iteration by rebuilding the DIS, + re-initializing the amplitudes for a new set of generators, + generating the circuit, and updates the classical optimizer. + """ + + # get the optimal variational parameters and split them for qmf and qcc + n_qubits = self.qcc_ansatz.n_qubits + optimal_qmf_var_params = self.vqe_solver.optimal_var_params[:2*n_qubits] + optimal_qcc_var_params = self.vqe_solver.optimal_var_params[2*n_qubits:] + + # update all lists with data from the current iteration + self.energies.append(self.vqe_solver.optimal_energy) + + # dress and (optionally) compress the qubit Hamiltonian + self.qcc_ansatz.qubit_ham = qcc_op_dress(self.qcc_ansatz.qubit_ham, self.qcc_ansatz.dis, + optimal_qcc_var_params) + if self.compress_qubit_ham: + self.qcc_ansatz.qubit_ham.frobenius_norm_compression(self.compress_eps, n_qubits) + + # set dis and var_params to none to rebuild the dis and initialize new amplitudes + self.qcc_ansatz.dis = None + self.qcc_ansatz.var_params = None + self.qcc_ansatz.build_circuit() + self.vqe_solver.initial_var_params = self.qcc_ansatz.var_params + + self.iteration += 1 + + if self.verbose: + print(f"Iteration # {self.iteration}") + print(f"iQCC total energy = {self.vqe_solver.optimal_energy} Eh") + print(f"iQCC correlation energy = {self.vqe_solver.optimal_energy-self.qmf_energy} Eh") + print(f"Optimal QMF variational parameters = {optimal_qmf_var_params}") + print(f"Optimal QCC variational parameters = {optimal_qcc_var_params}") + print(f"Number of iQCC generators = {len(self.qcc_ansatz.dis)}") + print(f"iQCC generators = {self.qcc_ansatz.dis}") + print(f"iQCC resource estimates = {self.get_resources()}") + + if abs(delta_eqcc) < self.deqcc_thresh or self.iteration == self.max_iqcc_iter: + self.converged = True + self.final_optimal_energy = self.vqe_solver.optimal_energy + self.final_optimal_qmf_params = optimal_qmf_var_params + self.final_optimal_qcc_params = optimal_qcc_var_params + + if self.verbose: + if abs(delta_eqcc) < self.deqcc_thresh: + print("Terminating the iQCC-VQE solver: energy convergence threshold achieved.") + elif self.iteration == self.max_iqcc_iter: + print("Terminating the iQCC-VQE solver: maximum number of iQCC iterations reached.") diff --git a/tangelo/algorithms/variational/tests/test_iqcc_solver.py b/tangelo/algorithms/variational/tests/test_iqcc_solver.py new file mode 100644 index 000000000..2c18693ef --- /dev/null +++ b/tangelo/algorithms/variational/tests/test_iqcc_solver.py @@ -0,0 +1,110 @@ +# Copyright 2021 Good Chemistry Company. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the closed-shell and restricted open-shell iQCC-VQE Solver. """ + +import unittest + +from tangelo.algorithms.variational import iQCC_solver +from tangelo.molecule_library import mol_H2_sto3g, mol_H4_sto3g, mol_H4_cation_sto3g,\ + mol_H4_doublecation_minao + + +class iQCC_solver_test(unittest.TestCase): + """Unit tests for the iQCC_solver class. Examples for both closed-shell + and restricted open-shell iQCC are provided via H4, H4+, and H4+2. + """ + + @staticmethod + def test_build_success(): + """Test instantation of iQCC solver with user-defined input.""" + + iqcc_options = {"molecule": mol_H2_sto3g, + "qubit_mapping": "scbk", + "up_then_down": True, + "deqcc_thresh": 1e-5, + "max_iqcc_iter": 25, + "max_iqcc_retries": 10, + "compress_qubit_ham": True, + "compress_eps": 1e-4} + + iqcc = iQCC_solver(iqcc_options) + iqcc.build() + + def test_build_fail(self): + """Test that instantation of iQCC solver fails without input of a molecule.""" + + iqcc_options = {"max_iqcc_iter": 15} + self.assertRaises(ValueError, iQCC_solver, iqcc_options) + + def test_iqcc_h4(self): + """Test the energy after 1 iteration for H4 using the maximum + number of generators and compressing the qubit Hamiltonian""" + + ansatz_options = {"max_qcc_gens": None} + + iqcc_options = {"molecule": mol_H4_sto3g, + "qubit_mapping": "scbk", + "up_then_down": True, + "ansatz_options": ansatz_options, + "deqcc_thresh": 1e-5, + "max_iqcc_iter": 1, + "compress_qubit_ham": True, + "compress_eps": 1e-4} + + iqcc_solver = iQCC_solver(iqcc_options) + iqcc_solver.build() + iqcc_energy = iqcc_solver.simulate() + + self.assertAlmostEqual(iqcc_energy, -1.96259, places=4) + + def test_iqcc_h4_cation(self): + """Test the energy after 3 iterations for H4+""" + + ansatz_options = {"max_qcc_gens": None} + + iqcc_options = {"molecule": mol_H4_cation_sto3g, + "qubit_mapping": "scbk", + "up_then_down": True, + "ansatz_options": ansatz_options, + "deqcc_thresh": 1e-5, + "max_iqcc_iter": 3} + + iqcc = iQCC_solver(iqcc_options) + iqcc.build() + iqcc_energy = iqcc.simulate() + + self.assertAlmostEqual(iqcc_energy, -1.638524, places=4) + + def test_iqcc_h4_double_cation(self): + """Test the energy after 1 iteration for H4+2""" + + ansatz_options = {"max_qcc_gens": None} + + iqcc_options = {"molecule": mol_H4_doublecation_minao, + "qubit_mapping": "scbk", + "up_then_down": True, + "ansatz_options": ansatz_options, + "deqcc_thresh": 1e-5, + "max_iqcc_iter": 1} + + iqcc = iQCC_solver(iqcc_options) + iqcc.build() + iqcc_energy = iqcc.simulate() + + self.assertAlmostEqual(iqcc_energy, -0.854647, places=4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tangelo/algorithms/variational/vqe_solver.py b/tangelo/algorithms/variational/vqe_solver.py index cd2d41f84..b16a89f4f 100644 --- a/tangelo/algorithms/variational/vqe_solver.py +++ b/tangelo/algorithms/variational/vqe_solver.py @@ -128,8 +128,8 @@ def __init__(self, opt_dict): # The QCC & ILC ansatze require up_then_down=True when mapping="jw" if isinstance(self.ansatz, BuiltInAnsatze): if self.ansatz in (BuiltInAnsatze.QCC, BuiltInAnsatze.ILC) and self.qubit_mapping.lower() == "jw" and not self.up_then_down: - warnings.warn("Efficient generator screening for QCC-based ansatze requires spin-orbital ordering to be " - "all spin-up first followed by all spin-down for the JW mapping.", RuntimeWarning) + warnings.warn("Spin-orbital ordering shifted to all spin-up first then down to ensure efficient generator screening " + "for the Jordan-Wigner mapping with QCC-based ansatze.", RuntimeWarning) self.up_then_down = True # QCC and QMF and ILC require a reference state that can be represented by a single layer of RZ-RX gates on each qubit. # This decomposition can not be determined from a general Circuit reference state. diff --git a/tangelo/toolboxes/ansatz_generator/_qubit_cc.py b/tangelo/toolboxes/ansatz_generator/_qubit_cc.py index d7ecb26cc..ee6f04f27 100644 --- a/tangelo/toolboxes/ansatz_generator/_qubit_cc.py +++ b/tangelo/toolboxes/ansatz_generator/_qubit_cc.py @@ -30,14 +30,17 @@ J. Chem. Theory Comput. 2020, 16, 2, 1055–1063. """ +from math import sin, cos from itertools import combinations +from openfermion import commutator + from tangelo.toolboxes.operators.operators import QubitOperator from tangelo.toolboxes.ansatz_generator._qubit_mf import get_op_expval def construct_dis(qubit_ham, pure_var_params, deqcc_dtau_thresh): - """Construct the DIS of QCC generators, which proceeds as follows: + """ Construct the direct interaction set (DIS) of QCC generators as follows: 1. Identify the flip indices of all Hamiltonian terms and group terms by flip indices. 2. Construct a representative generator using flip indices from each candidate DIS group and evaluate dEQCC/dtau for all Hamiltonian terms. @@ -62,7 +65,9 @@ def construct_dis(qubit_ham, pure_var_params, deqcc_dtau_thresh): for dis_group in dis_groups: dis_group_idxs = [int(idxs) for idxs in dis_group[0].split(" ")] dis_group_gens = get_gens_from_idxs(dis_group_idxs) - dis.append(dis_group_gens) + # for now just grab the first generator; eventually add capability to + # allow the user to select which generators to use. + dis.append(dis_group_gens[0]) else: raise ValueError(f"The DIS is empty: there are no candidate DIS groups where " f"|dEQCC/dtau| >= {deqcc_dtau_thresh} a.u. Terminate simulation.\n") @@ -154,3 +159,54 @@ def get_gens_from_idxs(group_idxs): gen_list = [(idx, "Y") if idx in xy_idx else (idx, "X") for idx in group_idxs] dis_group_gens.append(QubitOperator(tuple(gen_list), 1.)) return dis_group_gens + + +def build_qcc_qubit_op(dis_gens, amplitudes): + """Returns the QCC operator by selecting n_var_params generators from the DIS. + The QCC operator is constructed as a linear combination of generators using the + parameter set {tau} as coefficients: QCC operator = -0.5 * SUM_k P_k * tau_k. + The exponentiated QCC operator, U = PROD_k exp(-0.5j * tau_k * P_k), is used to + build the circuit. + + Args: + dis_gens (list of QubitOperator): The list of QCC Pauli word generators + selected from a user-specified number of characteristic DIS groups. + amplitudes (list or numpy array of float): The QCC variational parameters + arranged such that their ordering matches the order of dis_gens. + + Returns: + QubitOperator: QCC ansatz operator. + """ + + qubit_op = QubitOperator.zero() + for i, dis_gen in enumerate(dis_gens): + qubit_op -= 0.5 * amplitudes[i] * dis_gen + qubit_op.compress() + return qubit_op + + +def qcc_op_dress(qubit_op, dis_gens, amplitudes): + """Performs canonical transformation of a qubit operator with the set of QCC + generators and amplitudes for the current iteration. For an operator with M terms + each transformation results in exponential growth of the number terms. This growth + can be approximated as M * (3 / 2) ^ n_g, where n_g is the number of QCC generators + selected for the ansatz at the current iteration. + + Args: + qubit_op (QubitOperator): A qubit operator (e.g., a molecular Hamiltonian or the + electronic spin and number operators) that was previously dressed by canonical + transformation with the QCC generators and amplitudes at the current iteration. + dis_gens (list of QubitOperator): The list of QCC Pauli word generators + selected from a user-specified number of characteristic DIS groups. + amplitudes (list or numpy array of float): The QCC variational parameters + arranged such that their ordering matches the ordering of dis_gens. + + Returns: + QubitOperator: Dressed qubit operator. + """ + + for i, gen in enumerate(dis_gens): + comm = commutator(qubit_op, gen) + qubit_op += .5 * ((1. - cos(amplitudes[i])) * gen - 1j * sin(amplitudes[i])) * comm + qubit_op.compress() + return qubit_op diff --git a/tangelo/toolboxes/ansatz_generator/_qubit_ilc.py b/tangelo/toolboxes/ansatz_generator/_qubit_ilc.py index d9543f561..419c17cc2 100644 --- a/tangelo/toolboxes/ansatz_generator/_qubit_ilc.py +++ b/tangelo/toolboxes/ansatz_generator/_qubit_ilc.py @@ -13,7 +13,7 @@ # limitations under the License. """This module implements a collection of functions related to the ILC ansatz: -1. Function to create the anti-commuting set (ACS) of generators from the QCC DIS; +1. Function to create the anticommuting set (ACS) of generators from the QCC DIS; 2. An efficient solver that performs Gaussian elimination over GF(2); 3. Function that computes the ILC parameters via matrix diagonalization. @@ -36,7 +36,7 @@ def construct_acs(dis, max_ilc_gens, n_qubits): - """Driver function for constructing the anti-commuting set of generators from + """Driver function for constructing the anticommuting set of generators from the direct interaction set (DIS) of QCC generators. Args: @@ -45,7 +45,7 @@ def construct_acs(dis, max_ilc_gens, n_qubits): n_qubits (int): number of qubits Returns: - list of QubitOperator: the anti-commuting set (ACS) of ILC generators + list of QubitOperator: the anticommuting set (ACS) of ILC generators """ bad_sln_idxs, good_sln = [], False @@ -57,7 +57,7 @@ def construct_acs(dis, max_ilc_gens, n_qubits): # a_mat --> A and z_vec --> z in Appendix A, Refs. 1 & 2. a_mat, z_vec, one_vec = np.zeros((ng2, ngnq)), np.zeros(ngnq), np.ones((ng2, 1)) for idx, gen_idx in enumerate(gen_idxs): - gen = dis[gen_idx][0] + gen = dis[gen_idx] for term in gen.terms: for paulis in term: p_idx, pauli = paulis @@ -77,7 +77,7 @@ def construct_acs(dis, max_ilc_gens, n_qubits): # Solve A * z = b --> here b = 1 z_sln = gauss_elim_over_gf2(a_mat, b_vec=one_vec) - # Check solution: odd # of Y ops, at least two flip indices, and mutually anti-commutes + # Check solution: odd # of Y ops, at least two flip indices, and mutually anticommutes for i in range(n_gens): n_flip, n_y, gen_idx, gen_tup = 0, 0, gen_idxs[i], tuple() for j in range(n_qubits): @@ -98,7 +98,7 @@ def construct_acs(dis, max_ilc_gens, n_qubits): if n_flip > 1 and n_y % 2 == 1: gen_i = QubitOperator(gen_tup, 1.) good_sln = True - # check mutual anti-commutativity of each new ILC generator with all the rest + # check mutual anticommutativity of each new ILC generator with all the rest for gen_j in ilc_gens: if gen_i * gen_j != -1. * gen_j * gen_i: if gen_idx not in bad_sln_idxs: @@ -192,7 +192,7 @@ def get_ilc_params_by_diag(qubit_ham, ilc_gens, qmf_var_params): Args: qubit_ham (QubitOperator): the qubit Hamiltonian of the system. - ilc_gens (list of QubitOperator): the anti-commuting set of ILC Pauli words. + ilc_gens (list of QubitOperator): the anticommuting set of ILC Pauli words. Returns: list of float: the ILC parameters corresponding to the ACS of ILC generators @@ -241,4 +241,5 @@ def get_ilc_params_by_diag(qubit_ham, ilc_gens, qmf_var_params): denom_sum += pow(gs_coefs[i].real, 2.) + pow(gs_coefs[i].imag, 2.) beta = np.arcsin(gs_coefs[i] / np.sqrt(denom_sum)) ilc_var_params.append(beta.real) + del ilc_gens[0] return ilc_var_params diff --git a/tangelo/toolboxes/ansatz_generator/ilc.py b/tangelo/toolboxes/ansatz_generator/ilc.py index 117e2b742..ba708a0ef 100755 --- a/tangelo/toolboxes/ansatz_generator/ilc.py +++ b/tangelo/toolboxes/ansatz_generator/ilc.py @@ -13,7 +13,7 @@ # limitations under the License. """This module defines the qubit coupled cluster ansatz class with involutory -linear combinations (ILC) of anti-commuting sets (ACS) of Pauli words +linear combinations (ILC) of anticommuting sets (ACS) of Pauli words (generators). Relative to the direct interation set (DIS) of QCC generators, which incur an exponential growth of Hamiltonian terms upon dressing, the ACS of ILC generators enables Hamiltonian dressing such that the number of terms @@ -53,7 +53,8 @@ class ILC(Ansatz): mapping (str): One of the supported mapping identifiers. Default, "jw". up_then_down (bool): Change basis ordering putting all spin-up orbitals first, followed by all spin-down. Default, False. - ilc_op_list (list of QubitOperator): Generator list for the ILC ansatz. Default, None. + acs (list of QubitOperator): The mutually anticommuting generator list for the ILC ansatz. + Default, None. qmf_circuit (Circuit): An instance of tangelo.linq Circuit class implementing a QMF state circuit. If passed from the QMF ansatz class, parameters are variational. If None, one is created with QMF parameters that are not variational. Default, None. @@ -62,21 +63,30 @@ class ILC(Ansatz): qubit_ham (QubitOperator): Pass a qubit Hamiltonian to the ansatz class and ignore the fermionic Hamiltonian in molecule. Default, None. deilc_dtau_thresh (float): Threshold for |dEILC/dtau| so that a candidate group is added - to the DIS if |dEILC/dtau| >= deilc_dtau_thresh for a generator. Default, 1.e-3 a.u. + to the DIS if |dEILC/dtau| >= deilc_dtau_thresh for a generator. Default, 1e-3 a.u. ilc_tau_guess (float): The initial guess for all ILC variational parameters. - Default, 1.e-2 a.u. + Default, 1e-2 a.u. max_ilc_gens (int or None): Maximum number of generators allowed in the ansatz. If None, one generator from each DIS group is selected. If int, then min(|DIS|, max_ilc_gens) generators are selected in order of decreasing |dEILC/dtau|. Default, None. - n_trotter (int): Number of Trotterization steps used to create the ILC ansatz circuit. - Default, 1. """ - def __init__(self, molecule, mapping="jw", up_then_down=False, ilc_op_list=None, - qmf_circuit=None, qmf_var_params=None, qubit_ham=None, ilc_tau_guess=1.e-2, - deilc_dtau_thresh=1.e-3, max_ilc_gens=None, n_trotter=1): + def __init__(self, molecule, mapping="jw", up_then_down=False, acs=None, + qmf_circuit=None, qmf_var_params=None, qubit_ham=None, ilc_tau_guess=1e-2, + deilc_dtau_thresh=1e-3, max_ilc_gens=None): + if not molecule: + raise ValueError("An instance of SecondQuantizedMolecule is required for initializing " + "the self.__class__.__name__ ansatz class.") self.molecule = molecule + self.mapping = mapping + self.up_then_down = up_then_down + if self.mapping.lower() == "jw" and not self.up_then_down: + warnings.warn("Spin-orbital ordering shifted to all spin-up first then down to " + "ensure efficient generator screening for the Jordan-Wigner mapping " + "with the self.__class__.__name__ ansatz.", RuntimeWarning) + self.up_then_down = True + self.n_spinorbitals = self.molecule.n_active_sos if self.n_spinorbitals % 2 != 0: raise ValueError("The total number of spin-orbitals should be even.") @@ -84,30 +94,16 @@ def __init__(self, molecule, mapping="jw", up_then_down=False, ilc_op_list=None, self.spin = molecule.spin self.fermi_ham = self.molecule.fermionic_hamiltonian self.n_electrons = self.molecule.n_electrons - self.mapping = mapping self.n_qubits = get_qubit_number(self.mapping, self.n_spinorbitals) - self.up_then_down = up_then_down - if self.mapping.lower() == "jw" and not self.up_then_down: - warnings.warn("Efficient generator screening for the ILC ansatz requires spin-orbital " - "ordering to be all spin-up first followed by all spin-down for the JW " - "mapping.", RuntimeWarning) - self.up_then_down = True - - self.ilc_op_list = ilc_op_list - self.ilc_tau_guess = ilc_tau_guess - self.deilc_dtau_thresh = deilc_dtau_thresh - self.max_ilc_gens = max_ilc_gens - self.qmf_var_params = qmf_var_params - self.qmf_circuit = qmf_circuit - self.n_trotter = n_trotter + self.qubit_ham = qubit_ham if qubit_ham is None: + self.fermi_ham = self.molecule.fermionic_hamiltonian self.qubit_ham = fermion_to_qubit_mapping(self.fermi_ham, self.mapping, self.n_spinorbitals, self.n_electrons, self.up_then_down, self.spin) - else: - self.qubit_ham = qubit_ham + self.qmf_var_params = qmf_var_params if self.qmf_var_params is None: self.qmf_var_params = init_qmf_from_hf(self.n_spinorbitals, self.n_electrons, self.mapping, self.up_then_down, self.spin) @@ -116,8 +112,15 @@ def __init__(self, molecule, mapping="jw", up_then_down=False, ilc_op_list=None, if self.qmf_var_params.size != 2 * self.n_qubits: raise ValueError("The number of QMF variational parameters must be 2 * n_qubits.") + self.qmf_circuit = qmf_circuit + + self.acs = acs + self.ilc_tau_guess = ilc_tau_guess + self.deilc_dtau_thresh = deilc_dtau_thresh + self.max_ilc_gens = max_ilc_gens + # Get purified QMF parameters and build the DIS & ACS or use a list of generators. - if self.ilc_op_list is None: + if self.acs is None: pure_var_params = purify_qmf_state(self.qmf_var_params, self.n_spinorbitals, self.n_electrons, self.mapping, self.up_then_down, self.spin) self.dis = construct_dis(self.qubit_ham, pure_var_params, self.deilc_dtau_thresh) @@ -127,8 +130,7 @@ def __init__(self, molecule, mapping="jw", up_then_down=False, ilc_op_list=None, self.n_var_params = len(self.acs) else: self.dis = None - self.acs = self.ilc_op_list - self.n_var_params = len(self.ilc_op_list) + self.n_var_params = len(self.acs) # Supported reference state initialization self.supported_reference_state = {"HF"} @@ -137,7 +139,7 @@ def __init__(self, molecule, mapping="jw", up_then_down=False, ilc_op_list=None, # Default starting parameters for initialization self.default_reference_state = "HF" - self.var_params_default = "ilc_tau_guess" + self.var_params_default = "diag" self.var_params = None self.rebuild_dis = False self.rebuild_acs = False @@ -164,9 +166,9 @@ def set_var_params(self, var_params=None): # Initialize all ILC parameters to the same value specified by self.ilc_tau_guess elif var_params == "ilc_tau_guess": initial_var_params = self.ilc_tau_guess * np.ones((self.n_var_params,)) - # Initialize tau parameters randomly over the domain [-ilc_tau_guess, ilc_tau_guess] + # Initialize tau parameters randomly over the domain [0., 2 pi) elif var_params == "random": - initial_var_params = 2. * self.ilc_tau_guess * np.random.random((self.n_var_params,)) - self.ilc_tau_guess + initial_var_params = 2. * np.pi * np.random.random((self.n_var_params,)) # Initialize ILC parameters by matrix diagonalization (see Appendix B, Refs. 1 & 2). elif var_params == "diag": initial_var_params = get_ilc_params_by_diag(self.qubit_ham, self.acs, self.qmf_var_params) @@ -208,10 +210,9 @@ def build_circuit(self, var_params=None): # Obtain quantum circuit through trotterization of the list of ILC operators pauli_word_gates = [] - for _ in range(self.n_trotter): - for ilc_op in self.ilc_op_list: - pauli_word, coef = list(ilc_op.terms.items())[0] - pauli_word_gates += exp_pauliword_to_gates(pauli_word, float(coef/self.n_trotter), variational=True) + for ilc_op in self.ilc_op_list: + pauli_word, coef = list(ilc_op.terms.items())[0] + pauli_word_gates += exp_pauliword_to_gates(pauli_word, coef, variational=True) self.ilc_circuit = Circuit(pauli_word_gates) self.circuit = self.qmf_circuit + self.ilc_circuit if self.qmf_circuit.size != 0\ else self.ilc_circuit @@ -228,10 +229,9 @@ def update_var_params(self, var_params): self.ilc_op_list = self._get_ilc_op() pauli_word_gates = [] - for _ in range(self.n_trotter): - for ilc_op in self.ilc_op_list: - pauli_word, coef = list(ilc_op.terms.items())[0] - pauli_word_gates += exp_pauliword_to_gates(pauli_word, float(coef/self.n_trotter), variational=True) + for ilc_op in self.ilc_op_list: + pauli_word, coef = list(ilc_op.terms.items())[0] + pauli_word_gates += exp_pauliword_to_gates(pauli_word, coef, variational=True) self.ilc_circuit = Circuit(pauli_word_gates) self.circuit = self.qmf_circuit + self.ilc_circuit if self.qmf_circuit.size != 0\ else self.ilc_circuit diff --git a/tangelo/toolboxes/ansatz_generator/qcc.py b/tangelo/toolboxes/ansatz_generator/qcc.py index 12c15fc72..6ee7b9863 100755 --- a/tangelo/toolboxes/ansatz_generator/qcc.py +++ b/tangelo/toolboxes/ansatz_generator/qcc.py @@ -37,14 +37,13 @@ import numpy as np -from tangelo.toolboxes.operators.operators import QubitOperator from tangelo.toolboxes.qubit_mappings.mapping_transform import get_qubit_number,\ fermion_to_qubit_mapping from tangelo.linq import Circuit from tangelo.toolboxes.ansatz_generator.ansatz import Ansatz from tangelo.toolboxes.ansatz_generator.ansatz_utils import exp_pauliword_to_gates from tangelo.toolboxes.ansatz_generator._qubit_mf import init_qmf_from_hf, get_qmf_circuit, purify_qmf_state -from tangelo.toolboxes.ansatz_generator._qubit_cc import construct_dis +from tangelo.toolboxes.ansatz_generator._qubit_cc import build_qcc_qubit_op, construct_dis class QCC(Ansatz): @@ -59,7 +58,8 @@ class QCC(Ansatz): mapping (str): One of the supported qubit mapping identifiers. Default, "jw". up_then_down (bool): Change basis ordering putting all spin-up orbitals first, followed by all spin-down. Default, False. - qcc_op_list (list of QubitOperator): Generator list for the QCC ansatz. Default, None. + dis (list of QubitOperator): The direct interaction set (DIS) of generators for the + QCC ansatz. Default, None. qmf_circuit (Circuit): An instance of tangelo.linq Circuit class implementing a QMF state circuit. If passed from the QMF ansatz class, parameters are variational. If None, one is created with QMF parameters that are not variational. Default, None. @@ -68,73 +68,65 @@ class QCC(Ansatz): qubit_ham (QubitOperator): Pass a qubit Hamiltonian to the QCC ansatz class and ignore the fermionic Hamiltonian in molecule. Default, None. deqcc_dtau_thresh (float): Threshold for |dEQCC/dtau| so that a candidate group is added - to the DIS if |dEQCC/dtau| >= deqcc_dtau_thresh for a generator. Default, 1.e-3 a.u. + to the DIS if |dEQCC/dtau| >= deqcc_dtau_thresh for a generator. Default, 1e-3 a.u. qcc_tau_guess (float): The initial guess for all QCC variational parameters. - Default, 1.e-2 a.u. + Default, 1e-2 a.u. max_qcc_gens (int or None): Maximum number of generators allowed in the ansatz. If None, one generator from each DIS group is selected. If int, then min(|DIS|, max_qcc_gens) generators are selected in order of decreasing |dEQCC/dtau|. Default, None. """ - def __init__(self, molecule, mapping="jw", up_then_down=False, qcc_op_list=None, - qmf_circuit=None, qmf_var_params=None, qubit_ham=None, qcc_tau_guess=1.e-2, - deqcc_dtau_thresh=1.e-3, max_qcc_gens=None): + def __init__(self, molecule, mapping="jw", up_then_down=False, dis=None, + qmf_circuit=None, qmf_var_params=None, qubit_ham=None, qcc_tau_guess=1e-2, + deqcc_dtau_thresh=1e-3, max_qcc_gens=None): + if not molecule: + raise ValueError("An instance of SecondQuantizedMolecule is required for initializing " + "the self.__class__.__name__ ansatz class.") self.molecule = molecule - self.n_spinorbitals = self.molecule.n_active_sos - if self.n_spinorbitals % 2 != 0: - raise ValueError("The total number of spin-orbitals should be even.") - - self.spin = molecule.spin - self.fermi_ham = self.molecule.fermionic_hamiltonian - self.n_electrons = self.molecule.n_electrons self.mapping = mapping - self.n_qubits = get_qubit_number(self.mapping, self.n_spinorbitals) self.up_then_down = up_then_down if self.mapping.lower() == "jw" and not self.up_then_down: - warnings.warn("Efficient generator screening for the QCC ansatz requires spin-orbital " - "ordering to be all spin-up first followed by all spin-down for the JW " - "mapping.", RuntimeWarning) + warnings.warn("Spin-orbital ordering shifted to all spin-up first then down to " + "ensure efficient generator screening for the Jordan-Wigner mapping " + "with the self.__class__.__name__ ansatz.", RuntimeWarning) self.up_then_down = True - self.molecule = molecule self.n_spinorbitals = self.molecule.n_active_sos if self.n_spinorbitals % 2 != 0: raise ValueError("The total number of spin-orbitals should be even.") - self.qcc_tau_guess = qcc_tau_guess - self.deqcc_dtau_thresh = deqcc_dtau_thresh - self.max_qcc_gens = max_qcc_gens - self.qcc_op_list = qcc_op_list - self.qmf_var_params = qmf_var_params - self.qmf_circuit = qmf_circuit + self.spin = molecule.spin + self.fermi_ham = self.molecule.fermionic_hamiltonian + self.n_electrons = self.molecule.n_electrons + self.n_qubits = get_qubit_number(self.mapping, self.n_spinorbitals) - if qubit_ham is None: + self.qubit_ham = qubit_ham + if not qubit_ham: self.fermi_ham = self.molecule.fermionic_hamiltonian self.qubit_ham = fermion_to_qubit_mapping(self.fermi_ham, self.mapping, self.n_spinorbitals, self.n_electrons, self.up_then_down, self.spin) - else: - self.qubit_ham = qubit_ham - if self.qmf_var_params is None: + self.qmf_var_params = qmf_var_params + if not self.qmf_var_params: self.qmf_var_params = init_qmf_from_hf(self.n_spinorbitals, self.n_electrons, self.mapping, self.up_then_down, self.spin) elif isinstance(self.qmf_var_params, list): self.qmf_var_params = np.array(self.qmf_var_params) if self.qmf_var_params.size != 2 * self.n_qubits: raise ValueError("The number of QMF variational parameters must be 2 * n_qubits.") + self.n_qmf_params = 2 * self.n_qubits + self.qmf_circuit = qmf_circuit - # Get purified QMF parameters and use them to build the DIS or use a list of generators. - if self.qcc_op_list is None: - pure_var_params = purify_qmf_state(self.qmf_var_params, self.n_spinorbitals, self.n_electrons, - self.mapping, self.up_then_down, self.spin) - self.dis = construct_dis(self.qubit_ham, pure_var_params, self.deqcc_dtau_thresh) - self.n_var_params = len(self.dis) if self.max_qcc_gens is None\ - else min(len(self.dis), self.max_qcc_gens) - else: - self.dis = None - self.n_var_params = len(self.qcc_op_list) + self.dis = dis + self.qcc_tau_guess = qcc_tau_guess + self.deqcc_dtau_thresh = deqcc_dtau_thresh + self.max_qcc_gens = max_qcc_gens + + # Build the DIS or specify a list of generators; updates the number of QCC parameters + self._get_qcc_generators() + self.n_var_params = self.n_qmf_params + self.n_qcc_params # Supported reference state initialization self.supported_reference_state = {"HF"} @@ -146,7 +138,6 @@ def __init__(self, molecule, mapping="jw", up_then_down=False, qcc_op_list=None, self.default_reference_state = "HF" self.var_params_default = "qcc_tau_guess" self.var_params = None - self.rebuild_dis = False self.qcc_circuit = None self.circuit = None @@ -166,19 +157,21 @@ def set_var_params(self, var_params=None): f"{self.supported_initial_var_params}") # Initialize the QCC wave function as |QCC> = |QMF> if var_params == "qmf_state": - initial_var_params = np.zeros((self.n_var_params,), dtype=float) + initial_var_params = np.zeros((self.n_qcc_params,), dtype=float) # Initialize all tau parameters to the same value specified by self.qcc_tau_guess elif var_params == "qcc_tau_guess": - initial_var_params = self.qcc_tau_guess * np.ones((self.n_var_params,)) - # Initialize tau parameters randomly over the domain [-qcc_tau_guess, qcc_tau_guess] + initial_var_params = self.qcc_tau_guess * np.ones((self.n_qcc_params,)) + # Initialize tau parameters randomly over the domain [0., 2 pi) elif var_params == "random": - initial_var_params = 2. * self.qcc_tau_guess * np.random.random((self.n_var_params,)) - self.qcc_tau_guess + initial_var_params = 2. * np.pi * np.random.random((self.n_qcc_params,)) + # Insert the QMF variational parameters at the beginning. + initial_var_params = np.concatenate((self.qmf_var_params, initial_var_params)) else: initial_var_params = np.array(var_params) - if initial_var_params.size != self.n_var_params: - raise ValueError(f"Expected {self.n_var_params} variational parameters but " - f"received {initial_var_params.size}.") self.var_params = initial_var_params + if initial_var_params.size != self.n_var_params: + raise ValueError(f"Expected {self.n_var_params} variational parameters but " + f"received {initial_var_params.size}.") return initial_var_params def prepare_reference_state(self): @@ -190,25 +183,30 @@ def prepare_reference_state(self): raise ValueError(f"Only supported reference state methods are: " f"{self.supported_reference_state}.") if self.default_reference_state == "HF": - reference_state_circuit = get_qmf_circuit(self.qmf_var_params, False) + reference_state_circuit = get_qmf_circuit(self.qmf_var_params, True) return reference_state_circuit def build_circuit(self, var_params=None): """Build and return the quantum circuit implementing the state preparation ansatz (with currently specified initial_state and var_params). """ + # Build the DIS or specify a list of generators; updates the number of QCC parameters + self._get_qcc_generators() + self.n_var_params = self.n_qmf_params + self.n_qcc_params + + # Get the variational parameters needed for the QCC unitary operator and circuit if var_params is not None: self.set_var_params(var_params) elif self.var_params is None: self.set_var_params() - # Build a qubit operator required for QCC - qubit_op = self._get_qcc_qubit_op() - # Build a QMF state preparation circuit if self.qmf_circuit is None: self.qmf_circuit = self.prepare_reference_state() + # Build the QCC ansatz qubit operator + qubit_op = build_qcc_qubit_op(self.dis, self.var_params[2*self.n_qubits:]) + # Obtain quantum circuit through trivial trotterization of the qubit operator # Track the order in which pauli words have been visited for fast parameter updates pauli_words_gates = [] @@ -229,7 +227,7 @@ def update_var_params(self, var_params): self.set_var_params(var_params) # Build the QCC ansatz qubit operator - qubit_op = self._get_qcc_qubit_op() + qubit_op = build_qcc_qubit_op(self.dis, self.var_params[2*self.n_qubits:]) # If qubit_op terms have changed, rebuild circuit if set(self.pauli_to_angles_mapping.keys()) != set(qubit_op.terms.keys()): @@ -243,40 +241,18 @@ def update_var_params(self, var_params): self.circuit = self.qmf_circuit + self.qcc_circuit if self.qmf_circuit.size != 0\ else self.qcc_circuit - def _get_qcc_qubit_op(self): - """Returns the QCC operator by selecting one generator from n_var_params DIS groups. - The QCC qubit operator is constructed as a linear combination of generators using the - parameter set {tau} as coefficients: QCC operator = -0.5 * SUM_k P_k * tau_k. - The exponentiated terms of the QCC operator, U = PROD_k exp(-0.5j * tau_k * P_k), - are used to build a QCC circuit. + def _get_qcc_generators(self): + """ Prepares the QCC ansatz by purifying the QMF state, constructing the DIS, + and selecting representative generators from the top candidate DIS groups. """ - Returns: - QubitOperator: QCC ansatz qubit operator. - """ - - # Rebuild DIS if qubit_ham or qmf_var_params changed or if DIS and qcc_op_list are None. - if self.rebuild_dis or (self.dis is None and self.qcc_op_list is None): + if not self.dis: pure_var_params = purify_qmf_state(self.qmf_var_params, self.n_spinorbitals, self.n_electrons, self.mapping, self.up_then_down, self.spin) self.dis = construct_dis(self.qubit_ham, pure_var_params, self.deqcc_dtau_thresh) - self.n_var_params = len(self.dis) if self.max_qcc_gens is None\ - else min(len(self.dis), self.max_qcc_gens) - self.qcc_op_list = None - - # Build the QCC operator using the DIS or a list of generators - qcc_qubit_op = QubitOperator.zero() - if self.qcc_op_list is None: - self.qcc_op_list = [] - for i in range(self.n_var_params): - # Instead of randomly choosing a generator, grab the first one. - qcc_gen = self.dis[i][0] - qcc_qubit_op -= 0.5 * self.var_params[i] * qcc_gen - self.qcc_op_list.append(qcc_gen) - else: - if len(self.qcc_op_list) == self.n_var_params: - for i, qcc_gen in enumerate(self.qcc_op_list): - qcc_qubit_op -= 0.5 * self.var_params[i] * qcc_gen + if self.max_qcc_gens: + self.n_qcc_params = min(len(self.dis), self.max_qcc_gens) + del self.dis[self.n_qcc_params:] else: - raise ValueError(f"Expected {self.n_var_params} generators in " - f"{self.qcc_op_list} but received {len(self.qcc_op_list)}.\n") - return qcc_qubit_op + self.n_qcc_params = len(self.dis) + else: + self.n_qcc_params = len(self.dis) diff --git a/tangelo/toolboxes/ansatz_generator/qmf.py b/tangelo/toolboxes/ansatz_generator/qmf.py index 9c13f5a7a..89e5d3a10 100755 --- a/tangelo/toolboxes/ansatz_generator/qmf.py +++ b/tangelo/toolboxes/ansatz_generator/qmf.py @@ -71,9 +71,13 @@ class QMF(Ansatz): Default, {"init_params": "hf_state"}. """ - def __init__(self, molecule=None, mapping="jw", up_then_down=False, init_qmf=None): + def __init__(self, molecule, mapping="jw", up_then_down=False, init_qmf=None): + if not molecule: + raise ValueError("An instance of SecondQuantizedMolecule is required for initializing " + "the self.__class__.__name__ ansatz class.") self.molecule = molecule + self.n_spinorbitals = self.molecule.n_active_sos if self.n_spinorbitals % 2 != 0: raise ValueError("The total number of spin-orbitals should be even.") diff --git a/tangelo/toolboxes/ansatz_generator/tests/test_ilc.py b/tangelo/toolboxes/ansatz_generator/tests/test_ilc.py index 619ed973a..706862ba4 100644 --- a/tangelo/toolboxes/ansatz_generator/tests/test_ilc.py +++ b/tangelo/toolboxes/ansatz_generator/tests/test_ilc.py @@ -13,7 +13,7 @@ # limitations under the License. """Unit tests for closed-shell and restricted open-shell qubit coupled cluster -with involutory linear combinations (ILC) of anti-commuting sets (ACS) of Pauli words.""" +with involutory linear combinations (ILC) of anticommuting sets (ACS) of Pauli words.""" import unittest @@ -119,12 +119,13 @@ def test_gauss_elim_over_gf2_lindep(): def test_ilc_h2(self): """ Verify closed-shell functionality when using the ILC class separately for H2 """ - # Build the ILC ansatz, which sets the QMF parameters automatically if none are passed + # Specify the mutually anticommuting set (ACS) of ILC generators and parameters. + acs = [QubitOperator("X0 Y1 Y2 Y3")] + # The QMF parameters are automatically set if the argument qmf_var_params is not given. ilc_var_params = [0.11360304] - ilc_op_list = [QubitOperator("X0 Y1 Y2 Y3")] - ilc_ansatz = ILC(mol_H2_sto3g, up_then_down=True, ilc_op_list=ilc_op_list) + ilc_ansatz = ILC(mol_H2_sto3g, "jw", True, acs) - # Build a QMF + ILC circuit + # Build the combined QMF (determined automatically if not specified) + ILC circuit. ilc_ansatz.build_circuit() # Get qubit hamiltonian for energy evaluation @@ -138,14 +139,15 @@ def test_ilc_h2(self): def test_ilc_h4_cation(self): """ Verify restricted open-shell functionality when using the ILC class for H4+ """ - # Build the ILC ansatz, which sets the QMF parameters automatically if none are passed - ilc_op_list = [QubitOperator("Y0 Z2 X4 Z6"), QubitOperator("Y1 Y2 Z4 X5 Y6"), QubitOperator("X0 Z2 Z4 Y6"), - QubitOperator("X1 Y2 X4 Z6"), QubitOperator("Y1 Y2 X4 Y5 Z6"), QubitOperator("Y1 Y2 Z4 Z5 Y6"), - QubitOperator("Y0 Z1 Z2 Y5 Y6"), QubitOperator("Y0 Z1 Z2 Y4 Y5 Z6")] + # Specify the mutually anticommuting set (ACS) of ILC generators and parameters. + acs = [QubitOperator("Y0 Z2 X4 Z6"), QubitOperator("Y1 Y2 Z4 X5 Y6"), QubitOperator("X0 Z2 Z4 Y6"), + QubitOperator("X1 Y2 X4 Z6"), QubitOperator("Y1 Y2 X4 Y5 Z6"), QubitOperator("Y1 Y2 Z4 Z5 Y6"), + QubitOperator("Y0 Z1 Z2 Y5 Y6"), QubitOperator("Y0 Z1 Z2 Y4 Y5 Z6")] + # The QMF parameters are automatically set if the argument qmf_var_params is not given. ilc_var_params = [ 0.14017492, -0.10792805, -0.05835484, 0.12468933, 0.07173118, 0.04683807, 0.02852163, -0.03133538] - ilc_ansatz = ILC(mol_H4_cation_sto3g, "BK", False, ilc_op_list) + ilc_ansatz = ILC(mol_H4_cation_sto3g, "bk", False, acs) - # Build a QMF + ILC circuit + # Build the combined QMF (determined automatically if not specified) + ILC circuit. ilc_ansatz.build_circuit() # Get qubit hamiltonian for energy evaluation diff --git a/tangelo/toolboxes/ansatz_generator/tests/test_qcc.py b/tangelo/toolboxes/ansatz_generator/tests/test_qcc.py index 602c8f8eb..0327f3ff1 100644 --- a/tangelo/toolboxes/ansatz_generator/tests/test_qcc.py +++ b/tangelo/toolboxes/ansatz_generator/tests/test_qcc.py @@ -21,7 +21,6 @@ from openfermion import load_operator from tangelo.linq import Simulator -from tangelo.toolboxes.ansatz_generator.qmf import QMF from tangelo.toolboxes.ansatz_generator.qcc import QCC from tangelo.toolboxes.operators.operators import QubitOperator from tangelo.molecule_library import mol_H2_sto3g, mol_H4_cation_sto3g, mol_H4_doublecation_minao @@ -34,9 +33,7 @@ class QCCTest(unittest.TestCase): """Unit tests for various functionalities of the QCC ansatz class. Examples for both closed- - and restricted open-shell QCC are provided using H2, H4+, and H4+2 as well as for using the - QMF and QCC classes together. - """ + and restricted open-shell QCC are provided using H2, H4+, and H4+2.""" @staticmethod def test_qcc_set_var_params(): @@ -44,21 +41,18 @@ def test_qcc_set_var_params(): qcc_ansatz = QCC(mol_H2_sto3g, up_then_down=True) - one_zero = np.zeros((1,), dtype=float) + nine_zeros = np.zeros((9,), dtype=float) - qcc_ansatz.set_var_params("qmf_state") - np.testing.assert_array_almost_equal(qcc_ansatz.var_params, one_zero, decimal=6) + qcc_ansatz.set_var_params([0.] * 9) + np.testing.assert_array_almost_equal(qcc_ansatz.var_params, nine_zeros, decimal=6) - qcc_ansatz.set_var_params([0.]) - np.testing.assert_array_almost_equal(qcc_ansatz.var_params, one_zero, decimal=6) + nine_tenths = 0.1 * np.ones((9,)) - one_tenth = 0.1 * np.ones((1,)) + qcc_ansatz.set_var_params([0.1] * 9) + np.testing.assert_array_almost_equal(qcc_ansatz.var_params, nine_tenths, decimal=6) - qcc_ansatz.set_var_params([0.1]) - np.testing.assert_array_almost_equal(qcc_ansatz.var_params, one_tenth, decimal=6) - - qcc_ansatz.set_var_params(np.array([0.1])) - np.testing.assert_array_almost_equal(qcc_ansatz.var_params, one_tenth, decimal=6) + qcc_ansatz.set_var_params(np.array([0.1] * 9)) + np.testing.assert_array_almost_equal(qcc_ansatz.var_params, nine_tenths, decimal=6) def test_qcc_incorrect_number_var_params(self): """ Return an error if user provide incorrect number of variational parameters """ @@ -68,151 +62,82 @@ def test_qcc_incorrect_number_var_params(self): self.assertRaises(ValueError, qcc_ansatz.set_var_params, np.array([1.] * 2)) def test_qcc_h2(self): - """ Verify closed-shell functionality when using the QCC class separately for H2 """ + """ Verify closed-shell functionality when using the QCC class for H2 """ - # Build the QCC ansatz, which sets the QMF parameters automatically if none are passed - qcc_var_params = [0.22613627] - qcc_op_list = [QubitOperator("X0 Y1 Y2 Y3")] - qcc_ansatz = QCC(mol_H2_sto3g, up_then_down=True, qcc_op_list=qcc_op_list) + # Specify the qubit operators from the direct interaction set (DIS) of QCC generators. + dis = [QubitOperator("Y0 X1 X2 X3")] + qcc_ansatz = QCC(mol_H2_sto3g, up_then_down=True, dis=dis) - # Build a QMF + QCC circuit + # Build the QCC circuit, which is prepended by the qubit mean field (QMF) circuit. qcc_ansatz.build_circuit() # Get qubit hamiltonian for energy evaluation qubit_hamiltonian = qcc_ansatz.qubit_ham + # The QMF and QCC parameters can both be specified; determined automatically othersise. + qmf_var_params = [ 3.14159265e+00, -2.42743256e-08, 3.14159266e+00, -3.27162543e-08, + 3.08514545e-09, 3.08514545e-09, 3.08514545e-09, 3.08514545e-09] + qcc_var_params = [-2.26136280e-01] + var_params = qmf_var_params + qcc_var_params # Assert energy returned is as expected for given parameters - qcc_ansatz.update_var_params(qcc_var_params) + qcc_ansatz.update_var_params(var_params) energy = sim.get_expectation_value(qubit_hamiltonian, qcc_ansatz.circuit) self.assertAlmostEqual(energy, -1.1372701746609022, delta=1e-6) - def test_qmf_qcc_h2(self): - """ Verify closed-shell functionality when using the QMF and QCC classes together for H2 """ - - # Build the QMF ansatz with optimized parameters - qmf_var_params = [3.14159265e+00, -2.42743256e-08, 3.14159266e+00, -3.27162543e-08, - 3.08514545e-09, 3.08514545e-09, 3.08514545e-09, 3.08514545e-09] - qmf_ansatz = QMF(mol_H2_sto3g, "JW", True) - qmf_ansatz.build_circuit(qmf_var_params) - - # Build the QCC ansatz with optimized QMF and QCC parameters and selected QCC generator - qcc_var_params = [-2.26136280e-01] - qcc_op_list = [QubitOperator("Y0 X1 X2 X3")] - qcc_ansatz = QCC(mol_H2_sto3g, "JW", True, qcc_op_list, qmf_ansatz.circuit) - - # Build a QMF + QCC circuit - qcc_ansatz.build_circuit() + def test_qmf_qcc_h4_cation(self): + """ Verify restricted open-shell functionality when using QCC ansatz for H4+ """ - # Get qubit hamiltonian for energy evaluation - qubit_hamiltonian = qcc_ansatz.qubit_ham + # Specify the qubit operators from the direct interaction set (DIS) of QCC generators. + dis = [QubitOperator("X0 X1 Y2 Y3 X4 Y5"), QubitOperator("Y1 Y3 Y4 X5"), + QubitOperator("X0 Y1 Y3 Y4"), QubitOperator("X1 X2 Y3 X4 X5"), + QubitOperator("Y1 Y2 Y3 X4"), QubitOperator("Y1 X3 X4"), + QubitOperator("Y0 X2"), QubitOperator("X0 X1 X3 X4 Y5"), + QubitOperator("X0 X1 X2 Y3 X4")] + qcc_ansatz = QCC(mol_H4_cation_sto3g, "scbk", True, dis) - # Assert energy returned is as expected for the optimized QMF + QCC variational parameters - qcc_ansatz.update_var_params(qcc_var_params) - energy = sim.get_expectation_value(qubit_hamiltonian, qcc_ansatz.circuit) - self.assertAlmostEqual(energy, -1.137270174660901, delta=1e-6) - - def test_qcc_h4_cation(self): - """ Verify restricted open-shell functionality when using the QCC class for H4+ """ - - # Build the QCC ansatz, which sets the QMF parameters automatically if none are passed - qcc_op_list = [QubitOperator("X0 Y1 Y2 X3 X4 Y5"), QubitOperator("Y1 X3 X4 X5"), - QubitOperator("X0 Y1 Y3 Y4"), QubitOperator("X1 X2 Y3 X4 X5"), - QubitOperator("Y1 X2 Y3 Y4"), QubitOperator("Y1 X3 X4"), - QubitOperator("X0 Y2"), QubitOperator("X0 X1 Y3 X4 X5"), - QubitOperator("X0 X1 X2 Y3 X4")] - qcc_var_params = [ 0.26202301, -0.21102705, 0.11683144, -0.24234041, 0.13832747, - -0.0951985, -0.03501809, 0.0640034, 0.0542095] - qcc_ansatz = QCC(mol_H4_cation_sto3g, "SCBK", True, qcc_op_list) - - # Build a QMF + QCC circuit + # Build the QCC circuit, which is prepended by the qubit mean field (QMF) circuit. qcc_ansatz.build_circuit() # Get qubit hamiltonian for energy evaluation qubit_hamiltonian = qcc_ansatz.qubit_ham - # Assert energy returned is as expected for given parameters - qcc_ansatz.update_var_params(qcc_var_params) - energy = sim.get_expectation_value(qubit_hamiltonian, qcc_ansatz.circuit) - self.assertAlmostEqual(energy, -1.6380901, delta=1e-6) - - def test_qmf_qcc_h4_cation(self): - """ Verify restricted open-shell functionality when using QMF + QCC ansatze for H4+ """ - - # Build the QMF ansatz with optimized parameters + # The QMF and QCC parameters can both be specified; determined automatically othersise. qmf_var_params = [3.14159302e+00, 6.20193478e-07, 1.51226426e-06, 3.14159350e+00, 3.14159349e+00, 7.88310582e-07, 3.96032530e+00, 2.26734374e+00, 3.22127001e+00, 5.77997401e-01, 5.51422406e+00, 6.26513711e+00] - qmf_ansatz = QMF(mol_H4_cation_sto3g, "SCBK", True) - qmf_ansatz.build_circuit(qmf_var_params) - - # Build QCC ansatz with optimized QMF and QCC parameters and selected QCC generators - qcc_op_list = [QubitOperator("X0 X1 Y2 Y3 X4 Y5"), QubitOperator("Y1 Y3 Y4 X5"), - QubitOperator("X0 Y1 Y3 Y4"), QubitOperator("X1 X2 Y3 X4 X5"), - QubitOperator("Y1 Y2 Y3 X4"), QubitOperator("Y1 X3 X4"), - QubitOperator("Y0 X2"), QubitOperator("X0 X1 X3 X4 Y5"), - QubitOperator("X0 X1 X2 Y3 X4")] qcc_var_params = [-0.26816042, 0.21694796, 0.12139543, -0.2293093, -0.14577423, -0.08937818, 0.01796464, -0.06445363, 0.06056016] - qcc_ansatz = QCC(mol_H4_cation_sto3g, "SCBK", True, qcc_op_list, qmf_ansatz.circuit) - - # Build a QMF + QCC circuit - qcc_ansatz.build_circuit() - - # Get qubit hamiltonian for energy evaluation - qubit_hamiltonian = qcc_ansatz.qubit_ham - + var_params = qmf_var_params + qcc_var_params # Assert energy returned is as expected for given parameters - qcc_ansatz.update_var_params(qcc_var_params) + qcc_ansatz.update_var_params(var_params) energy = sim.get_expectation_value(qubit_hamiltonian, qcc_ansatz.circuit) self.assertAlmostEqual(energy, -1.6382913, delta=1e-6) - def test_qcc_h4_double_cation(self): - """ Verify restricted open-shell functionality when using the QCC class for H4+2 """ + def test_qmf_qcc_h4_double_cation(self): + """ Verify restricted open-shell functionality when using the QCC ansatz for H4+2 """ - # Build the QCC ansatz, which sets the QMF parameters automatically if none are passed - qcc_op_list = [QubitOperator("X0 Y2"), QubitOperator("Y0 X4"), QubitOperator("X0 Y6"), - QubitOperator("X0 Y5 X6"), QubitOperator("Y0 Y4 Y5")] - qcc_var_params = [0.27697283, -0.2531527, 0.05947973, -0.06943673, 0.07049098] - qcc_ansatz = QCC(mol_H4_doublecation_minao, "BK", False, qcc_op_list) + # Specify the qubit operators from the direct interaction set (DIS) of QCC generators. + dis = [QubitOperator("Y0 X4"), QubitOperator("X0 Y1 Y2 X4 Y5 X6"), + QubitOperator("Y0 Y1 Y4 X5"), QubitOperator("Y0 X1 Y4 Y5 X6"), + QubitOperator("X0 X1 X2 Y4 X5")] + qcc_ansatz = QCC(mol_H4_doublecation_minao, "bk", True, dis) - # Build a QMF + QCC circuit + # Build the QCC circuit, which is prepended by the qubit mean field (QMF) circuit. qcc_ansatz.build_circuit() # Get qubit hamiltonian for energy evaluation - qubit_hamiltonian = load_operator("mol_H4_doublecation_minao_qubitham_bk.data", data_directory=pwd_this_test+"/data", plain_text=True) - - # Assert energy returned is as expected for given parameters - qcc_ansatz.update_var_params(qcc_var_params) - energy = sim.get_expectation_value(qubit_hamiltonian, qcc_ansatz.circuit) - self.assertAlmostEqual(energy, -0.8547019, delta=1e-6) - - def test_qmf_qcc_h4_double_cation(self): - """ Verify restricted open-shell functionality when using QMF + QCC ansatze for H4+2 """ + qubit_hamiltonian = load_operator("mol_H4_doublecation_minao_qubitham_bk_updown.data", data_directory=pwd_this_test+"/data", plain_text=True) - # Build the QMF ansatz with optimized parameters + # The QMF and QCC parameters can both be specified; determined automatically othersise. qmf_var_params = [3.14159247e+00, 3.14158884e+00, 1.37660700e-06, 3.14159264e+00, 3.14159219e+00, 3.14158908e+00, 0.00000000e+00, 0.00000000e+00, 6.94108155e-01, 1.03928030e-01, 5.14029803e+00, 2.81850365e+00, 4.25403875e+00, 6.19640367e+00, 1.43241026e+00, 3.50279759e+00] - qmf_ansatz = QMF(mol_H4_doublecation_minao, "BK", True) - qmf_ansatz.build_circuit(qmf_var_params) - - # Build QCC ansatz with optimized QMF and QCC parameters and selected QCC generators - qcc_op_list = [QubitOperator("Y0 X4"), QubitOperator("X0 Y1 Y2 X4 Y5 X6"), - QubitOperator("Y0 Y1 Y4 X5"), QubitOperator("Y0 X1 Y4 Y5 X6"), - QubitOperator("X0 X1 X2 Y4 X5")] qcc_var_params = [-2.76489925e-01, -2.52783324e-01, 5.76565629e-02, 6.99988237e-02, -7.03721438e-02] - qcc_ansatz = QCC(mol_H4_doublecation_minao, "BK", True, qcc_op_list, qmf_ansatz.circuit) - - # Build a QMF + QCC circuit - qcc_ansatz.build_circuit() - - # Get qubit hamiltonian for energy evaluation - qubit_hamiltonian = load_operator("mol_H4_doublecation_minao_qubitham_bk_updown.data", data_directory=pwd_this_test+"/data", plain_text=True) - + var_params = qmf_var_params + qcc_var_params # Assert energy returned is as expected for given parameters - qcc_ansatz.update_var_params(qcc_var_params) + qcc_ansatz.update_var_params(var_params) energy = sim.get_expectation_value(qubit_hamiltonian, qcc_ansatz.circuit) self.assertAlmostEqual(energy, -0.85465810, delta=1e-6) diff --git a/tangelo/toolboxes/operators/operators.py b/tangelo/toolboxes/operators/operators.py index d8ad9d7c4..a1a8330cf 100644 --- a/tangelo/toolboxes/operators/operators.py +++ b/tangelo/toolboxes/operators/operators.py @@ -16,6 +16,9 @@ broken down in several modules if needed. """ +from math import sqrt +from collections import OrderedDict + # Later on, if needed, we can extract the code for the operators themselves to remove the dependencies and customize import openfermion @@ -31,7 +34,36 @@ class QubitOperator(openfermion.QubitOperator): """Currently, this class is coming from openfermion. Can be later on be replaced by our own implementation. """ - pass + + def frobenius_norm_compression(self, epsilon, n_qubits): + """Reduces the number of operator terms based on its Frobenius norm + and a user-defined threshold, epsilon. The eigenspectrum of the + compressed operator will not deviate more than epsilon. For more + details, see J. Chem. Theory Comput. 2020, 16, 2, 1055–1063. + + Args: + epsilon (float): Parameter controlling the degree of compression + and resulting accuracy. + n_qubits (int): Number of qubits in the register. + + Returns: + QubitOperator: The compressed qubit operator. + """ + + compressed_op = dict() + coef2_sum = 0. + frob_factor = 2**(n_qubits // 2) + + # Arrange the terms of the qubit operator in ascending order + self.terms = OrderedDict(sorted(self.terms.items(), key=lambda x: abs(x[1]), reverse=False)) + + for term, coef in self.terms.items(): + coef2_sum += abs(coef)**2 + # while the sum is less than epsilon / factor, discard the terms + if sqrt(coef2_sum) > epsilon / frob_factor: + compressed_op[term] = coef + self.terms = compressed_op + self.compress() class QubitHamiltonian(QubitOperator):