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

Un mapper trasforma un BosonOperator in un operatore di qubit, qui un SparseObservable. L'operatore in sé non sa nulla dei qubit: memorizza operatori di scala sui modi, e un modo bosonico contiene un numero arbitrario di particelle. Tutto ciò che rende finito l'operatore (quanti livelli di occupazione mantieni e come quei livelli sono disposti sui qubit) viene deciso quando lo mappi.

Il mapper della libreria occupation_encoding() lavora nella base di occupazione (di Fock): il livello nn di un modo è lo stato con nn bosoni. Prende l'operatore e un LevelSpec, che contiene il troncamento e la codifica. Questa guida spiega cosa significano queste due scelte e quanto costa ciascuna. Per un percorso completo che le utilizza, consulta Simulare la dinamica di Bose-Hubbard con una scelta di codifica; per come il transpiler applica le stesse scelte a un intero circuito, consulta Eseguire il transpile dei circuiti bosonici.

Troncare lo spazio di Fock​

Un modo mantenuto a d=nmax+1d = n_\text{max} + 1 livelli, dove nmaxn_\text{max} è il suo max_occupation, non obbedisce più alla relazione di commutazione canonica. Indicando con bˉ\bar{b} l'operatore di annichilazione troncato a quei livelli,

[bˉ,bˉ†]=1−d ∣d−1⟩⟨d−1∣ ,[\bar{b}, \bar{b}^\dagger] = 1 - d \, \lvert d-1 \rangle\langle d-1 \rvert \, ,

la relazione vale quindi su ogni livello tranne il più alto [1]. Puoi verificarlo mappando il commutatore dei due operatori di scala e leggendone la diagonale sui livelli di un modo. La seguente funzione di supporto costruisce la matrice densa di un operatore mappato per un singolo modo, cosa ragionevole solo per registri molto piccoli:

>>> import numpy as np
>>> from qiskit.quantum_info import SparsePauliOp
>>> from qiskit_bosons.mappers import LevelEncoding, LevelSpec
>>> from qiskit_bosons.mappers.library import occupation_encoding
>>> from qiskit_bosons.operators import BosonOperator, ann, cre
>>>
>>> def level_diagonal(operator, max_occupation, encoding):
... """The diagonal of a mapped one-mode operator on the codewords of the levels."""
... spec = LevelSpec(max_occupation, encoding)
... matrix = SparsePauliOp.from_sparse_observable(occupation_encoding(operator, spec)).to_matrix()
... codewords = [spec.encoding.level_to_bits(level) for level in range(max_occupation + 1)]
... return [round(float(matrix[c, c].real), 6) for c in codewords]
>>>
>>> b = BosonOperator.from_dict({(ann(0),): 1.0})
>>> b_dagger = BosonOperator.from_dict({(cre(0),): 1.0})
>>> commutator = (b @ b_dagger) - (b_dagger @ b)
>>> level_diagonal(commutator, 2, "unary_unprojected") # d = 3
[1.0, 1.0, -2.0]

L'ultima voce è 1−d1 - d. Poiché ciò significa che lo span di {bˉ†,bˉ}\{\bar{b}^\dagger, \bar{b}\} non è chiuso rispetto al commutatore, nessuna codifica è un isomorfismo di algebre. Una codifica mappa stati, insieme agli operatori che agiscono all'interno del sottospazio troncato. Le due conseguenze seguenti contano nella pratica.

Il cutoff deve limitare l'occupazione raggiungibile. Non basta limitare l'occupazione da cui parti. Un beamsplitter che agisce su ∣1,1⟩\lvert 1, 1 \rangle popola ∣2,0⟩\lvert 2, 0 \rangle e ∣0,2⟩\lvert 0, 2 \rangle, quindi un cutoff pari a uno produce silenziosamente uno stato errato:

