Diagonalización cuántica basada en muestras de un hamiltoniano químico
Estimación de uso: menos de un minuto en un procesador Heron r2 (NOTA: Esto es sólo una estimación. Su tiempo de ejecución puede variar)
Resultados del aprendizaje
Una vez completado este tutorial, los usuarios deberían comprender:
- Cómo utilizar el complemento SQD Qiskit para aproximar la energía del estado fundamental de un sistema molecular utilizando cadenas de bits muestreadas a partir de una unidad de procesamiento cuántico (QPU).
- Cómo utilizar ffsim para construir un circuito Jastrow de clúster unitario local (LUCJ) para la simulación de química cuántica.
Requisitos previos
Recomendamos a los usuarios que se familiaricen con los siguientes temas antes de seguir este tutorial:
- Química cuántica y segunda cuantización
- Uso de la primitiva «Sampler» para obtener muestras de circuitos cuánticos
En segundo plano
En este tutorial, mostramos cómo realizar el posprocesamiento de muestras cuánticas con ruido para aproximar el estado fundamental de la molécula de nitrógeno a la longitud de enlace de equilibrio, utilizando el complemento SQD de Qiskit para implementar el algoritmo de diagonalización cuántica basada en muestras (SQD). En la documentación correspondiente se puede encontrar más información sobre el software, incluido un ejemplo sencillo para empezar.
Este tutorial está recomendado para usuarios que estén familiarizados con la química cuántica; en concreto, con el cálculo de las energías del estado fundamental de una molécula. Para obtener una guía detallada sobre el flujo de trabajo, consulta el curso sobre el algoritmo de diagonalización cuántica.
El SQD es una técnica para hallar los valores propios y los vectores propios de operadores cuánticos, como el hamiltoniano de un sistema cuántico, mediante la combinación de la computación cuántica y la computación clásica distribuida. La computación distribuida clásica se utiliza para procesar muestras obtenidas de un procesador cuántico, así como para proyectar y diagonalizar un hamiltoniano objetivo en un subespacio que estas abarcan. Un flujo de trabajo basado en SQD consta de los siguientes pasos:
- Elija un ansatz de circuito y aplíquelo en un ordenador cuántico a un estado de referencia (en este caso, el estado Hartree-Fock ).
- Muestras de cadenas de bits del estado cuántico resultante.
- Ejecuta el procedimiento de recuperación de la configuración autoconsistente en las cadenas de bits para obtener la aproximación del estado fundamental.
Se sabe que SQD funciona bien cuando el estado propio objetivo es disperso: la función de onda está soportada en un conjunto de estados base cuyo tamaño no aumenta exponencialmente con el tamaño del problema.
Química cuántica
El Hamiltoniano de un sistema molecular puede escribirse como
donde y son números complejos denominados integrales moleculares que pueden calcularse a partir de la especificación de la molécula utilizando un programa informático. En este tutorial, calculamos las integrales utilizando el paquete de software PySCF paquete de software.
Para más detalles sobre cómo se obtiene el hamiltoniano molecular, consulte un libro de texto sobre química cuántica (por ejemplo, Modern Quantum Chemistry de Szabo y Ostlund). Para una explicación de alto nivel de cómo los problemas de química cuántica se trasladan a los ordenadores cuánticos, consulte la conferencia Mapping Problems to Qubits de la Escuela Mundial de Verano Qiskit 2024.
Enfoque del clúster unitario local de Jastrow (LUCJ)
El SQD requiere un ansatz de circuito cuántico del que extraer muestras. En este tutorial utilizaremos el enfoque del clúster unitario local de Jastrow (LUCJ), debido a su combinación de fundamentación física y facilidad de implementación en hardware. Utilizaremos ffsim para construir el circuito de aproximación.
El enfoque LUCJ se adapta a las QPU con conectividad de qubits limitada. Los orbitales de espín se asignan a qubits de tal manera que el ansatz no requiere el uso de puertas SWAP. IBM® El hardware presenta una topología de qubits de red hexagonal densa, en cuyo caso podemos adoptar un patrón «en zigzag», tal y como se muestra a continuación. En este esquema, los orbitales con el mismo espín se asignan a qubits con una topología lineal (círculos rojos y azules), y cada cuatro orbitales espaciales hay una conexión entre orbitales de espín diferente, facilitada por un qubit auxiliar (círculos morados).
Recuperación de configuración autoconsistente
El procedimiento de recuperación de la configuración autoconsistente está diseñado para extraer la mayor cantidad de señal posible de muestras cuánticas ruidosas. Dado que el Hamiltoniano molecular conserva el número de partícula y el espín Z, tiene sentido elegir un ansatz de circuito que también conserve estas simetrías. Cuando se aplica al estado Hartree-Fock, el estado resultante tiene un número de partícula y un espín Z fijos en la configuración sin ruido. Por lo tanto, las mitades spin- y spin- de cualquier cadena de bits muestreada desde este estado deben tener el mismo peso Hamming que en el estado Hartree-Fock. Debido a la presencia de ruido en los procesadores cuánticos actuales, algunas cadenas de bits medidas violarán esta propiedad. Una forma sencilla de postselección descartaría estas cadenas de bits, pero esto es un desperdicio porque esas cadenas de bits aún podrían contener alguna señal. El procedimiento de recuperación autoconsistente intenta recuperar parte de esa señal en el postprocesado. El procedimiento es iterativo y requiere como entrada una estimación de las ocupaciones medias de cada orbital en el estado fundamental, que se calcula primero a partir de las muestras brutas. El procedimiento se ejecuta en un bucle, y cada iteración tiene los siguientes pasos:
- Para cada cadena de bits que viole las simetrías especificadas, voltea sus bits con un procedimiento probabilístico diseñado para acercar la cadena de bits a la estimación actual de las ocupaciones orbitales medias, para obtener una nueva cadena de bits.
- Recoge todas las cadenas de bits antiguas y nuevas que cumplan las simetrías, y submuestrea subconjuntos de un tamaño fijo, elegido de antemano.
- Para cada subconjunto de cadenas de bits, proyecte el Hamiltoniano en el subespacio abarcado por los vectores base correspondientes (véase la sección anterior para una descripción de estos vectores base), y calcule una estimación del estado base del Hamiltoniano proyectado en un ordenador clásico.
- Actualizar la estimación de las ocupaciones orbitales medias con la estimación del estado fundamental con la energía más baja.
Diagrama del flujo de trabajo SQD
El flujo de trabajo de SQD se representa en el siguiente diagrama:
Requisitos
Antes de empezar este tutorial, asegúrate de que tienes instalado lo siguiente:
- Qiskit SDK v1.0 o posterior, con soporte de visualización
- Qiskit Runtime v0.22 o posterior (
pip install qiskit-ibm-runtime) - Complemento SQD Qiskit v0.11 o posterior (
pip install qiskit-addon-sqd) - ffsim v0.0.75 o posterior (
pip install ffsim)
Configuración
import math
import ffsim
import matplotlib.pyplot as plt
import numpy as np
import pyscf
import pyscf.cc
import pyscf.mcscf
from qiskit import QuantumCircuit, QuantumRegister
from qiskit.primitives import StatevectorSampler
from qiskit.providers.fake_provider import GenericBackendV2
from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_ibm_runtime import SamplerV2 as SamplerEjemplo de simulador a pequeña escala
En este tutorial, hallaremos una aproximación al estado fundamental de una molécula de nitrógeno cerca de su distancia de enlace de equilibrio. En primer lugar, utilizamos un pequeño conjunto de bases « STO-6G » para poder simular el experimento y asegurarnos de que funciona.
Paso 1: Asignar entradas clásicas a un problema cuántico
En primer lugar, especificamos la molécula y sus propiedades.
# Specify molecule properties
spin_sq = 0
# Build N2 molecule
mol = pyscf.gto.Mole()
mol.build(
atom=[["N", (0, 0, 0)], ["N", (1.0, 0, 0)]],
basis="sto-6g",
symmetry="Dooh",
)
# Define active space
n_frozen = 2
active_space = range(n_frozen, mol.nao_nr())
# Get molecular integrals
scf = pyscf.scf.RHF(mol).run()
norb = len(active_space)
n_electrons = int(sum(scf.mo_occ[active_space]))
n_alpha = (n_electrons + mol.spin) // 2
n_beta = (n_electrons - mol.spin) // 2
nelec = (n_alpha, n_beta)
cas = pyscf.mcscf.CASCI(scf, norb, nelec)
mo = cas.sort_mo(active_space, base=0)
hcore, nuclear_repulsion_energy = cas.get_h1cas(mo)
eri = pyscf.ao2mo.restore(1, cas.get_h2cas(mo), norb)
# Compute exact energy using FCI
reference_energy = cas.run().e_tot
print(f"norb = {norb}")
print(f"nelec = {nelec}")Output:
converged SCF energy = -108.464957764796
CASCI E = -108.595987350986 E(CI) = -32.4115475088426 S^2 = 0.0000000
norb = 8
nelec = (5, 5)
Antes de construir el circuito LUCJ ansatz, primero realizamos un cálculo CCSD en la siguiente celda de código. Las amplitudes y de este cálculo se utilizarán para inicializar los parámetros del ansatz.
# Get CCSD t2 amplitudes for initializing the ansatz
ccsd = pyscf.cc.CCSD(
scf, frozen=[i for i in range(mol.nao_nr()) if i not in active_space]
).run()
t1 = ccsd.t1
t2 = ccsd.t2Output:
E(CCSD) = -108.5933309085008 E_corr = -0.1283731437052354
Ahora utilizamos ffsim para crear el circuito de aproximación. Dado que nuestra molécula presenta un estado de Hartree-Fock de capa cerrada, utilizamos la variante de equilibrio de espín del enfoque UCJ, UCJOpSpinBalanced. En el from_t_amplitudes método hemos establecido optimize=True para habilitar la doble factorización «comprimida» de las amplitudes de (véase el enfoque del clúster unitario local de Jastrow (LUCJ) en la documentación de ffsim para más detalles).
Dado que el ansatz LUCJ se adapta a la conectividad disponible de la QPU, debemos inicializar el backend de la QPU antes de crear el ansatz. Por ahora, crearemos un backend genérico con un mapa de acoplamiento hexagonal fuerte y un conjunto de puertas en el que el enfoque LUCJ se descompone de forma natural. A continuación, utilizaremos ffsim.qiskit.generate_lucj_pass_manager para crear un gestor de pases especializado en la transpilación del enfoque LUCJ al backend especificado, siguiendo el esquema «en zigzag» descrito en la sección de antecedentes sobre el enfoque LUCJ. Esta función utiliza una heurística de puntuación para minimizar los errores asociados al diseño seleccionado, lo cual es importante si tu backend es una QPU real o un simulador con un modelo de ruido. Además de devolver el gestor de pases, esta función también devuelve los pares de acoplamiento alfa-beta que se pueden implementar en el hardware. Si no es posible implementar todos los pares, se emite una advertencia.
import warnings
from qiskit.transpiler import CouplingMap
warnings.formatwarning = lambda msg, *args, **kwargs: f"Warning: {msg}\n"
# Set ansatz properties
n_reps = 1
pairs_aa = [(p, p + 1) for p in range(norb - 1)]
# Let generate_lucj_pass_manager determine the alpha-beta interactions
pairs_ab = None
# Initialize backend
coupling_map = CouplingMap.from_heavy_hex(3)
backend = GenericBackendV2(
coupling_map.size(),
coupling_map=coupling_map,
basis_gates=["cp", "xx_plus_yy", "p", "x", "swap"],
)
# Create pass manager
pass_manager, pairs_ab = ffsim.qiskit.generate_lucj_pass_manager(
backend=backend,
norb=norb,
connectivity="heavy-hex",
interaction_pairs=(pairs_aa, pairs_ab),
optimization_level=3,
)
# Create the LUCJ ansatz operator
ucj_op = ffsim.UCJOpSpinBalanced.from_t_amplitudes(
t2=t2,
t1=t1,
n_reps=n_reps,
interaction_pairs=(pairs_aa, pairs_ab),
# Setting optimize=True enables the "compressed" factorization
optimize=True,
# Limit the number of optimization iterations to prevent the code cell
# from running too long. Removing this line may improve results.
options=dict(maxiter=1000),
)
# create an empty quantum circuit
qubits = QuantumRegister(2 * norb, name="q")
circuit = QuantumCircuit(qubits)
# prepare Hartree-Fock state as the reference state and append it
# to the quantum circuit
circuit.append(ffsim.qiskit.PrepareHartreeFockJW(norb, nelec), qubits)
# apply the UCJ operator to the reference state
circuit.append(ffsim.qiskit.UCJOpSpinBalancedJW(ucj_op), qubits)
circuit.measure_all()Paso 2: Optimizar para la ejecución en hardware cuántico
A continuación, optimizamos el circuito para un hardware específico. Por lo general, este paso consiste en inicializar el backend de hardware y un gestor de pases para dicho backend. Sin embargo, dado que el enfoque LUCJ se adapta a la conectividad del hardware, ya hemos llevado a cabo estas acciones en el paso anterior. Lo único que queda por hacer es ejecutar el gestor de pases en el circuito para transpilarlo a un circuito ISA que pueda ejecutarse directamente en la QPU.
isa_circuit = pass_manager.run(circuit)
print(f"Gate counts: {isa_circuit.count_ops()}")Output:
Gate counts: OrderedDict({'xx_plus_yy': 86, 'p': 16, 'measure': 16, 'cp': 15, 'x': 10, 'swap': 2, 'barrier': 1})
Paso 3: Ejecutar utilizando Qiskit primitives
Después de optimizar el circuito para su ejecución en hardware, estamos listos para ejecutarlo en el hardware de destino y recoger muestras para la estimación de la energía del estado de tierra. Como sólo tenemos un circuito, utilizaremos el modo de ejecución Job de Qiskit Runtime y ejecutaremos nuestro circuito.
rng = np.random.default_rng()
sampler = StatevectorSampler(seed=rng)
job = sampler.run([isa_circuit], shots=100_000)Output:
Warning: Trying to add QuantumRegister to a QuantumCircuit having a layout
primitive_result = job.result()
pub_result = primitive_result[0]Paso 4: Procesamiento posterior y devolución del resultado en el formato clásico deseado
Una métrica útil para evaluar la calidad de la salida de la QPU es el número de configuraciones válidas devueltas. Una configuración válida tiene el número correcto de partículas y el espín Z, lo que significa que la mitad derecha de la cadena de bits tiene un peso de Hamming igual al número de electrones con espín hacia arriba, y la mitad izquierda tiene un peso de Hamming igual al número de electrones con espín hacia abajo. La siguiente celda calcula la fracción de configuraciones muestreadas que son válidas.
def is_valid_bitstring(
bitstring: str, norb: int, nelec: tuple[int, int]
) -> bool:
n_alpha, n_beta = nelec
return (
len(bitstring) == 2 * norb
and bitstring[norb:].count("1") == n_alpha
and bitstring[:norb].count("1") == n_beta
)
bit_array = pub_result.data.meas
num_valid = sum(
is_valid_bitstring(b, norb, nelec) for b in bit_array.get_bitstrings()
)
valid_fraction = num_valid / bit_array.num_shots
print(f"Fraction of sampled configurations that are valid: {valid_fraction}")Output:
Fraction of sampled configurations that are valid: 1.0
Todas las cadenas de bits son válidas porque estamos realizando un muestreo del circuito en un simulador sin ruido. Cuando se ejecuta en una QPU ruidosa, la fracción será inferior a uno, pero es de esperar que sea mayor que la fracción que cabría esperar si las cadenas de bits se muestrearan de forma aleatoria y uniforme, tal y como se calcula en la siguiente celda.
expected_fraction_random = (
math.comb(norb, n_alpha) * math.comb(norb, n_beta) / 2 ** (2 * norb)
)
print(
f"Expected fraction of valid configurations from uniformly random bitstrings: "
f"{expected_fraction_random}"
)Output:
Expected fraction of valid configurations from uniformly random bitstrings: 0.0478515625
Ahora, estimamos la energía del estado fundamental del Hamiltoniano utilizando la función diagonalize_fermionic_hamiltonian . Esta función realiza el procedimiento de recuperación de la configuración autoconsistente para refinar iterativamente las muestras cuánticas ruidosas con el fin de mejorar la estimación de la energía. Pasamos una función callback para poder guardar los resultados intermedios para su posterior análisis. Consulte la documentación de la API para obtener explicaciones sobre los argumentos de diagonalize_fermionic_hamiltonian.
Aquí, utilizamos el initial_occupancies argumento para diagonalize_fermionic_hamiltonian especificar la configuración de Hartree-Fock como la estimación inicial para las ocupaciones orbitales en el estado fundamental. Este enfoque es adecuado para sistemas en los que el estado fundamental tiene un apoyo significativo en la configuración de Hartree-Fock, pero puede que no sea apropiado en otras situaciones, aunque métodos computacionales más avanzados podrían proporcionar mejores estimaciones iniciales en esos casos. Especificar initial_occupancies también permite que la recuperación de la configuración se ejecute incluso si no se han muestreado configuraciones válidas, como puede ocurrir al muestrear un circuito grande en una QPU ruidosa. Sin este argumento, la recuperación de la configuración fallaría y generaría un error si no se proporcionaran configuraciones válidas.
from functools import partial
from qiskit_addon_sqd.fermion import (
SCIResult,
diagonalize_fermionic_hamiltonian,
solve_sci_batch,
)
# SQD options
energy_tol = 1e-3
occupancies_tol = 1e-3
max_iterations = 5
# Eigenstate solver options
num_batches = 3
samples_per_batch = 300
symmetrize_spin = True
carryover_threshold = 1e-4
max_cycle = 200
# Use the Hartree-Fock configuration as an initial guess for the orbital occupancies
initial_occupancies = (
np.array([1] * n_alpha + [0] * (norb - n_alpha)),
np.array([1] * n_beta + [0] * (norb - n_beta)),
)
# Pass options to the built-in eigensolver. If you just want to use the defaults,
# you can omit this step, in which case you would not specify the sci_solver argument
# in the call to diagonalize_fermionic_hamiltonian below.
sci_solver = partial(solve_sci_batch, spin_sq=0.0, max_cycle=max_cycle)
# List to capture intermediate results
result_history = []
def callback(results: list[SCIResult]):
result_history.append(results)
iteration = len(result_history)
print(f"Iteration {iteration}")
for i, result in enumerate(results):
print(f"\tSubsample {i}")
print(f"\t\tEnergy: {result.energy + nuclear_repulsion_energy}")
print(
f"\t\tSubspace dimension: {np.prod(result.sci_state.amplitudes.shape)}"
)
result = diagonalize_fermionic_hamiltonian(
hcore,
eri,
bit_array,
samples_per_batch=samples_per_batch,
norb=norb,
nelec=nelec,
num_batches=num_batches,
energy_tol=energy_tol,
occupancies_tol=occupancies_tol,
max_iterations=max_iterations,
sci_solver=sci_solver,
symmetrize_spin=symmetrize_spin,
initial_occupancies=initial_occupancies,
carryover_threshold=carryover_threshold,
callback=callback,
seed=rng,
)
final_energy = result.energy + nuclear_repulsion_energy
energy_error = final_energy - reference_energy
print(f"Final energy: {final_energy}")
print(f"Final energy error: {energy_error}")Output:
Iteration 1
Subsample 0
Energy: -108.59275573641656
Subspace dimension: 900
Subsample 1
Energy: -108.59275573641656
Subspace dimension: 900
Subsample 2
Energy: -108.59275573641656
Subspace dimension: 900
Iteration 2
Subsample 0
Energy: -108.59275573641656
Subspace dimension: 900
Subsample 1
Energy: -108.59275573641656
Subspace dimension: 900
Subsample 2
Energy: -108.59275573641656
Subspace dimension: 900
Final energy: -108.59275573641656
Final energy error: 0.0032316145694579745
Visualizar los resultados
El primer gráfico muestra que, en esta simulación, ya nos encontramos muy 1 mH cerca de la respuesta exacta tras la primera iteración (por lo general, se acepta que la precisión química es de 1 kcal/mol1.6 mH). Sin embargo, se trata de un sistema pequeño y, dado que las muestras no contienen ruido, no es necesario recuperar la configuración. En un sistema más grande que se ejecute en una QPU ruidosa, es posible que se necesiten varias iteraciones de recuperación de la configuración y que la precisión final sea menor. Por lo general, se puede mejorar la energía permitiendo más iteraciones de recuperación de la configuración o aumentando el número de muestras por lote.
El segundo gráfico muestra la ocupación media de cada orbital espacial tras la iteración final. Podemos ver que tanto los electrones de espín arriba como los de espín abajo ocupan los cinco primeros orbitales con alta probabilidad en nuestras soluciones.
# Data for energies plot
x1 = range(len(result_history))
min_e = [
min(result, key=lambda res: res.energy).energy + nuclear_repulsion_energy
for result in result_history
]
e_diff = [abs(e - reference_energy) for e in min_e]
yt1 = [1.0, 1e-1, 1e-2, 1e-3, 1e-4]
# Chemical accuracy (+/- 1 milli-Hartree)
chem_accuracy = 0.001
# Data for avg spatial orbital occupancy
y2 = np.sum(result.orbital_occupancies, axis=0)
x2 = range(len(y2))
fig, axs = plt.subplots(1, 2, figsize=(12, 6))
# Plot energies
axs[0].plot(x1, e_diff, label="energy error", marker="o")
axs[0].set_xticks(x1)
axs[0].set_xticklabels(x1)
axs[0].set_yticks(yt1)
axs[0].set_yticklabels(yt1)
axs[0].set_yscale("log")
axs[0].set_ylim(1e-4)
axs[0].axhline(
y=chem_accuracy,
color="#BF5700",
linestyle="--",
label="chemical accuracy",
)
axs[0].set_title("Approximated Ground State Energy Error vs SQD Iterations")
axs[0].set_xlabel("Iteration Index", fontdict={"fontsize": 12})
axs[0].set_ylabel("Energy Error (Ha)", fontdict={"fontsize": 12})
axs[0].legend()
# Plot orbital occupancy
axs[1].bar(x2, y2, width=0.8)
axs[1].set_xticks(x2)
axs[1].set_xticklabels(x2)
axs[1].set_title("Avg Occupancy per Spatial Orbital")
axs[1].set_xlabel("Orbital Index", fontdict={"fontsize": 12})
axs[1].set_ylabel("Avg Occupancy", fontdict={"fontsize": 12})
plt.tight_layout()
plt.show()Output:
Ejemplo de hardware a gran escala
Ahora ejecutamos un ejemplo más amplio en hardware cuántico real. En este caso, derivaremos un espacio activo para la molécula de nitrógeno a partir del conjunto de bases « cc-pVDZ ».
Pasos 1 a 4
Aquí reunimos todos los pasos en un único flujo de trabajo a mayor escala, que luego se ejecuta en hardware cuántico real.
# ------------------------------ Step 1 ------------------------------
# Build N2 molecule
mol = pyscf.gto.Mole()
mol.build(
atom=[["N", (0, 0, 0)], ["N", (1.0, 0, 0)]],
basis="cc-pvdz",
symmetry="Dooh",
)
# Define active space
n_frozen = 2
active_space = range(n_frozen, mol.nao_nr())
# Get molecular integrals
scf = pyscf.scf.RHF(mol).run()
norb = len(active_space)
n_electrons = int(sum(scf.mo_occ[active_space]))
n_alpha = (n_electrons + mol.spin) // 2
n_beta = (n_electrons - mol.spin) // 2
nelec = (n_alpha, n_beta)
cas = pyscf.mcscf.CASCI(scf, norb, nelec)
mo = cas.sort_mo(active_space, base=0)
hcore, nuclear_repulsion_energy = cas.get_h1cas(mo)
eri = pyscf.ao2mo.restore(1, cas.get_h2cas(mo), norb)
# Store reference energy from SCI calculation performed separately
reference_energy = -109.22802921665716
print(f"norb = {norb}")
print(f"nelec = {nelec}")
# Get CCSD t2 amplitudes for initializing the ansatz
ccsd = pyscf.cc.CCSD(
scf, frozen=[i for i in range(mol.nao_nr()) if i not in active_space]
).run()
t1 = ccsd.t1
t2 = ccsd.t2
# Set ansatz properties
n_reps = 1
pairs_aa = [(p, p + 1) for p in range(norb - 1)]
# Let generate_lucj_pass_manager determine the alpha-beta interactions
pairs_ab = None
# Initialize backend
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, min_num_qubits=133
)
print(f"Using backend {backend.name}")
# Create pass manager
pass_manager, pairs_ab = ffsim.qiskit.generate_lucj_pass_manager(
backend=backend,
norb=norb,
connectivity="heavy-hex",
interaction_pairs=(pairs_aa, pairs_ab),
optimization_level=3,
)
# Create the LUCJ ansatz operator
ucj_op = ffsim.UCJOpSpinBalanced.from_t_amplitudes(
t2=t2,
t1=t1,
n_reps=n_reps,
interaction_pairs=(pairs_aa, pairs_ab),
# Setting optimize=True enables the "compressed" factorization
optimize=True,
# Limit the number of optimization iterations to prevent the code cell
# from running too long. Removing this line may improve results.
options=dict(maxiter=1000),
)
# create an empty quantum circuit
qubits = QuantumRegister(2 * norb, name="q")
circuit = QuantumCircuit(qubits)
# prepare Hartree-Fock state as the reference state and append it
# to the quantum circuit
circuit.append(ffsim.qiskit.PrepareHartreeFockJW(norb, nelec), qubits)
# apply the UCJ operator to the reference state
circuit.append(ffsim.qiskit.UCJOpSpinBalancedJW(ucj_op), qubits)
circuit.measure_all()
# ------------------------------ Step 2 ------------------------------
isa_circuit = pass_manager.run(circuit)
print(f"Gate counts: {isa_circuit.count_ops()}")
# ------------------------------ Step 3 ------------------------------
sampler = Sampler(mode=backend)
sampler.options.environment.job_tags = ["TUT_SQD"]
job = sampler.run([isa_circuit], shots=100_000)
primitive_result = job.result()
pub_result = primitive_result[0]
# ------------------------------ Step 4 ------------------------------
bit_array = pub_result.data.meas
num_valid = sum(
is_valid_bitstring(b, norb, nelec) for b in bit_array.get_bitstrings()
)
valid_fraction = num_valid / bit_array.num_shots
print(f"Fraction of sampled configurations that are valid: {valid_fraction}")
expected_fraction_random = (
math.comb(norb, n_alpha) * math.comb(norb, n_beta) / 2 ** (2 * norb)
)
print(
f"Expected fraction of valid configurations from uniformly random bitstrings: "
f"{expected_fraction_random}"
)
# SQD options
energy_tol = 1e-3
occupancies_tol = 1e-3
max_iterations = 5
# Eigenstate solver options
num_batches = 3
samples_per_batch = 300
symmetrize_spin = True
carryover_threshold = 1e-4
max_cycle = 200
# Use the Hartree-Fock configuration as an initial guess for the
# orbital occupancies
initial_occupancies = (
np.array([1] * n_alpha + [0] * (norb - n_alpha)),
np.array([1] * n_beta + [0] * (norb - n_beta)),
)
# Pass options to the built-in eigensolver. If you just want to use the defaults,
# you can omit this step, in which case you would not specify the
# sci_solver argument in the call to diagonalize_fermionic_hamiltonian below.
sci_solver = partial(solve_sci_batch, spin_sq=0.0, max_cycle=max_cycle)
# List to capture intermediate results
result_history = []
result = diagonalize_fermionic_hamiltonian(
hcore,
eri,
bit_array,
samples_per_batch=samples_per_batch,
norb=norb,
nelec=nelec,
num_batches=num_batches,
energy_tol=energy_tol,
occupancies_tol=occupancies_tol,
max_iterations=max_iterations,
sci_solver=sci_solver,
symmetrize_spin=symmetrize_spin,
initial_occupancies=initial_occupancies,
carryover_threshold=carryover_threshold,
callback=callback,
seed=rng,
)
final_energy = result.energy + nuclear_repulsion_energy
energy_error = final_energy - reference_energy
print(f"Final energy: {final_energy}")
print(f"Final energy error: {energy_error}")
# Data for energies plot
x1 = range(len(result_history))
min_e = [
min(result, key=lambda res: res.energy).energy + nuclear_repulsion_energy
for result in result_history
]
e_diff = [abs(e - reference_energy) for e in min_e]
yt1 = [1.0, 1e-1, 1e-2, 1e-3, 1e-4]
# Chemical accuracy (+/- 1 milli-Hartree)
chem_accuracy = 0.001
# Data for avg spatial orbital occupancy
y2 = np.sum(result.orbital_occupancies, axis=0)
x2 = range(len(y2))
fig, axs = plt.subplots(1, 2, figsize=(12, 6))
# Plot energies
axs[0].plot(x1, e_diff, label="energy error", marker="o")
axs[0].set_xticks(x1)
axs[0].set_xticklabels(x1)
axs[0].set_yticks(yt1)
axs[0].set_yticklabels(yt1)
axs[0].set_yscale("log")
axs[0].set_ylim(1e-4)
axs[0].axhline(
y=chem_accuracy,
color="#BF5700",
linestyle="--",
label="chemical accuracy",
)
axs[0].set_title("Approximated Ground State Energy Error vs SQD Iterations")
axs[0].set_xlabel("Iteration Index", fontdict={"fontsize": 12})
axs[0].set_ylabel("Energy Error (Ha)", fontdict={"fontsize": 12})
axs[0].legend()
# Plot orbital occupancy
axs[1].bar(x2, y2, width=0.8)
axs[1].set_xticks(x2)
axs[1].set_xticklabels(x2)
axs[1].set_title("Avg Occupancy per Spatial Orbital")
axs[1].set_xlabel("Orbital Index", fontdict={"fontsize": 12})
axs[1].set_ylabel("Avg Occupancy", fontdict={"fontsize": 12})
plt.tight_layout()
plt.show()Output:
converged SCF energy = -108.929838385609
norb = 26
nelec = (5, 5)
E(CCSD) = -109.2177884185544 E_corr = -0.2879500329450045
Using backend ibm_boston
Warning: Backend cannot accommodate pairs_ab=[(0, 0), (4, 4), (8, 8), (12, 12), (16, 16), (20, 20), (24, 24)].
Removing interaction (24, 24) from the end.
Warning: Backend cannot accommodate pairs_ab=[(0, 0), (4, 4), (8, 8), (12, 12), (16, 16), (20, 20)].
Removing interaction (20, 20) from the end.
Gate counts: OrderedDict({'sx': 7039, 'rz': 6990, 'cz': 1858, 'x': 61, 'measure': 52, 'barrier': 1})
Fraction of sampled configurations that are valid: 0.02124
Expected fraction of valid configurations from uniformly random bitstrings: 9.607888706852918e-07
Iteration 1
Subsample 0
Energy: -109.13889134249762
Subspace dimension: 120409
Subsample 1
Energy: -109.11785470455858
Subspace dimension: 110889
Subsample 2
Energy: -109.13234360554011
Subspace dimension: 130321
Iteration 2
Subsample 0
Energy: -109.16392179579177
Subspace dimension: 223729
Subsample 1
Energy: -109.16281938332986
Subspace dimension: 223729
Subsample 2
Energy: -109.16955816711932
Subspace dimension: 233289
Iteration 3
Subsample 0
Energy: -109.17905772999075
Subspace dimension: 324900
Subsample 1
Energy: -109.17532445048462
Subspace dimension: 357604
Subsample 2
Energy: -109.1733168689756
Subspace dimension: 348100
Iteration 4
Subsample 0
Energy: -109.18437778820451
Subspace dimension: 474721
Subsample 1
Energy: -109.18450164209159
Subspace dimension: 476100
Subsample 2
Energy: -109.18493571190754
Subspace dimension: 487204
Iteration 5
Subsample 0
Energy: -109.18616522497996
Subspace dimension: 622521
Subsample 1
Energy: -109.18652868888333
Subspace dimension: 644809
Subsample 2
Energy: -109.18753326484406
Subspace dimension: 585225
Final energy: -109.18753326484406
Final energy error: 0.040495951813099396
Próximos pasos
Si te ha parecido interesante este trabajo, quizá te interese el siguiente material:
- Diagonalización cuántica de Krylov basada en muestras de un modelo de red fermiónica : tutorial relacionado que utiliza circuitos de evolución temporal en lugar de un enfoque variacional
- Amplía los flujos de trabajo químicos de SQD con el solucionador Dice : una página que explica cómo utilizar el software Dice, más eficiente, para la diagonalización
- Documentación de la API del complemento SQD : referencia de la
diagonalize_fermionic_hamiltonianfunción - La química más allá de la escala de la diagonalización exacta en un superordenador cuántico : el artículo en el que se basa este tutorial