QSCI¶
QSCI (see arxiv:2302.11320) is a group of hybrid algorithms to approximate the ground state (or low energy eigenstates) of a system hamiltonian, usually for molecule structure problems. In principle it functions similar to other CI methods:
Identify a suitable truncated Fock Space through sampling from a quantum state
Diagonalize the subspace hamiltonian to solve the eigenvalue problem classically
For more details we refer the reader to the aforementioned article.
This example serves as a demonstration to show how existing algorithms can be ported to a tierkreis graph to leverage its automatic scaling features. For this were going to break down QSCI into 4 tasks:
Construct the target circuits
Build a state preparation circuit ansatz (JW)
Generate circuits simulating the system hamiltonian (through time evolution)
Run (simulate) the quantum circuits to sample the configurations
Generate and diagonalize the approximate Hamiltonian to find the ground state energy The granularity of these tasks is kept very low for comprehensibility. For tierkreis worker tasks are considered atomic and will run in parallel if the data allows it. It is possible to break down the tasks even further if desired.
We will bundle most of the functionality in a worker but repeat the here for brevity. Using the worker functions we will construct a tierkreis graph implementing QSCI.
The core tasks¶
In the next section we will provide the implementations for 1.1, 1.2 and 3. which are part of the qsci_worker.
First we will define some helper types:
%pip install tierkreis pytket
/home/runner/work/tierkreis/tierkreis/.venv/bin/python3: No module named pip
Note: you may need to restart the kernel to use updated packages.
from typing import NamedTuple
class Molecule(NamedTuple):
geometry: list[tuple[str, list[float]]]
basis: str
charge: int
class CompleteActiveSpace(NamedTuple):
n: int
n_ele: int
class Hamiltonian(NamedTuple):
h0: float
h1: list[list[float]]
h2: list[list[list[list[float]]]]
Then we define our core functionality.
Here we reference our core library qsci which is part of the worker code.
import logging
from typing import cast
import numpy as np
from example_workers.qsci_worker.src.qsci.active_space import get_n_active, get_n_core
from example_workers.qsci_worker.src.qsci.jordan_wigner import (
qubit_mapping_jordan_wigner,
)
from example_workers.qsci_worker.src.qsci.state_prep import perform_state_preparation
from example_workers.qsci_worker.src.qsci.utils import rhf2ghf
from pytket._tket.circuit import Circuit
from pytket.pauli import QubitPauliString
from pytket.utils.operators import CoeffTypeAccepted, QubitPauliOperator
def state_prep(
ham_init: Hamiltonian,
reference_state: list[int],
max_iteration_prep: int,
atol: float,
mo_occ: list[int],
cas_init: CompleteActiveSpace,
cas_hsim: CompleteActiveSpace,
) -> Circuit:
ham_init_operator = QubitPauliOperator(
cast(
"dict[QubitPauliString, CoeffTypeAccepted]",
qubit_mapping_jordan_wigner(
*rhf2ghf(
ham_init.h0,
np.array(ham_init.h1),
np.array(ham_init.h2),
),
),
),
)
# time-evolve CASCI ground state.
n_core_init = get_n_core(mo_occ, cas_init.n_ele)
n_core_hsim = get_n_core(mo_occ, cas_hsim.n_ele)
n_core = n_core_init - n_core_hsim
logging.info(
f"mo_occ={mo_occ} n_cas_hsim={cas_hsim.n} n_elecas_hsim={cas_hsim.n_ele}",
)
n_active_hsim = get_n_active(mo_occ, cas_hsim.n, cas_hsim.n_ele)
prepared_circ = Circuit(n_active_hsim * 2)
for i in range(n_core * 2):
prepared_circ.X(i)
return perform_state_preparation(
reference_state=reference_state,
ham_init=ham_init_operator,
n_cas_init=cas_init.n,
max_iteration=max_iteration_prep,
atol=atol,
)
from example_workers.qsci_worker.src.qsci.utils import make_time_evolution_circuits
from pytket.circuit import Qubit
def circuits_from_hamiltonians(
ham_init: Hamiltonian,
ham_hsim: Hamiltonian,
adapt_circuit: Circuit,
t_step_list: list[float],
cas_init: CompleteActiveSpace,
cas_hsim: CompleteActiveSpace,
mo_occ: list[int],
max_cx_gates: int,
) -> list[Circuit]:
ham_init_operator = QubitPauliOperator(
cast(
"dict[QubitPauliString, CoeffTypeAccepted]",
qubit_mapping_jordan_wigner(
*rhf2ghf(
ham_init.h0,
np.array(ham_init.h1),
np.array(ham_init.h2),
),
),
),
)
ham_hsim_operator = QubitPauliOperator(
cast(
"dict[QubitPauliString, CoeffTypeAccepted]",
qubit_mapping_jordan_wigner(
*rhf2ghf(
ham_hsim.h0,
np.array(ham_hsim.h1),
np.array(ham_hsim.h2),
),
),
),
)
# Load the input data.
n_core_init = get_n_core(mo_occ, cas_init.n_ele)
n_core_hsim = get_n_core(mo_occ, cas_hsim.n_ele)
n_core = n_core_init - n_core_hsim
n_active_hsim = get_n_active(mo_occ, cas_hsim.n, cas_hsim.n_ele)
prepared_circ = Circuit(n_active_hsim * 2)
for i in range(n_core * 2):
prepared_circ.X(i)
prepared_circ.add_circuit(
adapt_circuit,
[2 * n_core + i for i in range(adapt_circuit.n_qubits)],
)
ham_init_shifted = QubitPauliOperator(
{
QubitPauliString(
{
Qubit(qubit.index[0] + 2 * n_core): pauli
for qubit, pauli in qps.map.items()
},
): coeff
for qps, coeff in ham_init_operator._dict.items()
},
)
return make_time_evolution_circuits(
t_step_list,
prepared_circ,
h_hsim=ham_hsim_operator,
h_init=ham_init_shifted,
max_cx_gates=max_cx_gates,
)
from collections import Counter
from example_workers.qsci_worker.src.qsci.postprocess import (
get_ci_matrix,
postprocess_configs,
)
from example_workers.qsci_worker.src.qsci.utils import get_config_from_cas_init
from pytket.backends.backendresult import BackendResult
def energy_from_results(
ham_hsim: Hamiltonian,
backend_results: list[BackendResult],
mo_occ: list[int],
cas_init: CompleteActiveSpace,
cas_hsim: CompleteActiveSpace,
) -> float:
counts = Counter()
for r in backend_results:
for k, v in r.get_counts().items():
counts[k] += v
phis = list(counts.keys())
phis_init_orig = get_config_from_cas_init(
mo_occ,
cas_init.n,
cas_init.n_ele,
cas_hsim.n,
cas_hsim.n_ele,
)
for p in phis_init_orig:
if p not in phis:
phis.append(p)
# phis = get_config(backend_results)
logging.info(f"CONFIG (before): {len(phis)}")
phis = postprocess_configs(phis_init_orig[0], phis)
logging.info(f"CONFIG (after): {len(phis)}")
hsd = get_ci_matrix(
phis,
h1=np.array(ham_hsim.h1),
h2=np.array(ham_hsim.h2),
enuc=ham_hsim.h0,
)
energy = np.linalg.eigh(hsd.todense())[0][0]
logging.info(f"ENERGY: {energy}")
return energy
Additionally were going to use a helper function to generate the hamiltonian from the molecule definition
from example_workers.qsci_worker.src.chemistry.active_space import get_frozen
from example_workers.qsci_worker.src.chemistry.molecule import extract_hamiltonian_rhf
def make_ham(
molecule: Molecule,
mo_occ: list[int],
cas: CompleteActiveSpace,
) -> Hamiltonian:
# Construct the frozen orbital lists.
frozen = get_frozen(mo_occ, cas.n, cas.n_ele)
h0, h1, h2 = extract_hamiltonian_rhf(
molecule.geometry,
molecule.basis,
charge=molecule.charge,
frozen=frozen,
)
return Hamiltonian(h0=h0, h1=h1.tolist(), h2=h2.tolist())
As before we can annotate all these functions with the @worker.task() decorator to make the functionality available in tierkreis.
Constructing the QSCI graph¶
Using the above defined functions (now as a worker) were going to use preexisting workest to compile and run a circuit
from tierkreis.aer_worker import submit_single
from tierkreis.builder import GraphBuilder
from tierkreis.controller.data.models import TKR, OpaqueType
from tierkreis.quantinuum_worker import compile_circuit_quantinuum
def _compile_and_run() -> GraphBuilder[
TKR[OpaqueType["pytket._tket.circuit.Circuit"]], # noqa: F821
TKR[OpaqueType["pytket.backends.backendresult.BackendResult"]], # noqa: F821
]:
g = GraphBuilder(
TKR[OpaqueType["pytket._tket.circuit.Circuit"]],
TKR[OpaqueType["pytket.backends.backendresult.BackendResult"]],
)
n_shots = g.const(500)
compiled_circuit = g.task(compile_circuit_quantinuum(g.inputs))
backend_result = g.task(submit_single(compiled_circuit, n_shots))
g.outputs(backend_result)
return g
Defining the inputs and outputs of the graph
class QSCIInputs(NamedTuple):
molecule: TKR[Molecule]
mo_occ: TKR[list[int]]
reference_state: TKR[list[int]]
cas_init: TKR[CompleteActiveSpace]
cas_hsim: TKR[CompleteActiveSpace]
t_step_list: TKR[list[float]]
max_iteration_prep: TKR[int]
max_cx_gates_hsim: TKR[int]
atol: TKR[float]
class QSCIOutputs(NamedTuple):
energy: TKR[float]
leads to the definition of the graph. We are redefining the above functions to point at the correct stubs (as we would normally use them)
from qsci_worker import ( # noqa: F811
make_ham,
circuits_from_hamiltonians,
energy_from_results,
make_ham,
state_prep,
)
qsci_graph = GraphBuilder(QSCIInputs, QSCIOutputs)
# Separate tasks 'make_h_init'+'state_pre' and 'make_h_hsim' run in parallel
ham_init = qsci_graph.task(
make_ham(
qsci_graph.inputs.molecule,
qsci_graph.inputs.mo_occ,
qsci_graph.inputs.cas_init,
),
)
ham_hsim = qsci_graph.task(
make_ham(
qsci_graph.inputs.molecule,
qsci_graph.inputs.mo_occ,
qsci_graph.inputs.cas_hsim,
),
)
adapt_circuit = qsci_graph.task(
state_prep(
ham_init,
qsci_graph.inputs.reference_state,
qsci_graph.inputs.max_iteration_prep,
qsci_graph.inputs.atol,
qsci_graph.inputs.mo_occ,
qsci_graph.inputs.cas_init,
qsci_graph.inputs.cas_hsim,
),
)
circuits = qsci_graph.task(
circuits_from_hamiltonians(
ham_init,
ham_hsim,
adapt_circuit,
qsci_graph.inputs.t_step_list,
qsci_graph.inputs.cas_init,
qsci_graph.inputs.cas_hsim,
qsci_graph.inputs.mo_occ,
qsci_graph.inputs.max_cx_gates_hsim,
),
)
backend_results = qsci_graph.map(_compile_and_run(), circuits)
energy = qsci_graph.task(
energy_from_results(
ham_hsim,
backend_results,
qsci_graph.inputs.mo_occ,
qsci_graph.inputs.cas_init,
qsci_graph.inputs.cas_hsim,
),
)
qsci_graph.outputs(QSCIOutputs(energy))
Finally we can run the graph as before
import json
from pathlib import Path
from uuid import UUID
from tierkreis.consts import PACKAGE_PATH
from tierkreis.controller import run_graph
from tierkreis.controller.executor.multiple import MultipleExecutor
from tierkreis.controller.executor.uv_executor import UvExecutor
from tierkreis.controller.storage.filestorage import ControllerFileStorage
from tierkreis.storage import read_outputs
workflow_id = UUID(int=104)
storage = ControllerFileStorage(workflow_id, name="qsci", do_cleanup=True)
registry_path = Path().parent / "example_workers"
custom_executor = UvExecutor(registry_path=registry_path, logs_path=storage.logs_path)
common_registry_path = PACKAGE_PATH.parent / "tierkreis_workers"
common_executor = UvExecutor(
registry_path=common_registry_path,
logs_path=storage.logs_path,
)
multi_executor = MultipleExecutor(
common_executor,
executors={"custom": custom_executor},
assignments={"qsci_worker": "custom"},
)
run_graph(
storage,
multi_executor,
qsci_graph,
{
k: json.dumps(v).encode()
for k, v in (
{
"molecule": {
"geometry": [
["H", [0, 0, 0]],
["H", [0, 0, 1.0]],
["H", [0, 0, 2.0]],
["H", [0, 0, 3.0]],
],
"basis": "sto-3g",
"charge": 0,
},
"reference_state": [1, 1, 0, 0],
"cas_init": {
"n": 2,
"n_ele": 2,
},
"cas_hsim": {
"n": 4,
"n_ele": 4,
},
"max_cx_gates_hsim": 2000,
"mo_occ": [2, 2, 0, 0],
"t_step_list": [0.5, 1.0, 1.5],
"max_iteration_prep": 5,
"atol": 0.03,
}
).items()
},
polling_interval_seconds=0.01,
)
output = read_outputs(qsci_graph, storage)