>>> from scipy.linalg import expm
>>>
>>> beamsplitter = BosonOperator.from_dict({(cre(0), ann(1)): 1.0, (cre(1), ann(0)): 1.0})
>>>
>>> def stays_in_11(max_occupation):
... """Probability to remain in |1, 1> after a 50:50 beamsplitter."""
... spec = LevelSpec(max_occupation, "gray").resolve(2)
... observable = occupation_encoding(beamsplitter, spec, spec.total_width)
... matrix = SparsePauliOp.from_sparse_observable(observable).to_matrix()
... unitary = expm(-1j * (np.pi / 4) * matrix)
... index = sum(spec.encoding.level_to_bits(1) << offset for offset in spec.offsets)
... return round(float(abs(unitary[index, index]) ** 2), 6)
>>>
>>> stays_in_11(1), stays_in_11(2)
(1.0, 0.0)

Con un cutoff pari a uno, la probabilità di restare in ∣1,1⟩\lvert 1, 1 \rangle è uno, perché i due stati che la dinamica popolerebbe non esistono. Con un cutoff pari a due, svanisce, ed è l'effetto Hong-Ou-Mandel. Per questo max_occupation è un argomento obbligatorio che non ha mai un valore predefinito: è una decisione di modellazione e un parametro di convergenza che ci si aspetta tu faccia variare.

Il mapping non commuta con l'ordinamento normale. Gli operatori bb†b b^\dagger e b†b+1b^\dagger b + 1 sono uguali prima del troncamento, ma vengono mappati in operatori di qubit diversi:

>>> product = BosonOperator.from_dict({(ann(0), cre(0)): 1.0})
>>> ordered = BosonOperator.from_dict({(cre(0), ann(0)): 1.0}) + BosonOperator.one()
>>> bool(product.equiv(ordered))
True
>>> level_diagonal(product, 3, "unary_unprojected") # d = 4
[1.0, 2.0, 3.0, 0.0]
>>> level_diagonal(ordered, 3, "unary_unprojected")
[1.0, 2.0, 3.0, 4.0]

I due coincidono su ogni livello tranne il più alto. Applicato al livello superiore, b†b^\dagger non ha dove andare e l'ampiezza viene tagliata, quindi bb†b b^\dagger è zero lì, mentre b†b+1b^\dagger b + 1 dà dd. Decidi una volta per tutte se il tuo hamiltoniano è definito in forma ordinata normalmente, e mappa quella forma in modo coerente.

Tre metodi ti aiutano a scegliere un cutoff, e tutti sono limiti inferiori. L'operatore riporta il maggior numero di operatori di creazione che applica a ciascun modo all'interno di un singolo termine, e una specifica lo confronta con i propri cutoff:

>>> quartic = BosonOperator.from_dict({(cre(0), cre(0), ann(0), ann(0)): 1.0})
>>> quartic.mode_occupations(), quartic.max_occupation()
([2], 2)
>>> LevelSpec(1, "gray").truncation(quartic) # one level short
[1]
>>> LevelSpec(2, "gray").truncation(quartic)
[0]

Una voce diversa da zero significa che il cutoff è al di sotto del limite, e il valore indica di quanto. Queste funzioni di supporto non conoscono la dinamica, quindi un controllo superato non rende sufficiente un cutoff; il beamsplitter visto sopra ha un limite pari a uno ma richiede un cutoff di due. Possono anche segnalare una carenza per un termine che è zero sui livelli mantenuti, come lo è b†b†bbb^\dagger b^\dagger b b per un cutoff di uno.

Descrivere il troncamento con un LevelSpec​

Un LevelSpec raggruppa il cutoff di ogni modo con la codifica, e da essi ricava quanti qubit occupa ciascun modo e dove iniziano i suoi qubit. Tutto ciò che deve concordare su questo (il mapper, il layout del transpiler, la preparazione dello stato) riceve la stessa specifica. La codifica è un argomento obbligatorio, e lo è anche il cutoff: nessuno dei due ha un valore predefinito.

Il cutoff è un unico intero, condiviso da tutti i modi, oppure un intero per ciascun modo:

>>> shared = LevelSpec(2, "gray")
>>> per_mode = LevelSpec([4, 2, 2], "gray")
>>> shared.num_modes is None, per_mode.num_modes
(True, 3)

Una specifica costruita da un singolo intero lascia aperto il numero di modi, quindi devi risolverlo prima di chiedere le larghezze. Una sequenza non viene mai estesa, qualunque sia la sua lunghezza, e fissa il numero di modi:

