Vai al contenuto principale
Questo progetto è in fase beta. Hai trovato un bug? Apri una issue su GitHub. Hai una domanda o un'idea? Chiedi su GitHub Discussions — apprezziamo il tuo feedback!
Com'è la qualità della traduzione su questa pagina?

Mappare le rappresentazioni degli operatori

I mapper sono routine che trasformano gli operatori da una rappresentazione a un'altra preservando l'equivalenza matematica. Usali per lavorare con flessibilità su algebre di operatori diverse e per preparare gli operatori all'esecuzione su circuiti quantistici.

Implementare mapper personalizzati​

Il modulo dei mapper rende semplice implementare mappature personalizzate. I mapper funzionano trasformando le azioni fondamentali (creazione, annichilazione, Majorana e così via) che compongono un operatore, e poi combinando queste azioni trasformate secondo le regole della rappresentazione di destinazione.

Come esempio concreto, considera una trasformazione di Jordan-Wigner che mappa un MajoranaOperator in un operatore di qubit usando map_majorana_action_generators():

>>> from qiskit.quantum_info import SparseObservable
>>> from qiskit_fermions.mappers import map_majorana_action_generators
>>> from qiskit_fermions.operators import MajoranaOperator
>>>
>>> # Create a Majorana operator
>>> maj_op = MajoranaOperator.from_dict({(0, 1): 1.0, (2,): 0.5})
>>>
>>> # Define how each Majorana action maps to Pauli strings
>>> num_qubits = 2
>>> def map_action(mode: int) -> SparseObservable:
... idx = mode // 2
... qubits = list(range(idx + 1))
... pauli = "Y" if mode % 2 else "X"
... return SparseObservable.from_sparse_list(
... [("Z" * idx + pauli, qubits, 1.0)],
... num_qubits=num_qubits,
... )
>>>
>>> # Apply the mapping to transform the operator
>>> custom_observable = map_majorana_action_generators(
... maj_op,
... map_action,
... identity=lambda: SparseObservable.identity(num_qubits),
... compose=SparseObservable.compose,
... )
>>> print(custom_observable.simplify())
<SparseObservable with 2 terms on 2 qubits: (0+1j)(Z_0) + (0.5+0j)(X_1 Z_0)>

I passaggi chiave per implementare qualsiasi mapper personalizzato sono:

  1. Definisci la trasformazione delle azioni: specifica come ogni azione (indicizzata per modo) viene mappata nella rappresentazione di destinazione. Nell'esempio precedente, le azioni di Majorana vengono mappate in stringhe di Pauli secondo le regole di Jordan-Wigner.

  2. Usa una delle funzioni di mappatura fornite: il modulo mappers fornisce funzioni di utilità per gestire l'iterazione sui termini dell'operatore e le successive applicazioni della mappa di azioni personalizzata per le rappresentazioni di operatori fornite dal modulo operators. Ad esempio, map_majorana_action_generators() funziona con gli operatori di Majorana.

  3. Ottieni il risultato: la funzione di mappatura restituisce un operatore nella rappresentazione di destinazione con la trasformazione applicata in modo coerente a tutti i termini.

Questo design rende facile prototipare mappature personalizzate senza una conoscenza approfondita delle strutture interne degli operatori. La trasformazione viene applicata automaticamente a ogni termine dell'operatore, preservando l'equivalenza matematica.

Suggerimento

Puoi anche iterare manualmente sui termini di un operatore invece di usare una delle funzioni iteratrici fornite. Per i dettagli, consulta la sezione sull'iterazione dei termini nella guida agli operatori.

Implementazioni della libreria​

Il modulo qiskit_fermions.mappers.library fornisce implementazioni efficienti e accuratamente testate delle mappature più comuni. Seguono lo stesso schema di fondo dei mapper personalizzati, ma sono ottimizzate per l'uso in produzione e sono disponibili sia nell'API Python sia nell'API C.

L'esempio seguente applica un mapper della libreria allo stesso MajoranaOperator della sezione sui mapper personalizzati, mostrando risultati equivalenti:

>>> from qiskit_fermions.mappers.library import majorana_to_fermion, jordan_wigner
>>>
>>> # Convert MajoranaOperator to FermionOperator using library mapper
>>> ferm_op = majorana_to_fermion(maj_op)
>>>
>>> # Apply Jordan-Wigner mapper to get qubit operator
>>> sparse_observable = jordan_wigner(ferm_op, num_qubits=2)
>>> difference = sparse_observable - custom_observable
>>> print(difference.simplify() == SparseObservable.zero(num_qubits))
True
Nota

