Skip to main content
IBM Quantum Platform

Diagonalização quantum baseada em amostras de um Hamiltoniano químico

Estimativa de uso: menos de um minuto em um processador Heron r2 (OBSERVAÇÃO: esta é apenas uma estimativa. Seu tempo de execução pode variar)


Resultados do aprendizado

Após concluir este tutorial, os usuários deverão compreender:

  • Como usar o complemento SQD Qiskit para aproximar a energia do estado fundamental de um sistema molecular utilizando sequências de bits amostradas a partir de uma unidade de processamento quântico (QPU).
  • Como usar o ffsim para construir um circuito Jastrow de cluster unitário local (LUCJ) para simulação em química quântica.

Pré-requisitos

Recomendamos que os usuários se familiarizem com os seguintes tópicos antes de seguir com este tutorial:

  • Química quântica e segunda quantização
  • Utilizando a primitiva Sampler para obter amostras de circuitos quânticos

Segundo plano

Neste tutorial, mostramos como realizar o pós-processamento de amostras quânticas com ruído para aproximar o estado fundamental da molécula de nitrogênio N2\text{N}_2 no comprimento de ligação de equilíbrio, utilizando o complemento SQD do Qiskit para implementar o algoritmo de diagonalização quântica baseada em amostras (SQD). Mais detalhes sobre o software podem ser encontrados na documentação correspondente, incluindo um exemplo simples para começar.

Este tutorial é recomendado para usuários familiarizados com a química quântica: mais especificamente, com o cálculo das energias do estado fundamental de uma molécula. Para obter um guia detalhado sobre o fluxo de trabalho, consulte o curso sobre o algoritmo de diagonalização quântica.

A SQD é uma técnica para determinar os autovalores e autovetores de operadores quânticos, como o hamiltoniano de um sistema quântico, por meio da combinação da computação quântica com a computação clássica distribuída. A computação distribuída clássica é utilizada para processar amostras obtidas de um processador quântico e para projetar e diagonalizar um hamiltoniano alvo num subespaço que essas amostras definem. Um fluxo de trabalho baseado em SQD segue as seguintes etapas:

  1. Escolha um ansatz de circuito e aplique-o em um computador quântico a um estado de referência (nesse caso, o estado Hartree-Fock ).
  2. Amostra de cadeias de bits do estado quântico resultante.
  3. Execute o procedimento de recuperação de configuração autoconsistente nas cadeias de bits para obter a aproximação do estado fundamental.

Sabe-se que a SQD funciona bem quando o estado próprio alvo é esparso: a função de onda é suportada em um conjunto de estados básicos S={x}\mathcal{S} = \{|x\rangle \} cujo tamanho não aumenta exponencialmente com o tamanho do problema.

Química quântica

O Hamiltoniano de um sistema molecular pode ser escrito como

H^=prσhpra^pσa^rσ+12prqsστhprqsa^pσa^qτa^sτa^rσ,\hat{H} = \sum_{ \substack{pr\\\sigma} } h_{pr} \, \hat{a}^\dagger_{p\sigma} \hat{a}_{r\sigma} + \frac12 \sum_{ \substack{prqs\\\sigma\tau} } h_{prqs} \, \hat{a}^\dagger_{p\sigma} \hat{a}^\dagger_{q\tau} \hat{a}_{s\tau} \hat{a}_{r\sigma},

onde hprh_{pr} e hprqsh_{prqs} são números complexos chamados de integrais moleculares que podem ser calculados a partir da especificação da molécula usando um programa de computador. Neste tutorial, calculamos as integrais usando o pacote de software PySCF pacote de software.

Para obter detalhes sobre como o Hamiltoniano molecular é derivado, consulte um livro-texto sobre química quântica (por exemplo, Modern Quantum Chemistry, de Szabo e Ostlund). Para obter uma explicação de alto nível sobre como os problemas de química quântica são mapeados em computadores quânticos, confira a palestra Mapping Problems to Qubits (Mapeando problemas para Qubits ) da Qiskit Global Summer School 2024.

Abordagem do cluster unitário local de Jastrow (LUCJ)

O SQD requer um ansatz de circuito quântico do qual extrair amostras. Neste tutorial, utilizaremos o modelo do cluster unitário local de Jastrow (LUCJ), devido à sua combinação de fundamentação física e facilidade de implementação em hardware. Usaremos o ffsim para construir o circuito de ansatz.

A abordagem LUCJ adapta-se a QPUs com conectividade de qubits restrita. Os orbitais de spin são mapeados para qubits de forma que a hipótese não exija o roteamento por meio de portas SWAP. IBM® O hardware possui uma topologia de qubits em rede hexagonal densa; nesse caso, podemos adotar um padrão em “ziguezague”, ilustrado abaixo. Nesse padrão, os orbitais com o mesmo spin são mapeados para qubits com topologia em linha (círculos vermelhos e azuis), e há uma conexão entre orbitais de spin diferente a cada quarto orbital espacial, sendo essa conexão facilitada por um qubit auxiliar (círculos roxos).