>>> per_mode.widths, per_mode.offsets, per_mode.total_width
([3, 2, 2], [0, 3, 5], 7)
>>> shared.resolve(3).widths
[2, 2, 2]
>>> try:
... shared.widths
... except ValueError as error:
... print(error)
this spec was built from a single cutoff, so its number of modes is open; call `resolve(num_modes)` first

Il modo mm occupa i qubit da offsets[m] a offsets[m] + widths[m] - 1, quindi modi con cutoff diversi possono occupare un numero diverso di qubit, e gli offset sono la somma progressiva delle larghezze. Non devi passare il numero di modi al mapper. Lo prende dalla specifica oppure, per una specifica con un singolo cutoff, dall'operatore tramite min_num_modes(). Il registro può essere più largo di quanto servano i modi; i qubit in eccesso restano inattivi e si trovano alla fine:

>>> number = BosonOperator.from_dict({(cre(0), ann(0)): 1.0})
>>> number.min_num_modes()
1
>>> occupation_encoding(number, LevelSpec(1, "gray")).num_qubits
1
>>> occupation_encoding(number, LevelSpec(1, "gray"), 4).num_qubits
4

Un operatore che agisce su un modo oltre l'ultimo di una specifica per modo viene rifiutato:

>>> far = BosonOperator.from_dict({(cre(5), ann(5)): 1.0})
>>> try:
... occupation_encoding(far, LevelSpec([1, 1], "gray"))
... except ValueError as error:
... print(error)
the operator acts on 6 modes, but the spec covers only 2

Alcuni limiti derivano da come le codifiche contano i qubit. In una codifica compatta, un modo con un solo livello (max_occupation = 0) non occupa alcun qubit, perché il suo unico livello non ha bisogno di nulla per essere distinto da un altro. Un modo simile è consentito e non sposta i qubit dei modi successivi, ma una specifica in cui ogni modo non occuperebbe alcun qubit viene rifiutata. Le codifiche unarie contengono al massimo 128 livelli per modo, e un cutoff maggiore genera un errore che suggerisce una codifica compatta:

>>> LevelSpec([0, 1], "gray").resolve(2).widths
[0, 1]
>>> try:
... LevelSpec(0, "gray")
... except ValueError as error:
... print(str(error).split(":")[0])
the spec would span no qubits
>>> try:
... LevelSpec(128, "unary_projected")
... except ValueError as error:
... print(str(error).split(" (")[0])
max_occupation = 128 is too large for the unary encodings, which hold at most 128 levels per mode

Scegliere una codifica​

Un LevelEncoding decide quale stato di base di un blocco di qubit rappresenta ciascun livello. Ce ne sono quattro, e nessuna è la migliore in generale [2]. Il numero di qubit per modo e lo schema di bit di ciascun livello derivano dalla codifica:

>>> encodings = (
... LevelEncoding.UNARY_PROJECTED,
... LevelEncoding.UNARY_UNPROJECTED,
... LevelEncoding.STANDARD_BINARY,
... LevelEncoding.GRAY,
... )
>>> rows = ["encoding qubits for d = 1, 2, 3, 4, 5, 8 level 0 to 4 as bit patterns"]
>>> for encoding in encodings:
... widths = [encoding.num_qubits(levels) for levels in (1, 2, 3, 4, 5, 8)]
... patterns = [f"{encoding.level_to_bits(level):05b}" for level in range(5)]
... rows.append(f"{encoding!s:18s} {widths!s:34s} {patterns}")
>>> print("\n".join(rows))
encoding qubits for d = 1, 2, 3, 4, 5, 8 level 0 to 4 as bit patterns
unary_projected [1, 2, 3, 4, 5, 8] ['00001', '00010', '00100', '01000', '10000']
unary_unprojected [1, 2, 3, 4, 5, 8] ['00001', '00010', '00100', '01000', '10000']
standard_binary [0, 1, 2, 2, 3, 3] ['00000', '00001', '00010', '00011', '00100']
gray [0, 1, 2, 2, 3, 3] ['00000', '00001', '00011', '00010', '00110']