I due passaggi precedenti sono scritti esplicitamente per mostrare la composizione di due mapper della libreria. In pratica mapperesti invece un MajoranaOperator in un solo passaggio, con majorana_jordan_wigner() (qf_maj_op_jordan_wigner() nell'API C), e lo stesso vale per gli altri tipi di operatore: edge_vertex_jordan_wigner() e transfer_vertex_jordan_wigner() (qf_edge_op_jordan_wigner(), qf_transfer_op_jordan_wigner()). Passare attraverso un FermionOperator gonfia inoltre il risultato intermedio: ogni generatore di queste algebre si mappa su una singola stringa di Pauli, mentre ogni azione fermionica si mappa su una somma di due termini, quindi un termine costruito da più generatori si espande solo per essere poi nuovamente ridotto. Questo risparmio cresce con la lunghezza dei termini; per i termini a singolo generatore di un hamiltoniano di hopping i due percorsi costano più o meno lo stesso.

Nell'API Python, alcuni di questi mapper della libreria sono raggiungibili anche tramite mapper di comodo indipendenti dal tipo. fermion_operator() e majorana_operator() si pongono davanti a majorana_to_fermion() e fermion_to_majorana() (tra gli altri), delegando internamente all'implementazione di libreria appropriata in base al tipo di oggetto ricevuto, invece di richiederti di scegliere tu la funzione giusta. Lo stesso vale per jordan_wigner() e per ternary_tree() (introdotto più avanti), ciascuno dei quali smista verso l'implementazione diretta corrispondente all'operatore ricevuto. Consulta qiskit_fermions.protocols per sapere come questo smistamento è implementato internamente.

Scegliere una codifica con gli alberi ternari​

Jordan-Wigner non è l'unica codifica da fermioni a qubit, e non è la meno costosa. Le sue stringhe ZZ crescono con l'indice del modo, quindi un operatore di Majorana sull'ultimo di nn modi viene mappato su una stringa di Pauli di peso nn. Le codifiche che distribuiscono l'informazione di parità su un albero anziché su una catena hanno prestazioni asintoticamente migliori.

Un albero ternario su nn nodi descrive tutta questa famiglia. Ogni nodo è un qubit con tre collegamenti verso il basso etichettati X, Y e Z; un collegamento o scende verso un nodo figlio (un arco) oppure termina (una foglia). Il percorso di ogni foglia fino alla radice descrive una stringa di Pauli: attraversare il collegamento etichettato PP che scende dal nodo uu contribuisce con PP sul qubit uu. Ogni albero di questo tipo produce una codifica valida, e quelle più note sono semplicemente forme particolari: una catena lungo Z è Jordan-Wigner, mentre un albero in cui ogni nodo ha tre figli, per quanto il numero di nodi lo consenta, è bilanciato.

>>> from qiskit_fermions.mappers import TernaryTree, TernaryTreeEncoding
>>>
>>> # Jordan-Wigner is a chain descending along Z, with X and Y as legs at every node.
>>> TernaryTree.chain(13, "Z").max_weight
13
>>> # The balanced tree spreads the same information over depth log_3(2n+1).
>>> TernaryTree.breadth_first(13, 3).max_weight
3

Quel peso massimo è ottimale. Jiang et al. dimostrano che gli operatori di Majorana di qualsiasi mappatura da fermioni a qubit di nn modi hanno un peso di Pauli medio di almeno log⁡3(2n)\log_3(2n), quindi nessuna mappatura può portare il più grande di essi sotto ⌈log⁡3(2n+1)⌉\lceil \log_3(2n+1) \rceil. Entrambi gli alberi usano esattamente nn qubit, quindi il risparmio riguarda il peso di Pauli, non il numero di qubit.

Per mappare un operatore, compila un albero in una codifica e passala a un mapper insieme al numero di qubit del risultato. La compilazione accoppia le foglie dell'albero in operatori di Majorana e costruisce una sola volta le loro stringhe di Pauli, quindi la stessa codifica va riutilizzata per molti operatori. Il numero di qubit resta nella chiamata di mappatura anziché nella codifica, così una stessa codifica può servire registri di larghezze diverse; un numero superiore a quello dei nodi dell'albero viene completato con l'identità.

>>> from qiskit_fermions.mappers.library import fermion_ternary_tree
>>> from qiskit_fermions.operators import FermionOperator
>>>
>>> hamiltonian = FermionOperator.from_dict(
... {((True, 0), (False, 3)): 1.0, ((True, 3), (False, 0)): 1.0}
... )
>>> encoding = TernaryTreeEncoding(TernaryTree.breadth_first(4, 3))
>>> fermion_ternary_tree(hamiltonian, encoding, 4).simplify()
<SparseObservable with 2 terms on 4 qubits: (0.5+0j)(Y_3 Z_1 Y_0) + (0.5+0j)(X_3 Z_2 X_0)>

La mappatura attraverso una catena lungo Z riproduce esattamente fermion_jordan_wigner() (qf_ferm_op_jordan_wigner() nell'API C), ed è in questo senso che la generalizza:

>>> from qiskit_fermions.mappers.library import fermion_jordan_wigner
>>> chain = TernaryTreeEncoding(TernaryTree.chain(4, "Z"))
>>> direct = fermion_jordan_wigner(hamiltonian, 4)
>>> (fermion_ternary_tree(hamiltonian, chain, 4) - direct).simplify().num_terms
0

Ogni rappresentazione di operatore ha un mapper ad albero ternario, che rispecchia l'insieme di Jordan-Wigner; consulta qiskit_fermions.mappers.library per l'elenco completo e Mapper Library nell'API C. Nell'API Python sono raggiungibili anche tramite ternary_tree(), il wrapper indipendente dal tipo che sceglie quello giusto per l'operatore ricevuto, esattamente come fa jordan_wigner():

>>> from qiskit_fermions.mappers.library import ternary_tree
>>> ternary_tree(hamiltonian, encoding, 4).simplify()
<SparseObservable with 2 terms on 4 qubits: (0.5+0j)(Y_3 Z_1 Y_0) + (0.5+0j)(X_3 Z_2 X_0)>
Nota

Alcune identità valide con Jordan-Wigner sono proprietà della catena e non degli alberi ternari. L'operatore numero è nj=(1−Zj)/2n_j = (1 - Z_j)/2 solo sulla catena; su un altro albero lo ZjZ_j diventa una stringa ZZ su un insieme di qubit. Allo stesso modo, un operatore di vertice VjV_j è il Pauli di peso 1 ZjZ_j solo sulla catena, dove le catene ZZ delle due stringhe di Majorana si annullano.

Poiché ogni operatore numero viene comunque mappato su una singola stringa ZZ, uno stato di Fock è ancora uno stato della base computazionale con qualsiasi albero ternario, ma il bit qq non indica più l'occupazione qq. L'occupazione jj è invece la parità dei bit misurati sul supporto dell'immagine di njn_j. Ricava questi supporti dal mapper anziché derivarli dall'albero, in modo che la decodifica resti corretta per qualsiasi forma o permutazione dei modi:

>>> for mode in range(4):
... number = FermionOperator.from_dict({((True, mode), (False, mode)): 1.0})
... print(mode, ternary_tree(number, encoding, 4).simplify())
0 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_2 Z_1 Z_0)>
1 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_1)>
2 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_2)>
3 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_3)>

