Gere circuitos SqDRIFT
Na diagonalização quântica baseada em amostras (SQD), você precisa escolher um ansatz do qual amostrar bitstrings. A variante SqDRIFT usa, em vez disso, um conjunto de circuitos de evolução temporal construídos diretamente a partir do Hamiltoniano alvo. Isso é feito subamostrando operadores de evolução temporal menores do Hamiltoniano com base em seus coeficientes, o que é conhecido como o método de trotterização qDRIFT.
Este guia de primeiros passos mostra como gerar um conjunto desses circuitos aleatorizados.
1. Configuração do Hamiltoniano
Para os fins deste guia, carregue o Hamiltoniano de estrutura eletrônica do N2 a partir de um arquivo FCIDUMP. Existem outras formas de construir o FermionOperator. Consulte sua documentação, bem como o qiskit_fermions.operators.library.
- PYTHON
- C
>>> from qiskit_fermions.operators.library import FCIDump
>>> from qiskit_fermions.operators import FermionOperator
>>>
>>> fcidump = FCIDump.from_file("docs/guides/n2.fcidump")
>>> num_modes = 2 * fcidump.norb
>>> hamil = FermionOperator.from_fcidump(fcidump)
#include <qiskit_fermions.h>
QfFCIDump* fcidump = NULL;
qf_fcidump_from_file("docs/guides/n2.fcidump", &fcidump);
QfFermionOperator* hamil = qf_ferm_op_from_fcidump(fcidump);
uint32_t num_modes = 2 * qf_fcidump_norb(fcidump);
2. Agrupe os termos do Hamiltoniano
Aproveite as muitas simetrias presentes no Hamiltoniano de estrutura eletrônica agrupando termos relacionados que têm coeficientes idênticos. Essa ação muda a distribuição de coeficientes do operador da qual o protocolo qDRIFT amostra, mas não afeta suas garantias de convergência. Fundamentalmente, agrupar termos relacionados por simetria resulta em um cancelamento favorável de termos de Pauli, produzindo uma profundidade de circuito geral menor ao evoluir um estado no tempo sob sua ação.
O módulo qiskit_fermions.operators.terms.grouping fornece funções de conveniência para agrupar os termos de um operador. Isso é explicado em mais detalhes neste guia.
A implementação de group_terms_by_electronic_structure() pressupõe que os termos do Hamiltoniano estejam em ordem normal!
- PYTHON
- C
>>> from qiskit_fermions.operators.terms.grouping import group_terms_by_electronic_structure
>>> from qiskit_fermions.operators.terms.ordering import canonical_order
>>>
>>> canon = canonical_order(hamil.normal_ordered().simplify(atol=1e-16))
>>> exit_code = group_terms_by_electronic_structure(canon, num_modes, two_body_physicist_order=False)
>>> assert exit_code is None
>>> print(canon.groups) # the groups attribute now contains some list of group indices
[0, ...]
QfFermionOperator* normal = qf_ferm_op_normal_ordered(hamil, NULL);
QfFermionOperator* simplified = qf_ferm_op_simplify(normal, 1e-16);
QfFermionOperator* canon = qf_ferm_op_canonical_order(simplified);
QfExitCode exit = qf_ferm_op_group_terms_by_electronic_structure(canon, num_modes, false);
assert(exit == QfExitCode_Success);
qf_ferm_op_free(normal);
qf_ferm_op_free(simplified);
O Hamiltoniano completo de estrutura eletrônica contém certos termos cuja inclusão em um circuito de evolução temporal não tem impacto sobre as bitstrings percebidas e, portanto, só resulta em um aumento do custo de amostragem. Por isso, recomenda-se filtrar esses termos do Hamiltoniano neste ponto, antes de construir a porta Evolution no próximo passo.
Os termos que se encaixam nessa descrição são aqueles diagonais na base de números de ocupação, isto é, os produtos de operadores de número (). Isso inclui o deslocamento constante de energia, cuja evolução temporal só introduz uma fase global no circuito, os operadores de número individuais, cuja evolução temporal equivale a rotações Z de um único qubit, assim como produtos de ordem superior como . Nenhum deles pode alterar a ocupação de um modo, então descartá-los não custa nenhum conteúdo de excitação. Diferentemente da rejeição por sorteio do passo 5, essa filtragem acontece uma única vez, no próprio Hamiltoniano, de modo que a distribuição de coeficientes da qual o protocolo amostra é reconstruída de forma consistente a partir do operador filtrado, em vez de renormalizada no meio do sorteio.
A função filter_diagonal_terms() remove esses termos de um operador no local:
- PYTHON
- C
>>> from qiskit_fermions.operators.terms.filtering import filter_diagonal_terms
>>>
>>> filter_diagonal_terms(canon)
qf_ferm_op_filter_diagonal_terms(canon);
Filtrar aqui, uma única vez, é consideravelmente mais barato do que filtrar repetidamente. FermionicQDrift é executado uma vez por circuito transpilado, então filtrar o Hamiltoniano inicialmente, em vez de a cada chamada, evita refazer o trabalho para cada circuito gerado a partir dele.
Pelo mesmo motivo, vale a pena ordenar aqui os termos do Hamiltoniano por índice de grupo. Os índices de grupo são uma etiqueta por termo que não diz nada sobre onde esses termos estão, então os termos de um grupo geralmente ficam espalhados pelo operador: o agrupamento aplicado acima atribui índices na ordem do primeiro encontro, o que deixa o segundo termo de um grupo longe do primeiro.
FermionicQDrift amostra grupos inteiros e procura cada grupo sorteado com split_out_groups(). Encontrar um grupo entre termos espalhados significa varrer todos eles, então essa busca custa o mesmo se um grupo é sorteado ou quarenta. group_order() reúne cada grupo em uma sequência contígua, depois da qual a busca é uma busca binária sobre os limites dos grupos e custa o que custam os grupos sorteados, e não o que custam os termos mantidos. A própria busca fica dramaticamente mais barata; quanto isso economiza em uma dada transpilação depende do que mais o pipeline faz, já que o mapeamento e a síntese dominam quando a varredura some:
- PYTHON
- C
>>> from qiskit_fermions.operators.terms.ordering import group_order
>>>
>>> canon = group_order(canon)
>>> print(canon.groups[:4])
[0, 0, ...]
QfFermionOperator* grouped = qf_ferm_op_group_order(canon);
Isso reordena os termos sem alterar o valor do operador, e só precisa acontecer uma vez, não importa quantas aleatorizações de circuito sejam sorteadas a partir dele.
3. Prepare o circuito de evolução temporal
Prepare o circuito de evolução temporal do Hamiltoniano e o circuito base do qual sortear amostras. O módulo qiskit_fermions.circuit.library contém todos os componentes necessários para isso, em conformidade com as convenções do Qiskit.
O protocolo qDRIFT é o método de síntese FermionicQDrift, e a porta Evolution o carrega como sua synthesis. O método introduz os poucos parâmetros que você pode usar para personalizar os circuitos a gerar:
-
O comprimento de cada circuito em termos de grupos de excitação:
num_groups -
Opcionalmente, uma semente para o gerador de números aleatórios:
rng -
Opcionalmente, pesos de amostragem pré-calculados, se a própria transpilação virar um gargalo:
weights(veja (Opcional) Pré-calcule os pesos de amostragem abaixo)
Como o método é mantido pela porta, tudo o que descreve o operador da porta, como esses pesos, fica logo ao lado desse operador.
- PYTHON
- C
>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.circuit.library.synthesis import FermionicQDrift
>>>
>>> time = 1.0 # you can choose a desired scaling factor here
>>> num_groups = 10
>>> qdrift = FermionicQDrift(num_groups, rng=19)
>>> evo_gate = Evolution(num_modes, canon, time, synthesis=qdrift)
>>>
>>> circ = FermionicCircuit(num_modes)
>>> circ.append(evo_gate, circ.modes)
// WARNING: Qiskit's C API does not yet allow us to implement circuits
// with custom gate definitions.
Este exemplo não inicializa os modos fermiônicos com partículas nem mede seu estado final.
4. Transpile o circuito
O módulo qiskit_fermions.transpiler se integra diretamente ao pipeline de transpilação do Qiskit, permitindo que o FermionicCircuit construído acima seja transpilado diretamente para um QuantumCircuit.
Use o mapeamento férmion-para-qubit jordan_wigner() para converter o Hamiltoniano expresso em termos de férmions em strings de Pauli. Isso pode ser feito diretamente como parte do processo de transpilação usando a passagem de transpilação F2QSynthesis. Use generate_preset_jw_pass_manager() para construir o FermionicStagedPassManager, que garante que a codificação de Jordan-Wigner seja usada de forma consistente em todas as instruções do circuito.
Fundamentalmente, adicione a passagem Decompose do Qiskit ao estágio optimization do pipeline de transpilação. Ela decompõe a porta Evolution com o método de síntese que a porta carrega, que é o método qDRIFT preparado acima. Isso garante que o circuito não use a evolução temporal do Hamiltoniano inteiro, cuja profundidade excederia as capacidades do hardware de computação quântica atualmente disponível.
Em vez disso, ela subamostra um número fixo de groups de termos do Hamiltoniano para cada circuito, toda vez que o circuito é transpilado. Com isso, você pode gerar várias aleatorizações de circuito, como exigido pelo algoritmo SqDRIFT, executando repetidamente o pipeline de transpilação. O número de circuitos a gerar é num_sqdrift_randomizations.
- PYTHON
- C
>>> from qiskit.transpiler.passes import Decompose
>>> from qiskit_fermions.transpiler import FermionicPassManager
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> pm = generate_preset_jw_pass_manager()
>>> pm.optimization = FermionicPassManager([Decompose(["Evolution"])])
>>>
>>> num_sqdrift_randomizations = 10
>>> sqdrift_circuits = [
... pm.run(circ) for _ in range(num_sqdrift_randomizations)
... ]
// WARNING: Qiskit's C API does not yet allow us to implement circuits
// with custom gate definitions, which we therefore also cannot transpile
// via this API.
Os exemplos anteriores fixam a semente rng do método de síntese FermionicQDrift, para que a sequência de aleatorizações possa ser reproduzida.
O mesmo pipeline aceita o método qDRIFT em dois outros lugares. A passagem F2FSynthesis recebe um método para toda porta Evolution por nome, F2FSynthesis({"Evolution": FermionicQDrift(num_groups)}), o que é tudo de que você precisa quando nenhuma porta guarda nada específico de seu operador. O próximo passo usa o terceiro, a passagem QDriftTrotterization.
5. Filtre as excitações sorteadas por ocupação
Além dos termos diagonais filtrados no passo anterior, uma excitação sorteada pode atuar inteiramente dentro de um conjunto de modos cuja ocupação já está fixa (todos ocupados ou todos desocupados), onde ela não pode mover uma partícula de um lado para o outro. Uma excitação assim não diz nada sobre as bitstrings amostradas, então uma posição gasta com ela é uma posição desperdiçada. A passagem QDriftTrotterization, que envolve o mesmo método FermionicQDrift, rejeita essas excitações à medida que são sorteadas e sorteia uma substituta, de modo que cada uma das num_groups posições do circuito resultante carregue uma excitação que acopla os dois lados.
Essa filtragem não é gratuita, e por isso é uma passagem separada. O que ela conserva é o orçamento de num_groups posições amostradas, não a profundidade do circuito: como esse orçamento é fixo, uma excitação rejeitada é substituída em vez de descartada, e uma barata dá lugar a uma excitação de acoplamento que custa mais para sintetizar. Espere que um circuito filtrado seja mais profundo do que um não filtrado sorteado com a mesma semente.
Renormalizar o sorteio sobre as excitações aceitas muda a distribuição da qual o protocolo qDRIFT amostra e, diferentemente do agrupamento do passo 2, também quebra a garantia de convergência do protocolo: o produto amostrado deixa de ter como média a evolução sob o Hamiltoniano que você passou. Toda excitação retida acaba ponderada pelo inverso da probabilidade de aceitação, as rejeitadas não contribuem com nada, e nem a magnitude total dos coeficientes nem o tempo de evolução por porta é ajustado para compensar.
A regra de aceitação também é uma superaproximação. Ela compara apenas os suportes dos modos, então não carrega nenhuma informação de amplitude, e os conjuntos rastreados só crescem: uma excitação confinada ao conjunto ocupado é genuinamente inerte em um estado produto, mas o mesmo raciocínio enfraquece para sorteios posteriores, quando excitações aceitas anteriormente já deixaram o estado emaranhado e marcaram a maioria dos modos como “incertos”.
Use a filtragem para o fluxo de trabalho de amostragem de bitstrings que este guia descreve, em que os circuitos alimentam o pós-processamento do SQD e só as bitstrings amostradas importam. Não a use se pretende estimar um valor esperado a partir desses circuitos, ou depender do limite de erro do qDRIFT de qualquer outra forma: esses resultados carregam um viés que a passagem não corrige.
Essa filtragem precisa saber quais modos começam ocupados. Por isso, ela exige uma porta InitializeModes antes das portas Evolution no circuito; InitializeModes.from_hartree_fock() é uma forma conveniente de construir uma. Adicione uma aqui para a referência Hartree-Fock do N2 (sete elétrons alfa e sete beta em 14 orbitais espaciais) e compare as excitações amostradas com e sem a filtragem:
- PYTHON
- C
>>> from qiskit_fermions.circuit.library import InitializeModes
>>> from qiskit_fermions.transpiler.passes import F2FSynthesis, QDriftTrotterization
>>>
>>> init = InitializeModes.from_hartree_fock(fcidump.norb, (7, 7))
>>>
>>> hf_circ = FermionicCircuit(num_modes)
>>> hf_circ.append(init, hf_circ.modes)
>>> hf_circ.append(Evolution(num_modes, canon, time), hf_circ.modes)
>>>
>>> num_groups = 5
>>> qdrift_unfiltered = F2FSynthesis(
... {"Evolution": FermionicQDrift(num_groups, rng=3480)}
... )
>>> qdrift_trivial = QDriftTrotterization(
... FermionicQDrift(num_groups, rng=3480)
... )
>>>
>>> for instruction in FermionicPassManager(qdrift_unfiltered).run(hf_circ)._inner.data:
... if instruction.operation.name == "Evolution":
... print(sorted(instruction.operation.operator.get_support()))
[2, 4]
[41, 45, 52]
[15, 45, 55]
[41, 52, 53]
[10, 16, 37, 38]
>>>
>>> for instruction in FermionicPassManager(qdrift_trivial).run(hf_circ)._inner.data:
... if instruction.operation.name == "Evolution":
... print(sorted(instruction.operation.operator.get_support()))
[0, 1, 6, 7]
[0, 1, 28, 29]
[4, 13, 55]
[13, 20, 40, 41]
[0, 1, 28, 29]
// WARNING: Qiskit's C API does not yet allow us to implement circuits
// with custom gate definitions, which we therefore also cannot transpile
// via this API.
Nenhuma das excitações sorteadas sem filtragem toca o conjunto ocupado (0-6 e 28-34), então nenhuma delas pode mover uma partícula entre um modo ocupado e um desocupado. Com a filtragem, todas as cinco são rejeitadas e substituídas por excitações que de fato acoplam um modo ocupado a um desocupado. Por exemplo, a primeira excitação aceita [0, 1, 6, 7] move uma partícula entre os modos ocupados 0, 1 e 6 e o modo desocupado 7.
Quando uma excitação é aceita, todo modo em seu suporte se torna “incerto” e, portanto, elegível para assumir qualquer um dos papéis nas amostras seguintes, de modo que os conjuntos de modos ocupados e desocupados continuam crescendo à medida que mais excitações são aceitas. É isso que torna aceitável a segunda excitação, [0, 1, 28, 29]. Os quatro modos dela estão entre os originalmente ocupados, então ela não se acopla a nenhum modo originalmente desocupado. Ela só é aceita porque os modos 0 e 1 se tornaram incertos (e, portanto, elegíveis como o lado “desocupado” do acoplamento) quando a primeira excitação os tocou.
Esse crescimento é o que faz da regra uma superaproximação em vez de um teste: à medida que mais modos se tornam incertos, menos sorteios são rejeitados, e a filtragem atua mais sobre a distribuição de amostragem nos primeiros sorteios.
Para avaliar o quanto a filtragem moveu a distribuição, leia as contagens de rejeição que a passagem registra nos metadados do circuito. Uma entrada é armazenada por porta Evolution filtrada, e a razão entre aceitas e total estima a probabilidade de aceitação, que é o fator pelo qual as excitações retidas foram superponderadas:
- PYTHON
- C
>>> # a freshly seeded pass, so the counts do not depend on earlier draws
>>> qdrift_counted = QDriftTrotterization(
... FermionicQDrift(num_groups, rng=3480)
... )
>>> filtered = FermionicPassManager(qdrift_counted).run(hf_circ)
>>> discarded = filtered.metadata.get("filter_trivial.discarded")
>>> emitted = filtered.metadata.get("filter_trivial.emitted")
>>> print(f"{emitted[0]} kept, {discarded[0]} discarded")
5 kept, 7 discarded
>>> print(f"acceptance probability: {emitted[0] / (emitted[0] + discarded[0]):.2f}")
acceptance probability: 0.42
// WARNING: Qiskit's C API does not yet allow us to implement circuits
// with custom gate definitions, which we therefore also cannot transpile
// via this API.
A filtragem precisa de informação de ocupação contra a qual filtrar. Uma porta PrepareSlaterDeterminant a fornece da mesma forma que InitializeModes, e qualquer OrbitalRotation marca como “incerto” todo modo em que atua. Sem nenhuma delas, ou quando os modos inicializados caem todos de um lado só (todo modo ocupado, ou todo modo desocupado), a passagem emite um UserWarning e deixa a amostragem sem filtragem para aquela porta Evolution. Em um Hamiltoniano cujos termos restantes não conseguem mais acoplar os dois conjuntos rastreados, a passagem, em vez disso, levanta RuntimeError após QDriftTrotterization.MAX_SAMPLE_RETRIES rejeições consecutivas.
(Opcional) Pré-calcule os pesos de amostragem
Recorra a isto apenas se a própria transpilação for um gargalo. Isso não muda nada nos circuitos que você obtém.
FermionicQDrift sorteia cada grupo com probabilidade proporcional à magnitude de seu coeficiente e deriva essas magnitudes do Hamiltoniano a cada chamada: um valor por termo do Hamiltoniano, reduzido a um por grupo. Como o método não tem estado, ele repete essa redução a cada aleatorização, mesmo que o resultado seja idêntico todas as vezes. O argumento weights permite calculá-la uma vez com group_coeff_means() e entregar o resultado, tirando o custo do laço e mantendo o método livre de qualquer estado em cache dependente do Hamiltoniano.
Este é o complemento da ordenação de termos do passo 2. Aquela tira a busca de grupos do laço, esta tira a redução de pesos, e cada uma só é tão perceptível quanto a outra está ausente, então vale aplicar as duas juntas. Como canon já foi reordenado acima, as duas se compõem diretamente.
Os pesos descrevem os grupos de um operador em particular, então pertencem à porta Evolution desse operador: construa o método para essa porta e entregue-o como a synthesis da porta, exatamente como no passo 3.
- PYTHON
- C
>>> from qiskit_fermions.operators.terms.grouping import group_coeff_means
>>>
>>> weights = group_coeff_means(canon) # `canon` is group-ordered, from step 2
>>> qdrift_weighted = FermionicQDrift(10, rng=19, weights=weights)
>>> weighted_gate = Evolution(num_modes, canon, time, synthesis=qdrift_weighted)
>>>
>>> weighted_circ = FermionicCircuit(num_modes)
>>> weighted_circ.append(weighted_gate, weighted_circ.modes)
>>>
>>> weighted_circuits = [
... pm.run(weighted_circ) for _ in range(num_sqdrift_randomizations)
... ]
>>> len(weighted_circuits)
10
// WARNING: Qiskit's C API does not yet allow us to implement circuits
// with custom gate definitions, which we therefore also cannot transpile
// via this API.
Como esses são exatamente os pesos que a passagem teria calculado, os circuitos são os mesmos de antes; só a redução repetida desapareceu.
Espera-se uma entrada por grupo, não por termo, sempre que o operador carrega grupos, já que um operador agrupado é amostrado por grupos. As entradas devem ser não negativas: um peso é uma magnitude, porque o protocolo qDRIFT decompõe com positivos, e o sinal de um coeficiente pertence a , de onde a passagem o lê diretamente do operador.
Os pesos são magnitudes absolutas, não preferências relativas. A soma deles também fixa o tempo de evolução, de modo que multiplicar cada entrada por amostra de forma idêntica, mas evolui por em vez de . Passar qualquer coisa que não sejam as próprias magnitudes dos coeficientes do Hamiltoniano muda, portanto, qual evolução o conjunto aproxima, e é por isso que esta é uma ferramenta de desempenho e não uma forma de ajustar a amostragem.
Como os pesos pertencem à porta cujo operador descrevem, um circuito pode conter várias portas Evolution, cada uma com os pesos de seu próprio operador. A passagem QDriftTrotterization do passo 5 os respeita da mesma forma: ela sorteia uma porta com o FermionicQDrift que a porta carrega e recorre àquele com o qual foi construída para as portas que não carregam nenhum. Outro tipo de método de síntese em uma porta não é uma declaração sobre o qDRIFT, então a passagem o ignora. Dar o método a uma passagem por nome de porta, como em F2FSynthesis({"Evolution": ...}), aplica um único método a toda porta Evolution, então pesos passados ali só fazem sentido para um circuito com uma única. O comprimento dos pesos é verificado em relação a cada porta à qual são aplicados.
(Opcional) Otimize a indexação dos modos fermiônicos
Você pode adicionar uma etapa de otimização extra ao pipeline de transpilação que minimiza a distância dos suportes de excitação fermiônica ao reetiquetar os índices dos modos fermiônicos. Essa otimização foi introduzida no artigo do SqDRIFT e é implementada por build_excitation_span_minimization_model(). Ela pode ser inserida facilmente no pipeline do transpilador usando a passagem RelabelModes:
- PYTHON
- C
>>> from pyomo.environ import SolverFactory
>>> from qiskit_fermions.transpiler.passes import RelabelModes
>>>
>>> solver = SolverFactory("appsi_highs")
>>> solver.options["time_limit"] = 10
>>>
>>> qdrift = F2FSynthesis({"Evolution": FermionicQDrift(5, rng=19)})
>>> relabel = RelabelModes(solver=solver)
>>>
>>> pm.optimization = FermionicPassManager([qdrift, relabel])
>>>
>>> relabeled_circ = pm.run(circ)
>>> # if the automatic mode relabeling was successful, the circuit's
>>> # metadata will contain the mode `permutation` information
// WARNING: This feature is not available via the C API.
Usar a otimização automática dentro de RelabelModes (que utiliza build_excitation_span_minimization_model()) requer a dependência opcional gerenciada por HAS_PYOMO.
O reetiquetamento muda qual modo um dado qubit carrega, então a diagonalização de subespaço só obtém as bitstrings corretas se isso for levado em conta. Onde essa contabilização acontece depende de onde você mede.
Adicione as medições ao FermionicCircuit com FermionicCircuit.measure_all() antes de transpilar, e não há nada a fazer: cada medição nomeia o modo que lê, então o reetiquetamento a leva junto e as bitstrings amostradas são indexadas pelo modo original.
Se, em vez disso, você medir o QuantumCircuit transpilado, as bitstrings precisam ser pós-processadas usando a permutation registrada nos metadados do circuito. Consulte a documentação de RelabelModes para ver a receita.
Próximos passos
Agora que você gerou com sucesso um conjunto de circuitos, pode amostrar bitstrings a partir deles. Para isso, os circuitos precisam ser executados em hardware. Consulte a documentação do Qiskit para instruções detalhadas.
Depois de obter as amostras de bitstrings, elas podem ser usadas em combinação com os coeficientes do Hamiltoniano para realizar o pós-processamento do SQD, como explicado nos tutoriais do addon SQD.