Gli schemi di bit sono interi in cui il bit qq è il valore del qubit qq. Le codifiche unarie impiegano un qubit per livello e ne impostano uno. Le codifiche compatte impiegano ⌈log⁡2d⌉\lceil \log_2 d \rceil qubit e memorizzano il numero del livello, in binario o come codice di Gray in cui livelli vicini differiscono di un bit. Il nome come stringa di una codifica è accettato ovunque sia accettata la codifica.

Il compromesso è tra qubit e peso dell'operatore. Le codifiche compatte richiedono esponenzialmente meno qubit, ma un operatore di scala collega livelli i cui schemi possono differire in più bit, quindi i suoi termini agiscono su più qubit. Le sezioni seguenti esaminano quanto costa, partendo dalle due codifiche unarie, che hanno layout identici e differiscono solo nell'operatore.

Confrontare le due codifiche unarie​

Nella codifica unaria un operatore di scala sposta un'eccitazione tra due qubit vicini. Per scriverlo come operatore sull'intero modo, devi scegliere cosa fare con gli altri qubit. Le due varianti scelgono in modo diverso:

  • unary_projected mette un proiettore ∣0⟩⟨0∣\lvert 0 \rangle\langle 0 \rvert su ogni altro qubit del modo. Questo impone il vincolo one-hot nell'operatore stesso: il termine agisce solo se il resto del modo è vuoto.

  • unary_unprojected lascia intatti quei qubit, quindi il termine agisce come identità su di essi.

Osserva un singolo b†b^\dagger con d=4d = 4 in entrambe. Il mapper restituisce un SparseObservable, i cui termini sono stringhe di termini di bit a singolo qubit. Ecco i primi quattro termini di ciascuno, con i qubit su cui agiscono:

>>> for encoding in ("unary_projected", "unary_unprojected"):
... observable = occupation_encoding(b_dagger, LevelSpec(3, encoding))
... terms = observable.to_sparse_list()
... print(encoding, "->", len(terms), "terms on", observable.num_qubits, "qubits")
... for label, qubits, coefficient in terms[:4]:
... print(f" {label:5s} on qubits {qubits} coefficient {complex(coefficient):.4f}")
unary_projected -> 12 terms on 4 qubits
XX00 on qubits [0, 1, 2, 3] coefficient 0.2500+0.0000j
XY00 on qubits [0, 1, 2, 3] coefficient 0.0000-0.2500j
YX00 on qubits [0, 1, 2, 3] coefficient 0.0000+0.2500j
YY00 on qubits [0, 1, 2, 3] coefficient 0.2500+0.0000j
unary_unprojected -> 12 terms on 4 qubits
XX on qubits [0, 1] coefficient 0.2500+0.0000j
XY on qubits [0, 1] coefficient 0.0000-0.2500j
YX on qubits [0, 1] coefficient 0.0000+0.2500j
YY on qubits [0, 1] coefficient 0.2500+0.0000j

Entrambi hanno dodici termini con gli stessi coefficienti. I termini proiettati contengono un termine di bit 0 sui due qubit che i termini non proiettati non menzionano, quindi ogni termine proiettato tocca tutti e quattro i qubit e ogni termine non proiettato ne tocca due. In generale un termine proiettato tocca tutti i dd qubit del modo, perché contiene due fattori, ciascuno XX o YY, e un proiettore su ogni altro qubit.

I due operatori sono uguali sugli stati per cui la codifica è pensata, gli stati di base one-hot (le parole di codice), e differiscono al di fuori di essi. Per verificarlo, prendi un piccolo operatore su due modi con d=3d = 3 e confronta le matrici sulle nove parole di codice:

>>> hamiltonian = BosonOperator.from_dict({
... (cre(0), ann(1)): 1.0, (cre(1), ann(0)): 1.0,
... (cre(0), ann(0)): 0.5, (cre(1), cre(1), ann(1), ann(1)): 0.25,
... })
>>> matrices = {}
>>> for encoding in ("unary_projected", "unary_unprojected"):
... observable = occupation_encoding(hamiltonian, LevelSpec(2, encoding))
... matrices[encoding] = SparsePauliOp.from_sparse_observable(observable).to_matrix()
>>> unary = LevelEncoding.UNARY_PROJECTED
>>> codewords = sorted(
... unary.level_to_bits(first) | (unary.level_to_bits(second) << 3)
... for first in range(3) for second in range(3)
... )
>>> projected, unprojected = matrices["unary_projected"], matrices["unary_unprojected"]
>>> bool(np.allclose(projected[np.ix_(codewords, codewords)], unprojected[np.ix_(codewords, codewords)]))
True
>>> bool(np.allclose(projected, unprojected))
False