In questo albero bilanciato, il modo 0 è occupato quando i qubit 0, 1 e 2 contengono un numero dispari di uni, quindi prepararlo significa invertire il solo qubit 0, mentre decodificare una stringa di bit misurata richiede la parità su tutti e tre. Un singolo errore di bit-flip sul qubit 1 corrompe quindi le occupazioni sia del modo 0 sia del modo 1, il che è rilevante per qualsiasi flusso di lavoro che legga le occupazioni da campioni rumorosi.

Quando un'Evolution viene abbassata attraverso un mapper di questo tipo, racchiudi il mapper in group_wise() (vedi Sintetizzare un gate di Evolution nello spazio fermionico). Un termine di hopping viene mappato su due stringhe di Pauli che commutano, nessuna delle quali conserva da sola il numero di particelle; lo fa solo il loro prodotto nell'evoluzione. Con Jordan-Wigner entrambe le stringhe agiscono sugli stessi qubit, quindi un ordine dei termini semplificato le mantiene adiacenti. Con altri alberi i loro supporti differiscono (come per Y_3 Z_1 Y_0 e X_3 Z_2 X_0 in precedenza), sia l'ordine grezzo sia quello semplificato le intercalano con le immagini di altri termini, e una formula di prodotto sul lato dei qubit finisce per disperdere il numero di particelle.

Costruire un albero arbitrario​

I costruttori a catena e breadth-first coprono le famiglie uniformi, ma l'interfaccia generale accetta una coppia (parent, label) per nodo, con None per la radice (l'API C accetta invece l'indice della radice). Questo conta perché gli alberi che vale la pena cercare di solito non sono né una catena né uniformi: l'algoritmo Bonsai, ad esempio, fa crescere un albero di copertura del grafo di accoppiamento di un dispositivo, quindi le sue ramificazioni seguono quella connettività.

>>> # Node 0 branches three ways, node 1 twice, and node 4 carries a single child.
>>> tree = TernaryTree([None, (0, "X"), (0, "Y"), (0, "Z"), (1, "X"), (1, "Z"), (4, "Y")])
>>> tree.num_nodes, tree.num_legs, tree.max_weight
(7, 15, 4)
>>> tree.children(0)
{'X': 1, 'Y': 2, 'Z': 3}