Diagrama de mapeamento de Qubit para a ansatz LUCJ em uma rede hexagonal pesada

Recuperação de configuração auto-consistente

O procedimento de recuperação de configuração autoconsistente foi projetado para extrair o máximo de sinal possível de amostras quânticas com ruído. Como o Hamiltoniano molecular conserva o número de partículas e o spin Z, faz sentido escolher um ansatz de circuito que também conserve essas simetrias. Quando aplicado ao estado Hartree-Fock, o estado resultante tem um número de partículas e um spin Z fixos na configuração sem ruído. Portanto, as metades spin- α\alpha e spin- β\beta de qualquer bitstring amostrada a partir desse estado devem ter o mesmo peso de Hamming que no estado Hartree-Fock. Devido à presença de ruído nos processadores quânticos atuais, algumas cadeias de bits medidas violarão essa propriedade. Uma forma simples de pós-seleção descartaria essas cadeias de bits, mas isso é um desperdício porque essas cadeias de bits ainda podem conter algum sinal. O procedimento de recuperação autoconsistente tenta recuperar parte desse sinal no pós-processamento. O procedimento é iterativo e requer, como entrada, uma estimativa das ocupações médias de cada orbital no estado fundamental, que é primeiro computada a partir das amostras brutas. O procedimento é executado em um loop, e cada iteração tem as seguintes etapas:

  1. Para cada bitstring que violar as simetrias especificadas, inverta seus bits com um procedimento probabilístico projetado para aproximar o bitstring da estimativa atual das ocupações orbitais médias, para obter um novo bitstring.
  2. Coletar todas as cadeias de bits antigas e novas que satisfaçam as simetrias e subamostrar subconjuntos de tamanho fixo, escolhidos antecipadamente.
  3. Para cada subconjunto de cadeias de bits, projete o Hamiltoniano no subespaço abrangido pelos vetores de base correspondentes (consulte a seção anterior para obter uma descrição desses vetores de base) e calcule uma estimativa do estado fundamental do Hamiltoniano projetado em um computador clássico.
  4. Atualize a estimativa das ocupações orbitais médias com a estimativa do estado fundamental com a energia mais baixa.

Diagrama do fluxo de trabalho SQD

O fluxo de trabalho do SQD está representado no diagrama a seguir:

Diagrama de fluxo de trabalho do algoritmo SQD

Requisitos

Antes de iniciar este tutorial, verifique se você tem os seguintes itens instalados:

  • Qiskit SDK v1.0 ou posterior, com suporte para visualização
  • Qiskit Runtime v0.22 ou mais tarde (pip install qiskit-ibm-runtime)
  • Complemento SQD Qiskit v0.11 ou posterior (pip install qiskit-addon-sqd)
  • ffsim v0.0.75 ou mais recente (pip install ffsim)

Instalação

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 Sampler

Exemplo de simulador em pequena escala

Neste tutorial, vamos encontrar uma aproximação do estado fundamental de uma molécula de nitrogênio próxima à sua distância de ligação de equilíbrio. Primeiramente, utilizamos um pequeno conjunto de bases do tipo “ STO-6G ” para simular o experimento e garantir que tudo esteja funcionando corretamente.

Passo 1: Mapear entradas clássicas para um problema quântico

Primeiro, especificamos a molécula e suas propriedades.

# 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 o circuito ansatz LUCJ, primeiro realizamos um cálculo CCSD na seguinte célula de código. As amplitudes t1t_1 e t2t_2 desse cálculo serão usadas para inicializar os parâmetros do 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.t2

Output:

E(CCSD) = -108.5933309085008  E_corr = -0.1283731437052354

Agora, usamos o ffsim para criar o circuito de ansatz. Como nossa molécula possui um estado de Hartree-Fock de camada fechada, utilizamos a variante com equilíbrio de spin do ansatz UCJ, UCJOpSpinBalanced. Definimos optimize=True no método from_t_amplitudes para habilitar a dupla fatoração "comprimida" das amplitudes de t2t_2 (consulte a abordagem do conjunto unitário local de Jastrow (LUCJ) na documentação do ffsim para obter mais detalhes).

Como o ansatz LUCJ se adapta à conectividade disponível da QPU, precisamos inicializar o backend da QPU antes de criar o ansatz. Por enquanto, vamos criar um backend genérico com um mapa de acoplamento hexagonal forte e um conjunto de portas no qual o ansatz LUCJ se decompõe naturalmente. Em seguida, usaremos ffsim.qiskit.generate_lucj_pass_manager para criar um gerenciador de passagens especializado em transpilá-lo o ansatz LUCJ para o backend especificado, de acordo com o layout “zig-zag” descrito na seção de contextualização sobre o ansatz LUCJ. Esta função utiliza uma heurística de pontuação para minimizar os erros associados ao layout selecionado, o que é importante se o seu backend for uma QPU real ou um simulador com um modelo de ruído. Além de retornar o gerenciador de passagem, esta função também retorna os pares de acoplamento alfa-beta que podem ser implementados no hardware. Se nem todos os pares puderem ser implementados, será exibido um aviso.

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()

