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():
- PYTHON
- C
>>> 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)>
// The C API provides direct mapping functions for common transformations.
// Custom mapper prototyping as shown in Python is primarily a Python feature.
// For custom mappings in C, you will need to perform the iteration and
// conversion logic entirely by yourself.
I passaggi chiave per implementare qualsiasi mapper personalizzato sono:
-
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.
-
Usa una delle funzioni di mappatura fornite: il modulo
mappersfornisce 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 modulooperators. Ad esempio,map_majorana_action_generators()funziona con gli operatori di Majorana. -
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.
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:
- PYTHON
- C
>>> 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
#include <qiskit_fermions.h>
// Create a MajoranaOperator with the same terms as maj_op above
uint64_t num_terms = 2;
uint64_t num_modes = 3;
uint32_t modes[3] = {0, 1, 2};
QkComplex64 coeffs[2] = {{1.0, 0.0}, {0.5, 0.0}};
uint32_t boundaries[3] = {0, 2, 3};
QfMajoranaOperator *maj_op = qf_maj_op_new(num_terms, num_modes, coeffs, modes, boundaries);
// Convert MajoranaOperator to FermionOperator using library mapper
QfFermionOperator *ferm_op = qf_majorana_to_fermion(maj_op);
// Apply Jordan-Wigner mapper to get qubit operator
QkObs *qubit_op;
QfExitCode exit = qf_ferm_op_jordan_wigner(ferm_op, 2, &qubit_op);
assert(exit == QfExitCode_Success);
// Clean up
qf_ferm_op_free(ferm_op);
qf_maj_op_free(maj_op);
qk_obs_free(qubit_op);
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 crescono con l'indice del modo, quindi un operatore di Majorana sull'ultimo di modi viene mappato su una stringa di Pauli di peso . Le codifiche che distribuiscono l'informazione di parità su un albero anziché su una catena hanno prestazioni asintoticamente migliori.
Un albero ternario su 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 che scende dal nodo contribuisce con sul qubit . 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.
- PYTHON
- C
>>> 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
#include <qiskit_fermions.h>
QfTernaryTree *chain;
qf_ternary_tree_chain(13, QfPauliLabel_Z, &chain);
QfPauliLabel order[3] = {QfPauliLabel_X, QfPauliLabel_Y, QfPauliLabel_Z};
QfTernaryTree *balanced;
qf_ternary_tree_breadth_first(13, 3, order, &balanced);
assert(qf_ternary_tree_max_weight(chain) == 13);
assert(qf_ternary_tree_max_weight(balanced) == 3);
qf_ternary_tree_free(chain);
qf_ternary_tree_free(balanced);
Quel peso massimo è ottimale. Jiang et al. dimostrano che gli operatori di Majorana di qualsiasi mappatura da fermioni a qubit di modi hanno un peso di Pauli medio di almeno , quindi nessuna mappatura può portare il più grande di essi sotto . Entrambi gli alberi usano esattamente 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à.
- PYTHON
- C
>>> 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)>
// Create the hopping Hamiltonian a^dagger_0 a_3 + a^dagger_3 a_0
QfFermionOperator *hamiltonian = qf_ferm_op_zero();
QkComplex64 coeffs[2] = {{1.0, 0.0}, {1.0, 0.0}};
bool actions[4] = {true, false, true, false};
uint32_t indices[4] = {0, 3, 3, 0};
for (int i = 0; i < 2; i++) {
qf_ferm_op_add_term(hamiltonian, 2, actions + 2 * i, indices + 2 * i, &coeffs[i]);
}
QfPauliLabel order[3] = {QfPauliLabel_X, QfPauliLabel_Y, QfPauliLabel_Z};
QfTernaryTree *tree;
qf_ternary_tree_breadth_first(4, 3, order, &tree);
// Compile the tree once, then reuse the encoding for every operator mapped through it.
QfTernaryTreeEncoding *encoding;
qf_ternary_tree_encoding_new(tree, NULL, &encoding);
QkObs *qubit_op;
QfExitCode exit = qf_ferm_op_ternary_tree(hamiltonian, encoding, 4, &qubit_op);
assert(exit == QfExitCode_Success);
// Clean up
qf_ferm_op_free(hamiltonian);
qf_ternary_tree_encoding_free(encoding);
qf_ternary_tree_free(tree);
qk_obs_free(qubit_op);
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)>
Alcune identità valide con Jordan-Wigner sono proprietà della catena e non degli alberi ternari. L'operatore numero è solo sulla catena; su un altro albero lo diventa una stringa su un insieme di qubit. Allo stesso modo, un operatore di vertice è il Pauli di peso 1 solo sulla catena, dove le catene delle due stringhe di Majorana si annullano.
Poiché ogni operatore numero viene comunque mappato su una singola stringa , uno stato di Fock è ancora uno stato della base computazionale con qualsiasi albero ternario, ma il bit non indica più l'occupazione . L'occupazione è invece la parità dei bit misurati sul supporto dell'immagine di . 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à.
- PYTHON
- C
>>> # 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}
// Node 0, the root, branches three ways, node 1 twice, and node 4 carries a single child.
// The root has no parent and no incoming link, so its entries are never read: any value works.
#define IGNORED 0
uint32_t parents[7] = {IGNORED, 0, 0, 0, 1, 1, 4};
QfPauliLabel labels[7] = {
IGNORED, QfPauliLabel_X, QfPauliLabel_Y, QfPauliLabel_Z,
QfPauliLabel_X, QfPauliLabel_Z, QfPauliLabel_Y,
};
QfTernaryTree *tree;
QfExitCode exit = qf_ternary_tree_new(7, 0, parents, labels, &tree);
assert(exit == QfExitCode_Success);
assert(qf_ternary_tree_num_nodes(tree) == 7);
assert(qf_ternary_tree_num_legs(tree) == 15);
assert(qf_ternary_tree_max_weight(tree) == 4);
qf_ternary_tree_free(tree);
Nota il numero di foglie: è sempre , 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 stringhe, si accoppiano negli operatori di Majorana degli modi, ed esattamente una resta in più.
Parità totale
Quella foglia rimanente è la parità fermionica totale . È una proprietà della codifica e non la mappatura di un operatore, quindi è un metodo della codifica stessa:
- PYTHON
- C
>>> 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)>
QfTernaryTree *tree;
qf_ternary_tree_chain(4, QfPauliLabel_Z, &tree);
QfTernaryTreeEncoding *encoding;
qf_ternary_tree_encoding_new(tree, NULL, &encoding);
// On the chain this is the full Z string; on the balanced tree it is not.
QkObs *parity;
qf_ternary_tree_encoding_total_parity(encoding, 4, &parity);
qf_ternary_tree_encoding_free(encoding);
qf_ternary_tree_free(tree);
qk_obs_free(parity);
Come mostra il caso bilanciato, questa non è in generale la stringa di soli : è la foglia raggiunta dal percorso di soli , che agisce come identità su ogni nodo non presente su quel percorso.
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 : una codifica ad albero ternario usa un qubit per modo ed è suriettiva sull'intero spazio di dimensione , 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 , sia la più grande potenza di due che ci sta; il nodo collocato per quell'intervallo è , 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 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 è :
>>> 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 , e qui quel percorso si ferma alla radice, quindi l'osservabile è il singolo qubit 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]