Sintetizzare un gate Evolution nello spazio fermionico
I concetti di questa guida sono attualmente disponibili solo nell'API Python. Una funzionalità equivalente sarà resa disponibile nell'API C in una versione futura.
Un gate Evolution porta l'evoluzione temporale di un intero operatore. Una sintesi da fermioni a fermioni riscrive quel gate in un FermionicCircuit di gate fermionici più piccoli. Entrambi i lati della riscrittura restano nello spazio fermionico, ed è ciò che la distingue dalla sintesi da fermioni a qubit che segue: i groups dell'operatore (vedi Raggruppare i termini dell'operatore: usare la struttura dell'operatore) la sopravvivono, mentre una mappatura da fermioni a qubit restituisce un SparseObservable, che non ha alcun concetto di raggruppamento.
Due manopole selezionano il metodo. Evolution.synthesis lo imposta su un singolo gate, e il passo di Transpiler F2FSynthesis lo imposta su un intero circuito. Entrambe accettano un F2FSynthesisPlugin, l'interfaccia che ogni riscrittura di questo tipo implementa. I metodi che questo pacchetto fornisce attualmente per un'evoluzione sono formule di prodotto, che è ciò che usa il resto di questa guida: FermionicLieTrotter per una formula del primo ordine (quella predefinita) e FermionicSuzukiTrotter per un ordine pari più alto. Qualsiasi altra cosa che implementi l'interfaccia si seleziona allo stesso modo, e le sezioni sulle due manopole si applicano ad essa senza modifiche.
Questo passo da fermioni a fermioni è facoltativo. Un gate Evolution può passare direttamente alla fase da fermioni a qubit, che lo mappa e lo sintetizza per intero indipendentemente dal numero di termini che contiene. Suddividerlo prima è una scelta, motivata dal fatto che i gate risultanti sono singolarmente meno costosi oppure dal fatto che la suddivisione espone una struttura che una fase successiva può usare.
I due passi si compongono, quindi un'evoluzione può risultare approssimata a uno dei due livelli, a entrambi o a nessuno. Una formula di prodotto è un'approssimazione a meno che i suoi fattori non commutino; un metodo di sintesi non deve necessariamente approssimare affatto.
Saltare questo passo non significa necessariamente perdere del tutto il raggruppamento. Avvolgere il mapper in group_wise() mappa l'operatore un gruppo alla volta e somma i risultati, così i termini di Pauli mappati arrivano raggruppati anziché intercalati. Una formula di prodotto lato qubit li sintetizza nell'ordine in cui li riceve, quindi i termini commutanti che finiscono adiacenti possono essere schedulati insieme.
Sull'Hamiltoniano qui sotto questo recupera l'intero vantaggio in profondità a due qubit della suddivisione: 18 invece di 30, con lo stesso numero di gate. La differenza sta in dove la struttura viene espressa. Suddividere nello spazio fermionico rende ogni gruppo un gate separato, che ogni fase successiva può vedere e su cui può agire; group_wise lascia un solo gate e riordina soltanto i termini al suo interno, il che basta per lo scheduling ma non lascia nulla su cui un passo fermionico possa lavorare. Usalo quando l'evoluzione deve restare un singolo gate, e ricorri a una formula di prodotto fermionica negli altri casi.
Costruire un Hamiltoniano raggruppato
Entrambe le formule di prodotto dividono un operatore in fattori, e per un operatore raggruppato quei fattori sono i suoi gruppi. Il raggruppamento determina quindi sia il costo sia l'accuratezza del risultato. Prendi una catena di Fermi-Hubbard a sei siti, raggruppata secondo la commutazione dei suoi termini: i termini di hopping sui legami pari, i termini di hopping sui legami dispari e l'interazione diagonale.
>>> from qiskit_fermions.operators import FermionOperator, cre, ann
>>>
>>> def fermi_hubbard_chain(num_sites, tunneling, interaction):
... """A 1D Fermi-Hubbard chain grouped into even bonds, odd bonds, and the interaction."""
... terms, groups = [], []
... for offset in (0, 1):
... for j in range(offset, num_sites - 1, 2):
... terms += [
... ([cre(j), ann(j + 1)], -tunneling),
... ([cre(j + 1), ann(j)], -tunneling),
... ]
... groups += [offset, offset]
... for j in range(num_sites - 1):
... terms.append(([cre(j), ann(j), cre(j + 1), ann(j + 1)], interaction))
... groups.append(2)
... operator = FermionOperator.from_terms(terms)
... operator.groups = groups
... return operator
>>>
>>> num_sites = 6
>>> hamiltonian = fermi_hubbard_chain(num_sites, tunneling=1.0, interaction=2.0)
>>> hamiltonian.num_groups()
3
Quindici termini in tre gruppi. Ogni gruppo di hopping accoppia ogni termine con il suo partner coniugato, il che è importante: un fattore deve essere Hermitiano perché il suo esponenziale sia unitario, e ciò non segue dal fatto che la somma sia Hermitiana. groups_are_hermitian() verifica questa convenzione anziché lasciarla all'ispezione:
>>> from qiskit_fermions.operators.terms.grouping import groups_are_hermitian
>>>
>>> groups_are_hermitian(hamiltonian)
[True, True, True]
Separare dal suo partner la violerebbe, e lasciare l'operatore non raggruppato ha lo stesso effetto, poiché in quel caso entrambe le formule lo dividono termine per termine e un singolo non è Hermitiano:
>>> ungrouped = fermi_hubbard_chain(num_sites, tunneling=1.0, interaction=2.0)
>>> ungrouped.groups = range(len(list(ungrouped.iter_terms()))) # one group per term
>>> all(groups_are_hermitian(ungrouped))
False
Un fattore del genere è un operatore fermionico valido, e questo pacchetto costruirà il gate per esso, quindi nulla segnala problemi finché l'operatore mappato non raggiunge PauliEvolutionGate, che richiede coefficienti reali e solleva ValueError: Operator contains complex coefficients, which are not supported. Verificare il raggruppamento in anticipo trasforma quel fallimento tardivo in una risposta disponibile prima che venga costruito qualsiasi circuito.
Impostare il metodo su un singolo gate
Passa un metodo di sintesi al gate, poi scomponilo. Ogni gate che emette torna come un proprio gate Evolution:
>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.circuit.library.synthesis import (
... FermionicLieTrotter,
... FermionicSuzukiTrotter,
... )
>>>
>>> total_time = 1.0
>>>
>>> def factors(synthesis):
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(num_sites, hamiltonian, time=total_time, synthesis=synthesis),
... circuit.modes,
... )
... return circuit.decompose().count_ops()["Evolution"]
>>>
>>> factors(FermionicLieTrotter())
3
>>> factors(FermionicSuzukiTrotter(order=2))
5
>>> factors(FermionicSuzukiTrotter(order=2, reps=4))
17
>>> factors(FermionicSuzukiTrotter(order=2, reps=4, merge_adjacent=False))
20
Il primo ordine emette un fattore per gruppo. Il secondo ordine emette il palindromo simmetrico, quindi i due gruppi esterni compaiono due volte con metà del tempo ciascuno, per un totale di cinque. Quattro ripetizioni di quello ne darebbero venti, ma ogni ripetizione termina con lo stesso gruppo con cui inizia la successiva. Quei gate adiacenti evolvono sotto lo stesso operatore, quindi vengono fusi in un unico gate con il tempo sommato, risparmiando un gate per ogni giunzione. Passa merge_adjacent=False per mantenere la sequenza da manuale. Ogni ordine divide l'operatore allo stesso modo; ciò che cambia è l'ordinamento dei fattori e i loro tempi di evoluzione, non la partizione.
I fattori sono contrassegnati come atomic, quindi scomporre di nuovo li lascia al loro posto anziché suddividerli ulteriormente. La scomposizione ha un punto fisso, ed è ciò che rende sicuro chiamare decompose() più volte.
Impostare il metodo per un'intera pipeline
Impostare synthesis per ogni gate significa propagare la scelta a tutto ciò che ne costruisce uno, compresi UCC e UCJ, che costruiscono i loro gate internamente. Il passo F2FSynthesis applica invece un solo metodo a ogni Evolution di un circuito, così la scelta viene fatta una volta per l'intera pipeline. Si configura per nome del gate, nello stesso modo di F2QSynthesis, e un gate non nominato viene lasciato com'è:
>>> from qiskit_fermions.transpiler import FermionicPassManager
>>> from qiskit_fermions.transpiler.passes import F2FSynthesis
>>>
>>> circuit = FermionicCircuit(num_sites)
>>> circuit.append(Evolution(num_sites, hamiltonian, time=total_time), circuit.modes)
>>>
>>> expanded = FermionicPassManager(
... F2FSynthesis({"Evolution": ("SuzukiTrotter", (), {"order": 2, "reps": 4})})
... ).run(circuit)
>>> expanded.count_ops()["Evolution"]
17
Questo è lo stesso conteggio che decompose() fornisce per quel metodo sopra. Il passo MergeEvolutions applica la stessa fusione ai gate adiacenti che non provengono da un'unica sintesi, come le giunzioni tra circuiti consecutivi. Il passo esegue la formula da sé, quindi non serve alcun passo di espansione separato, ed eseguirlo due volte non cambia nulla, poiché i fattori che emette sono atomic e quindi non hanno una definizione propria.
Passa apply=False per selezionare soltanto il metodo e lasciare l'espansione a qualcos'altro, come il Decompose di Qiskit (indicare il gate come Decompose("Evolution") lo limita alle evoluzioni, lasciando gli altri gate fermionici ai loro plugin di sintesi).
Questo è utile per scegliere in anticipo il metodo per un intero circuito rimandando l'espansione al momento in cui serve, ad esempio per ispezionare prima il circuito:
>>> selected = FermionicPassManager(
... F2FSynthesis({"Evolution": ("SuzukiTrotter", (), {"order": 2})}, apply=False)
... ).run(circuit)
>>> selected.count_ops()["Evolution"] # selected, not yet expanded
1
>>> selected.decompose().count_ops()["Evolution"] # expanded on demand
5
Sii però consapevole di ciò che fa un gate non espanso: raggiunge la fase da fermioni a qubit per intero, dove viene mappato senza consultare synthesis. Questo favorisce un errore facile quando si confrontano i metodi, perché ognuno di essi produce allora un output identico e il confronto non misura silenziosamente nulla. Sull'Hamiltoniano qui sopra, lasciare non espanso il gate del secondo ordine riduce il circuito molto più grande qui sotto al costo di una singola evoluzione mappata, indipendentemente dal metodo selezionato.
Misurare il vantaggio
Un ordine più alto costa profondità, quindi la domanda non è se sia più accurato ma se sia più accurato per gate. Servono due numeri per metodo: l'errore dello stato che produce e il costo a due qubit del circuito che lo ha prodotto.
L'errore viene misurato con ffsim, che simula un FermionicCircuit direttamente (vedi Come questo pacchetto si rapporta a ffsim). Non interviene alcuna mappatura da fermioni a qubit, quindi il confronto isola la formula di prodotto fermionica da qualsiasi contributo della mappatura o della sintesi lato qubit. Funziona inoltre nel settore a numero di particelle fisso, che è sia più piccolo dello spazio completo sia lo spazio in cui l'evoluzione effettivamente rimane.
>>> import ffsim
>>> import numpy as np
>>> from scipy.sparse.linalg import expm_multiply
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> norb, nelec = num_sites, (3, 0) # three spinless fermions on six modes
>>> initial = ffsim.hartree_fock_state(norb, nelec)
>>>
>>> hamiltonian_operator = ffsim.linear_operator(hamiltonian, norb=norb, nelec=nelec)
>>> exact = expm_multiply(
... -1j * total_time * hamiltonian_operator,
... initial,
... traceA=-1j * total_time * ffsim.trace(hamiltonian, norb=norb, nelec=nelec),
... )
>>>
>>> def cost_and_error(synthesis):
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(num_sites, hamiltonian, time=total_time, synthesis=synthesis),
... circuit.modes,
... )
... # the method is set on the gate, so decompose() is what expands it here; a pipeline
... # would reach for F2FSynthesis instead (see the note above)
... circuit = circuit.decompose()
... evolved = ffsim.apply_unitary(initial, circuit, norb=norb, nelec=nelec)
... error = 1 - abs(np.vdot(evolved, exact)) ** 2
... qubit_circuit = generate_preset_jw_pass_manager().run(circuit).decompose(reps=6)
... two_qubit = lambda instruction: len(instruction.qubits) == 2
... return error, qubit_circuit.count_ops()["cx"], qubit_circuit.depth(two_qubit)
>>>
>>> methods = [
... ("order 1, reps 1 ", FermionicLieTrotter()),
... ("order 2, reps 1 ", FermionicSuzukiTrotter(order=2)),
... ("order 1, reps 10", FermionicLieTrotter(reps=10)),
... ("order 2, reps 4 ", FermionicSuzukiTrotter(order=2, reps=4)),
... ]
>>> costs = {label: cost_and_error(synthesis) for label, synthesis in methods}
>>> labels, counts, depths = [], [], []
>>> for label, (error, gates, depth) in costs.items():
... labels.append(label.strip().replace(", ", "\n"))
... counts.append(gates)
... depths.append(depth)
... print(f"{label} error {error:.6f}")
order 1, reps 1 error 0.515997
order 2, reps 1 error 0.180958
order 1, reps 10 error 0.004026
order 2, reps 4 error 0.000421
Il costo a due qubit è volutamente escluso da quella tabella. Dipende dalla sintesi lato qubit che Qiskit seleziona, la quale migliora da una versione all'altra, quindi i numeri stampati qui sarebbero un'istantanea e non una proprietà della formula di prodotto. La figura qui sotto li rappresenta invece graficamente, e ciò su cui si basa il confronto viene verificato come relazione:
Leggi insieme gli ultimi due metodi, perché sono il confronto che conta. L'ordine due con quattro ripetizioni è circa dieci volte più accurato dell'ordine uno con dieci, e ci arriva con meno gate entangling anziché più, come mostra la figura. Spendere un budget di gate su un ordine più alto è qui preferibile a spenderlo su più passi del primo ordine.
Quel risultato non è universale. Aumentare reps e aumentare order riducono l'errore a ritmi diversi per lo stesso tipo di costo, e quale vinca dipende dall'operatore e dal tempo evoluto. Le due righe qui sopra sono il modo per scoprirlo per un dato problema.
La formula fermionica si compone con quella scelta per la fase da fermioni a qubit (vedi MapperFnEvolutionSynthesis.product_formula). Dove entrambe approssimano, l'accuratezza del risultato è determinata dalla più debole delle due, quindi aumentare l'ordine qui mentre la formula lato qubit resta del primo ordine porta pochi benefici.
Limitare il passo ai gate che ne traggono beneficio
Un ordine più alto aiuta solo quando i fattori non commutano. Se commutano tutti, la formula del primo ordine è già esatta e i fattori in più sono puro overhead. Un operatore puramente diagonale lo rende concreto, poiché gli operatori numero commutano tra loro:
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> diagonal = FermionOperator.from_terms(
... [([cre(j), ann(j), cre(j + 1), ann(j + 1)], 1.0 + 0.1 * j) for j in range(num_sites - 1)]
... )
>>> diagonal.groups = list(range(num_sites - 1))
>>>
>>> def diagonal_cost(synthesis):
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(num_sites, diagonal, time=total_time, synthesis=synthesis), circuit.modes
... )
... transpiled = generate_preset_jw_pass_manager().run(circuit.decompose())
... return transpiled.decompose(reps=6).count_ops()["cx"]
>>>
>>> diagonal_cost(FermionicLieTrotter())
10
>>> diagonal_cost(FermionicSuzukiTrotter(order=2))
18
Entrambi i circuiti sono esatti, quindi il secondo spende l'80% in più di gate entangling per nulla. Gli operatori di Coulomb diagonali di un UCJ hanno questa forma, ed è per questo che esiste F2FSynthesis.filter: accetta un predicato sul DAGOpNode e lascia intatti i gate rifiutati, così un solo passo può aumentare l'ordine sui gate che ne traggono beneficio e lasciare gli altri al primo ordine.
Un circuito che contiene entrambi i tipi di evoluzione mostra quanto si risparmia. Il predicato qui sotto accetta un gate solo quando il suo operatore sposta particelle tra i modi, ovvero esattamente quando i gruppi non commutano e un ordine più alto ha qualcosa da offrire:
>>> def is_diagonal(operator):
... """Whether every term only counts particles, rather than moving them between modes."""
... return all(
... sorted(mode for created, mode in actions if created)
... == sorted(mode for created, mode in actions if not created)
... for actions, _ in operator.iter_terms()
... )
>>>
>>> def mixed_circuit():
... circuit = FermionicCircuit(num_sites)
... circuit.append(Evolution(num_sites, hamiltonian, time=total_time), circuit.modes)
... circuit.append(Evolution(num_sites, diagonal, time=total_time), circuit.modes)
... return circuit
>>>
>>> def two_qubit_cost(circuit):
... transpiled = generate_preset_jw_pass_manager().run(circuit).decompose(reps=6)
... two_qubit = lambda instruction: len(instruction.qubits) == 2
... return transpiled.count_ops()["cx"], transpiled.depth(two_qubit)
>>>
>>> order_2 = FermionicSuzukiTrotter(order=2)
>>>
>>> everywhere = two_qubit_cost(
... FermionicPassManager(F2FSynthesis({"Evolution": order_2})).run(mixed_circuit())
... )
>>> restricted = two_qubit_cost(
... FermionicPassManager(
... F2FSynthesis(
... {"Evolution": order_2}, filter=lambda node: not is_diagonal(node.op.operator)
... )
... ).run(mixed_circuit())
... )
>>> everywhere[0] - restricted[0] # the gates the filter saved
8
>>> restricted[0] < everywhere[0] and restricted[1] < everywhere[1]
True
I gate che il filtro risparmia sono esattamente quelli che l'ordine due ha speso sull'evoluzione diagonale, che il confronto diagonale qui sopra quantifica in otto (18 contro 10). Lì non hanno apportato nulla, perché quell'evoluzione è già esatta al primo ordine, quindi eliminarli non costa alcuna accuratezza.
Confronta i due circuiti del secondo ordine tra loro, non con quello non espanso. Lasciare entrambi i gate non espansi invia ciascuno alla fase da fermioni a qubit per intero, che è l'errore descritto dalla nota su apply=False qui sopra: può arrivare allo stesso numero di gate entangling del circuito filtrato pur essendo un circuito diverso a una profondità diversa, quindi leggere il beneficio del filtro da quel numero non misura nulla.
Nessuna delle due manopole verifica che i fattori siano Hermitiani, e un operatore raggruppato in modo errato può sembrare migliore a un ordine pari che al primo ordine pur essendo sbagliato, perché la simmetrizzazione cancella in parte l'errore. Raggruppa insieme i partner coniugati, come fa fermi_hubbard_chain qui sopra.
Passaggi successivi
-
Leggi Raggruppare i termini dell'operatore: usare la struttura dell'operatore per sapere come vengono assegnati i gruppi su cui si dividono queste formule di prodotto.
-
Leggi Transpilazione dei circuiti fermionici per le fasi che un
FermionicCircuitattraversa. -
Vedi Simulare la dinamica di Fermi-Hubbard 1D con i flow set per un caso concreto in cui la formula fermionica fornisce il numero di passi che una sintesi lato qubit personalizzata non può fornire.
-
Consulta
synthesisper i metodi di sintesi stessi, eFermionicQDriftper un'alternativa randomizzata.