Passo 2: Otimizar para execução em hardware quântico

Em seguida, otimizamos o circuito para um hardware específico. Normalmente, essa etapa envolve a inicialização do backend de hardware e de um gerenciador de passagens para esse backend. No entanto, como a abordagem LUCJ está adaptada à conectividade do hardware, já realizamos essas ações na etapa anterior. Tudo o que resta fazer é executar o gerenciador de passagens no circuito para compilá-lo para um circuito ISA que possa ser executado diretamente na 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})

Passo 3: Execute usando Qiskit primitives

Depois de otimizar o circuito para execução em hardware, estamos prontos para executá-lo no hardware de destino e coletar amostras para a estimativa de energia do estado fundamental. Como temos apenas um circuito, usaremos o modo de execução de trabalho do site Qiskit Runtime e executaremos nosso 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]

Etapa 4: Pós-processamento e retorno do resultado no formato clássico desejado

Uma métrica útil para avaliar a qualidade da saída da QPU é o número de configurações válidas retornadas. Uma configuração válida tem o número correto de partículas e spin Z, o que significa que a metade direita da cadeia de bits tem peso de Hamming igual ao número de elétrons com spin para cima, e a metade esquerda tem peso de Hamming igual ao número de elétrons com spin para baixo. A célula a seguir calcula a fração de configurações amostradas que são 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 as sequências de bits são válidas porque estamos fazendo a amostragem do circuito em um simulador sem ruído. Ao executar em uma QPU ruidosa, a fração será menor que um, mas esperamos que seja maior do que a fração que se esperaria se as sequências de bits fossem amostradas de forma aleatória e uniforme, o que é calculado na célula a seguir.

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

Agora, estimamos a energia do estado fundamental do Hamiltoniano usando a função diagonalize_fermionic_hamiltonian . Essa função executa o procedimento de recuperação de configuração autoconsistente para refinar iterativamente as amostras quânticas com ruído para melhorar a estimativa de energia. Passamos uma função de retorno de chamada para que possamos salvar os resultados intermediários para análise posterior. Consulte a documentação da API para obter explicações sobre os argumentos para diagonalize_fermionic_hamiltonian.

Aqui, usamos o initial_occupancies argumento para diagonalize_fermionic_hamiltonian especificar a configuração de Hartree-Fock como a estimativa inicial para as ocupações orbitais no estado fundamental. Essa abordagem é sensata para sistemas em que o estado fundamental tem suporte significativo na configuração Hartree-Fock, mas pode não ser apropriada em outras situações, embora métodos computacionais mais avançados possam produzir melhores estimativas iniciais nesses casos. Especificar initial_occupancies também permite que a recuperação da configuração seja executada mesmo que nenhuma configuração válida tenha sido amostrada, como pode ser o caso ao amostrar um circuito grande em um QPU ruidoso. Sem esse argumento, a recuperação da configuração falharia e geraria um erro se nenhuma configuração válida fosse fornecida.

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

Visualize os resultados

O primeiro gráfico mostra que, nesta simulação, já estamos próximos 1 mH da resposta exata após a primeira iteração (normalmente, aceita-se que a precisão química seja de 1 kcal/mol\approx1.6 mH). Trata-se, porém, de um sistema pequeno e, como as amostras não apresentam ruído, não é necessário recuperar a configuração. Em um sistema de maior porte executado em uma QPU ruidosa, podem ser necessárias várias iterações de recuperação da configuração, e a precisão final pode ser inferior. Geralmente, é possível melhorar a energia permitindo mais iterações de recuperação da configuração ou aumentando o número de amostras por lote.

O segundo gráfico mostra a ocupação média de cada orbital espacial após a iteração final. Podemos ver que tanto os elétrons de spin para cima quanto os de spin para baixo ocupam os primeiros cinco orbitais com alta probabilidade em nossas soluções.

# 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:

Output of the previous code cell

Exemplo de hardware em grande escala

Agora, vamos executar um exemplo maior em hardware quântico real. Aqui, derivaremos um espaço ativo para a molécula de nitrogênio a partir do conjunto de bases cc-pVDZ.

Etapas 1 a 4

Aqui, reunimos todas as etapas em um único fluxo de trabalho em maior escala, que é então executado em hardware quâ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
Output of the previous code cell

Próximas etapas

Recomendações

Se você achou este trabalho interessante, talvez se interesse pelo seguinte material:

Esta página foi útil?
Relate um bug, erro de digitação ou solicite conteúdo no GitHub.