Entrambi lasciano inoltre invariante il sottospazio delle parole di codice, quindi uno stato preparato su una parola di codice resta sulle parole di codice. La scelta conta solo per gli stati che escono da quel sottospazio, ad esempio a causa del rumore. Lì l'operatore proiettato sopprime l'evoluzione, perché i suoi proiettori falliscono, mentre quello non proiettato continua ad agire. Nessuno dei due comportamenti è un requisito fisico. L'operatore non proiettato è definito solo sul sottospazio one-hot, quindi sceglierlo è una decisione di modellazione e non un'ottimizzazione gratuita.

Ciò che costano i proiettori sono i gate. Evolvi un b†+bb^\dagger + b hermitiano su un modo per un breve tempo e conta i gate a due qubit dopo il transpile di Qiskit:

>>> from qiskit import QuantumCircuit, transpile
>>> from qiskit.circuit.library import PauliEvolutionGate
>>>
>>> def cx_count(observable):
... circuit = QuantumCircuit(observable.num_qubits)
... circuit.append(PauliEvolutionGate(observable, time=0.1), range(observable.num_qubits))
... basis = ["cx", "rz", "sx", "x"]
... return transpile(circuit, basis_gates=basis, optimization_level=1).count_ops().get("cx", 0)
>>>
>>> hermitian = b + b_dagger
>>> levels = (3, 4, 5, 6)
>>> counts = {
... str(encoding): [
... cx_count(occupation_encoding(hermitian, LevelSpec(d - 1, encoding))) for d in levels
... ]
... for encoding in encodings
... }

I conteggi esatti dipendono da come Qiskit sintetizza l'evolution gate, che cambia da una release all'altra, quindi rappresentali in un grafico:

CX count against the number of levels d for the four encodings. The unary_projected count grows fastest, the unary_unprojected count grows linearly, and the Gray code is the cheapest.

Il conteggio non proiettato cresce linearmente con dd, perché ciascuna delle d−1d - 1 transizioni è una rotazione a due qubit. Il conteggio proiettato cresce molto più rapidamente, perché ogni termine agisce su tutti i dd qubit. Le codifiche compatte richiedono meno qubit, ma il peso dei loro termini dipende da come differiscono gli schemi di bit dei livelli vicini: il codice di Gray, in cui differiscono in un bit, è la più economica delle due codifiche compatte, e il binario standard salta quando un riporto inverte più bit contemporaneamente (d=5d = 5). Se una codifica compatta batta anche quella unaria non proiettata dipende da dd e da come Qiskit sintetizza l'evoluzione, quindi leggi la tendenza e non la classifica a una singola dimensione.

I proiettori contano anche quando espandi un operatore mappato in Pauli, cosa che from_sparse_observable() fa sostituendo ogni proiettore con una somma di due operatori di Pauli. Un termine con kk proiettori diventa 2k2^k termini di Pauli. Ecco un singolo b†b^\dagger con dd piccolo, che è l'unica dimensione in cui dovresti provarlo:

>>> rows = [f"{'d':>2s} {'stored (both)':>14s} {'expanded, projected':>20s} {'expanded, unprojected':>22s}"]
>>> for levels in (3, 4, 5):
... stored = len(occupation_encoding(b_dagger, LevelSpec(levels - 1, "unary_projected")))
... expanded = [
... len(SparsePauliOp.from_sparse_observable(
... occupation_encoding(b_dagger, LevelSpec(levels - 1, encoding))).simplify())
... for encoding in ("unary_projected", "unary_unprojected")
... ]
... rows.append(f"{levels:2d} {stored:14d} {expanded[0]:20d} {expanded[1]:22d}")
>>> print("\n".join(rows))
d stored (both) expanded, projected expanded, unprojected
3 8 16 8
4 12 48 12
5 16 128 16