Nota il numero di foglie: è sempre 2n+12n+1, qualunque sia la forma. Un nodo con meno figli non genera meno stringhe di Pauli: i suoi collegamenti inutilizzati sono foglie, e una foglia descrive una stringa proprio come i discendenti di un arco. Di queste 2n+12n+1 stringhe, 2n2n si accoppiano negli operatori di Majorana degli nn modi, ed esattamente una resta in più.

Parità totale​

Quella foglia rimanente è la parità fermionica totale ∏j(1−2nj)\prod_j (1 - 2 n_j). È una proprietà della codifica e non la mappatura di un operatore, quindi è un metodo della codifica stessa:

>>> TernaryTreeEncoding(TernaryTree.chain(4, "Z")).total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3 Z_2 Z_1 Z_0)>
>>> TernaryTreeEncoding(TernaryTree.breadth_first(4, 3)).total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3 Z_0)>

Come mostra il caso bilanciato, questa non è in generale la stringa di soli ZZ: è la foglia raggiunta dal percorso di soli ZZ, che agisce come identità su ogni nodo non presente su quel percorso.

Importante

La parità totale qui è un osservabile da misurare, non un vincolo da imporre. Entrambi i suoi autovalori sono fisici, quindi non proiettare uno stato sul settore +1+1: una codifica ad albero ternario usa un qubit per modo ed è suriettiva sull'intero spazio di dimensione 2n2^n, senza lasciare alcun sottospazio non fisico da eliminare con una proiezione.

Esempio svolto: un albero di Bravyi-Kitaev​

Le specifiche vengono calcolate più spesso di quanto vengano scritte a mano, dato che l'albero di una codifica reale raramente è abbastanza piccolo da essere scritto manualmente. Bravyi-Kitaev è un buon esempio: appartiene a questa famiglia, ma il suo albero non è uniforme, quindi nessun costruttore di comodo lo copre.

Il suo albero è costruito da una suddivisione ricorsiva dei nodi, uno per modo. All'interno di un intervallo di nodi [l,r][l, r], sia hh la più grande potenza di due che ci sta; il nodo collocato per quell'intervallo è l+h−1l + h - 1, l'ultimo di quel blocco. Il suo collegamento X scende verso l'albero costruito dal blocco che lo precede e il suo collegamento Z verso l'albero costruito dal resto che lo segue, lasciando il suo collegamento Y come foglia:

>>> def bravyi_kitaev_tree(num_modes: int) -> TernaryTree:
... """Builds the ternary tree of the Bravyi-Kitaev encoding on ``num_modes`` modes."""
... spec: list[tuple[int, str] | None] = [None] * num_modes
... def build(left: int, right: int) -> int | None:
... if left > right:
... return None # an empty range: the parent's link stays a leg
... block = 1
... while 2 * block <= right - left + 1:
... block *= 2
... node = left + block - 1
... for child, label in (
... (build(left, node - 1), "X"), # the block before this node
... (build(node + 1, right), "Z"), # the remainder after it
... ):
... if child is not None:
... spec[child] = (node, label)
... return node
... build(0, num_modes - 1)
... return TernaryTree(spec)

Con quattro modi l'intero intervallo è un unico blocco, quindi il nodo collocato per primo è il 3, con [0,2][0, 2] prima di esso e nulla dopo; quel sotto-intervallo dà il nodo 1 sopra i nodi singoli 0 e 2. La compilazione dell'albero conferma che la codifica è Bravyi-Kitaev e non semplicemente un albero della stessa classe di forma: la sua prima immagine di Majorana è X0X1X3X_0 X_1 X_3:

>>> tree = bravyi_kitaev_tree(4)
>>> tree.children(1)
{'X': 0, 'Z': 2}
>>> encoding = TernaryTreeEncoding(tree)
>>> encoding.majorana_image(0)
[(0, 'X'), (1, 'X'), (3, 'X')]

La sua parità totale rende concreto il punto della sezione precedente: la foglia rimanente è quella raggiunta dal percorso di soli ZZ, e qui quel percorso si ferma alla radice, quindi l'osservabile è il singolo qubit Z3Z_3 anziché una stringa su tutti e quattro i qubit:

>>> encoding.total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3)>

Per scalare non serve altro che un argomento più grande. Poiché nessun nodo usa mai il proprio collegamento Y per un arco, ciascuno si ramifica al massimo in due direzioni, quindi il peso di Pauli cresce in modo logaritmico anziché lineare:

>>> [bravyi_kitaev_tree(n).max_weight for n in (4, 8, 16, 32, 64)]
[3, 4, 5, 6, 7]