Entrambe le varianti memorizzano lo stesso numero di termini, ma l'espansione di quella proiettata cresce esponenzialmente in dd. Per qualsiasi cosa più grande di un esempio didattico, leggi direttamente i termini del SparseObservable, come fa l'elenco sopra, invece di convertirlo.

Le codifiche compatte non possono eliminare i loro proiettori. Lì i bit a zero fanno parte del numero del livello, quindi rimuovere un ∣0⟩⟨0∣\lvert 0 \rangle\langle 0 \rvert cambia quali livelli un termine collega, e l'operatore non è più lo stesso sulle parole di codice. Per questo solo la codifica unaria ha due varianti.

Un prodotto di operatori di scala viene troncato per primo​

Un'ulteriore scelta di progettazione emerge nel costo dei termini diagonali. Il mapper non mappa ciascun operatore di scala per poi moltiplicare i risultati. Raccoglie gli operatori di scala che un termine applica a un modo, li moltiplica come matrice d×dd \times d nella base di Fock, tronca quel prodotto una sola volta e codifica il risultato [2]. Un prodotto come l'operatore numero b†bb^\dagger b resta quindi diagonale:

>>> number_observable = occupation_encoding(number, LevelSpec(3, "unary_unprojected"))
>>> [(label, qubits) for label, qubits, _ in number_observable.to_sparse_list()]
[('1', [1]), ('1', [2]), ('1', [3])]
>>> cx_count(number_observable)
0

I suoi termini sono i proiettori ∣1⟩⟨1∣\lvert 1 \rangle\langle 1 \rvert sul qubit di ogni livello sopra lo zero, pesati dal numero del livello (il livello zero ha peso zero e quindi nessun termine). Una sua evoluzione temporale è un insieme di rotazioni a singolo qubit e non richiede alcun gate a due qubit. Moltiplicare invece i due operatori di scala mappati dà lo stesso operatore sulle parole di codice, ma uno diverso al di fuori di esse, con stringhe fuori diagonale che costano gate:

>>> unprojected = LevelSpec(3, "unary_unprojected")
>>> as_pauli = lambda operator: SparsePauliOp.from_sparse_observable(
... occupation_encoding(operator, unprojected, 4)).simplify()
>>> composed = as_pauli(b_dagger).dot(as_pauli(b)).simplify()
>>> direct = as_pauli(number)
>>> len(direct), len(composed)
(4, 16)
>>> cx_count(direct)
0
>>> bool(cx_count(composed) > 0)
True
>>> codewords = [LevelEncoding.UNARY_UNPROJECTED.level_to_bits(level) for level in range(4)]
>>> bool(np.allclose(direct.to_matrix()[np.ix_(codewords, codewords)],
... composed.to_matrix()[np.ix_(codewords, codewords)]))
True
>>> bool(np.allclose(direct.to_matrix(), composed.to_matrix()))
False

I termini diagonali, come l'interazione on-site del modello di Bose-Hubbard, sono questi prodotti, ed è qui che questa scelta conta.

Scrivere un mapper personalizzato​

Se vuoi una codifica che la libreria non fornisce, costruiscila a partire dall'immagine di un singolo operatore di scala. map_boson_action_generators() prende una funzione che mappa una singola BosonAction, una funzione che restituisce l'identità, e ne costruisce il mapper di operatori interi. Moltiplica in ordine le immagini delle azioni di ciascun termine, scala per il coefficiente e somma i termini. L'output può essere di qualsiasi tipo che supporti la moltiplicazione per scalare e la composizione.

Come esempio, prendi la codifica per d=2d = 2 che memorizza un modo in un qubit. Un operatore di creazione è ∣1⟩⟨0∣\lvert 1 \rangle\langle 0 \rvert, che è (X−iY)/2(X - iY)/2 nella convenzione di Qiskit, e un operatore di annichilazione è il suo aggiunto:

>>> from qiskit_bosons.mappers import map_boson_action_generators
>>> from qiskit_bosons.operators import BosonAction
>>>
>>> num_qubits = 2
>>>
>>> def map_action(action: BosonAction) -> SparsePauliOp:
... is_creation, mode = action
... sign = -1j if is_creation else 1j
... return SparsePauliOp.from_sparse_list(
... [("X", [mode], 0.5), ("Y", [mode], 0.5 * sign)], num_qubits=num_qubits
... )
>>>
>>> def identity() -> SparsePauliOp:
... return SparsePauliOp.from_sparse_list([("", [], 1.0)], num_qubits)
>>>
>>> model = BosonOperator.from_dict({
... (cre(0), ann(1)): -1.0, (cre(1), ann(0)): -1.0, (ann(0), cre(0)): 0.7, (cre(1), ann(1)): 0.3,
... })
>>> custom = map_boson_action_generators(model, map_action, identity)
>>> library = SparsePauliOp.from_sparse_observable(occupation_encoding(model, LevelSpec(1, "gray")))
>>> bool(np.allclose(custom.to_matrix(), library.to_matrix()))
True

Questo mapper scritto a mano moltiplica gli operatori di scala mappati, e qui concorda con il mapper della libreria perché ogni modo ha due livelli e un qubit contiene l'intero modo. Con dd più grande, come ha mostrato la sezione precedente, comporre gli operatori di scala mappati e troncare prima il prodotto non sono lo stesso operatore al di fuori delle parole di codice.

Per usare un mapper simile in un circuito, incapsulalo in una funzione con la firma (operator, num_qubits) -> SparseObservable e passalo a MapperFnEvolutionSynthesis, che con esso mappa l'operatore di ogni gate Evolution. La pipeline del transpiler è descritta in Eseguire il transpile dei circuiti bosonici.

>>> from qiskit.quantum_info import SparseObservable
>>> from qiskit_bosons.transpiler.passes import MapperFnEvolutionSynthesis
>>>
>>> def mapper_fn(operator, num_qubits):
... return SparseObservable.from_sparse_pauli_op(
... map_boson_action_generators(operator, map_action, identity)
... )
>>>
>>> synthesis = MapperFnEvolutionSynthesis(mapper_fn)

Vale la pena conoscere due dettagli. Primo, l'operatore di qubit deve avere coefficienti reali, perché PauliEvolutionGate rifiuta una parte immaginaria. Un operatore hermitiano si mappa in uno reale, ma solo dopo aver unito i termini che si cancellano. Questo mapper scritto a mano non li unisce, quindi un termine di hopping e il suo aggiunto lasciano coefficienti immaginari finché non semplifichi il risultato. Il mapper della libreria unisce il proprio risultato; uno personalizzato deve farlo, ad esempio incapsulandolo in simplify():

>>> hopping = BosonOperator.from_dict({(cre(0), ann(1)): 1.0})
>>> hermitian_hopping = hopping + hopping.adjoint()
>>> raw = mapper_fn(hermitian_hopping, 2)
>>> bool(np.allclose(np.asarray(raw.coeffs).imag, 0))
False
>>> merged = raw.simplify()
>>> bool(np.allclose(np.asarray(merged.coeffs).imag, 0))
True
>>> sorted(label for label, _, _ in merged.to_sparse_list())
['XX', 'YY']

Secondo, una formula di prodotto sintetizza i termini nell'ordine in cui li riceve, e l'ordine prodotto da un mapper non è garantito stabile. Le funzioni di supporto simplify() e group_wise() incapsulano una funzione mapper per controllarlo: simplify unisce le stringhe duplicate e ordina i termini in un ordine canonico, mentre group_wise mappa un gruppo dell'operatore alla volta in modo che i termini di un gruppo restino adiacenti. Non combinare le due, perché l'ordinamento scarta il raggruppamento. La guida al raggruppamento mostra come assegnare i gruppi, e la guida alla sintesi mostra come l'ordine influisce sul circuito.

Passi successivi​

[1]

R. Somma, G. Ortiz, E. Knill, and J. Gubernatis, Quantum Simulations of Physics Problems (2003), arXiv:quant-ph/0304063, Sec. 4, Eq. 22.

[2] (1,2)

N. P. D. Sawaya, T. Menke, T. H. Kyaw, S. Johri, A. Aspuru-Guzik, and G. G. Guerreschi, Resource-efficient digital quantum simulation of d-level systems for photonic, vibrational, and spin-s Hamiltonians, npj Quantum Information 6, 49 (2020), arXiv:1909.12847.