Sari la conținutul principal
Acest proiect este în versiune beta. Ai găsit o eroare? Deschide un issue pe GitHub. Ai o întrebare sau o idee? Întreabă în Discussions — ne-ar plăcea să aflăm părerea ta!
Cum este calitatea traducerii pe această pagină?

Simulează dinamica Fermi-Hubbard 2D cu seturi de flux

Important

Conceptele din acest ghid sunt disponibile în prezent doar în API-ul Python. Funcționalitate echivalentă va fi pusă la dispoziție în API-ul C într-o versiune viitoare.

Vezi și

Citește mai întâi ghidul seturilor de flux 1D. Acest ghid este continuarea lui bidimensională și nu repetă informațiile dezvoltate acolo: operatorii de transfer și de vârf, ce este un set de flux, cum supraviețuiește groups în circuit și cum este integrată o codificare personalizată în transpiler prin MapperFnEvolutionSynthesis.

Vezi ghidul de transpilare pentru etapele transpilerului menționate pe parcurs și ghidul mapperelor pentru rețeta generală de scriere a unui mapper personalizat.

Ghidul 1D s-a încheiat menționând că sunt necesare codificări locale când se lucrează în două dimensiuni. Acest ghid face acel pas, pentru modelul Fermi-Hubbard bidimensional pe o rețea pătrată, folosind codificarea Verstraete-Cirac (VC) [2].

Există trei diferențe față de problema 1D, care formează baza acestei lucrări:

  • Seturile de flux se înmulțesc. Un lanț are două orientări ale săgeților; o rețea pătrată are patru (est, vest, nord, sud), iar fiecare este o reuniune de căi orientate disjuncte ca vârfuri (una pe rând sau coloană a rețelei), nu o singură cale.

  • Codificarea câștigă o ancilă pe situs, nu una pentru întregul sistem, ceea ce dă un​

    raport qubit-la-mod de 22. Operatorii de transfer devin Pauli de pondere 3 (orizontali) și de pondere 4 (verticali), iar fiecare dintre cele patru seturi de flux se mapează pe propria formă Pauli, deci fiecare necesită propriul Clifford de diagonalizare.

  • Hamiltonianul codificat este fidel doar pe un subspațiu: +1+1 comun​

    comun +1+1 al constrângerilor de plachetă pe care le poartă orice codificare locală 2D [2] [3]. În 1D codificarea era o izometrie simplă, iar verificarea era o verificare de spectru. Aici comparația trebuie proiectată, așa cum ilustrează pasul 9.

Câștigul are aceeași formă ca în 1D: adâncimea cu doi qubiți a unui pas Trotter devine constantă în dimensiunea rețelei, la 2424 (1616 pentru salt, în concordanță cu Ref. [1], plus 88 pentru interacțiune), în timp ce o trotterizare termen cu termen a aceluiași hamiltonian codificat crește liniar. Numărul de porți încă crește cu numărul de legături, desigur; ceea ce devine independent de dimensiune este câte straturi ocupă aceste porți. Adâncimile sunt numărate peste tot pe un registru abstract, complet conectat; maparea codificării pe harta de cuplare restrânsă a unui dispozitiv este o problemă în sine, discutată la final.

Rețeaua și cele patru orientări ale ei​

Prima schimbare este vizibilă pe graful de interacțiune orientat. Situsurile sunt numerotate în ordine row-major, site(r, c) = r * cols + c, iar fiecare legătură poartă ambele orientări, ca lanțul în 1D, dar acum legăturile merg pe două axe:

A 3x3 square lattice with both orientations of every horizontal and vertical bond drawn as arrows, the center site carrying all four.

Regula de comutare este cea din 1D, neschimbată: doi operatori de transfer care împart un situs comută când săgețile curg prin el și anticomută când se ciocnesc. Totuși, un situs are acum până la patru legături incidente în loc de două, deci săgețile prin el se pot ciocni în mai multe feluri, iar un set de săgeți care nu se ciocnesc niciodată nu mai este o singură cale. Citirea săgeților orizontale de la stânga la dreapta dă un astfel de set, săgețile verticale de sus în jos dau altul, iar inversarea fiecăruia dă încă două. Acestea sunt seturile de flux est, vest, nord și sud care organizează tot ce urmează.

Codificarea Verstraete-Cirac​

Verstraete-Cirac (VC) asociază fiecare mod fermionic jj cu propriul qubit auxiliar. Cu NN situsuri ale rețelei, modul jj se află pe qubitul jj, iar ancila lui este pe qubitul N+jN + j, deci registrul conține 2N2N qubiți. Operatorii de vârf rămân cât se poate de simpli,

Vj=Zj ,V_j = Z_j \, ,

ceea ce face ca ocupările să poată fi citite direct mai târziu. Operatorii de transfer depind de orientarea muchiei. Pentru o muchie orizontală j→kj \to k cu jj situsul vestic și o muchie verticală cu jj situsul nordic,

Tjkhorizontal=12XjXkZN+j ,Tjkvertical=12XjYkXN+jYN+k ,T_{jk}^{\text{horizontal}} = \tfrac{1}{2} X_j X_k Z_{N+j} \, , \qquad T_{jk}^{\text{vertical}} = \tfrac{1}{2} X_j Y_k X_{N+j} Y_{N+k} \, ,

un „triunghi” de pondere 3 și un „pătrat” de pondere 4. Identificarea fiecărei săgeți contează: cu jj ca situs vestic, j→kj \to k indică spre est, dar cu jj ca situs nordic, indică spre sud. Orientările inverse rezultă din identitatea Tkj=−VjVkTjkT_{kj} = -V_j V_k T_{jk}, care transformă triunghiul XXZ în YYZ, iar pătratul XYXY în YXXY. Aceste patru etichete distincte sunt cele patru seturi de flux, iar restul ghidului le urmărește.

Notă

Acestea nu sunt chiar Ec. (A1) din Ref. [1], care afirmă „până la rotații ale unui singur qubit” și diferă în trei moduri care merită menționate, deoarece un cititor care verifică lucrarea le va observa:

  • Factorul prealabil: Ec. (A1) nu are niciunul. 12\tfrac12 de aici este ceea ce face ca Tjk2=14T_{jk}^2 = \tfrac14, în acord cu normalizarea Tjk=−12(aj†−aj)(ak†+ak)T_{jk} = -\tfrac12 (a^\dagger_j - a_j)(a^\dagger_k + a_k) pe care o folosește TransferVertexOperator. Lucrarea are nevoie de operatorii de transfer doar în interiorul lui exp⁡(−i dt H)\exp(-i\,dt\,H), unde un factor de scară este absorbit în unghiul de rotație; aici ei trebuie să fie operatori care satisfac o identitate.

  • Orientarea: Ec. (A1) alege literele după paritatea rândului lui jj, care pentru o muchie orizontală este aceeași pentru ambele capete, deci nu face distincție între TjkT_{jk} și TkjT_{kj}. Lucrarea recuperează orientarea separat, prin conjugare cu un strat de RZ(π/2)R^Z(\pi/2). Acest ghid o integrează în schimb în codificare, derivând inversa din Tkj=−VjVkTjkT_{kj} = -V_j V_k T_{jk}, ceea ce face ca cele două orientări să ajungă pe Pauli diferiți în pasul 3.

  • Forma verticală: Ec. (A1) alternează XXXX și YYYY după paritatea rândului, în timp ce acest ghid folosește un singur XYXY. Acestea sunt două moduri de a obține același lucru: o asimetrie de litere care face ca o muchie verticală să intre corect în conflict cu muchiile orizontale care se întâlnesc cu ea. Nu interpreta diferența ca o alegere liberă de bază. Renunțarea la alternarea parității și folosirea unui singur XXXX strică algebra complet, motiv pentru care literele distincte sunt verificate, nu presupuse.

Notă

Ancila este atașată unui situs, dând raportul 22; codificarea Derby-Klassen este cea care plasează un qubit auxiliar pe fiecare plachetă (impară), pentru raportul mai mic 3/23/2. VC este folosită aici deoarece operatorii ei de transfer sunt cei ale căror Clifford diagonalizatori sunt dați în formă închisă de Ref. [1].

1. Hamiltonianul pe o rețea pătrată​

Construcția este aceeași ca în 1D, dar cu o listă bidimensională de muchii. Siturile păstrează numerotarea pe rânduri folosită pentru figura de mai sus, site(r, c) = r * cols + c, astfel încât legăturile orizontale conectează indici consecutivi, iar legăturile verticale conectează indici care diferă prin cols:

>>> from collections import defaultdict
>>> from qiskit_fermions.operators import TransferVertexOperator
>>>
>>> def fermi_hubbard_2d(rows, cols, tunneling, interaction):
... site = lambda r, c: r * cols + c
... edges = [(site(r, c), site(r, c + 1)) for r in range(rows) for c in range(cols - 1)]
... edges += [(site(r, c), site(r + 1, c)) for r in range(rows - 1) for c in range(cols)]
... data: defaultdict[tuple[tuple[int, int], ...], complex] = defaultdict(complex)
... for j, k in edges:
... # hopping: -t (a^dag_j a_k + a^dag_k a_j) = t (T_jk + T_kj)
... data[((j, k),)] += tunneling
... data[((k, j),)] += tunneling
... # interaction: U n_j n_k with n_j = (1 - V_j) / 2
... data[()] += interaction / 4
... data[((j, j),)] -= interaction / 4
... data[((k, k),)] -= interaction / 4
... data[((j, j), (k, k))] += interaction / 4
... return TransferVertexOperator.from_dict(data)
>>>
>>> rows, cols = 3, 3
>>> num_sites = rows * cols
>>> hamiltonian = fermi_hubbard_2d(rows, cols, tunneling=1.0, interaction=2.0)
>>> print(f"Number of terms: {len(hamiltonian)}")
Number of terms: 46

2. Patru seturi de flux în loc de două​

Fiecare termen de salt este clasificat după direcția în care arată săgeata sa pe rețea. Compararea coordonatelor rând/coloană divmod ale celor două capete separă muchiile orizontale de cele verticale, iar semnul diferenței stabilește orientarea. Tot ce este diagonal (identitatea, termenii VjV_j și VjVkV_j V_k) merge într-un al cincilea grup:

Notă

Convenția de orientare. Numele sunt pur și simplu citite din figura de mai sus: o săgeată este est sau vest după cum arată spre dreapta sau spre stânga de-a lungul unui rând, și nord sau sud după cum arată în sus sau în jos pe o coloană. Deoarece numerotarea merge pe rânduri, estul și sudul sunt direcțiile de creștere a indicelui. Nimic din aval nu depinde de nume (ele doar etichetează grupuri), dar se confundă ușor, așa că merită fixate în raport cu imaginea. Ref. [1] își numește seturile de flux pe rețea pătrată în același fel.

>>> EAST, WEST, NORTH, SOUTH, DIAGONAL = range(5)
>>>
>>> def flow_set_groups(operator, cols):
... groups = []
... for terms, _ in operator.iter_terms():
... if len(terms) == 1 and terms[0][0] != terms[0][1]:
... j, k = terms[0]
... row_j, col_j = divmod(j, cols)
... row_k, col_k = divmod(k, cols)
... if row_j == row_k:
... groups.append(EAST if col_k > col_j else WEST)
... else:
... groups.append(SOUTH if row_k > row_j else NORTH)
... else:
... groups.append(DIAGONAL)
... return groups
>>>
>>> hamiltonian.groups = flow_set_groups(hamiltonian, cols)
>>>
>>> names = ["east", "west", "north", "south", "diagonal"]
>>> for index, name in enumerate(names):
... count = sum(1 for group in hamiltonian.groups if group == index)
... print(f"{name:9s} {count:2d}")
east 6
west 6
north 6
south 6
diagonal 22

În interiorul unui set de flux, fiecare săgeată se întâlnește cu alta cap la coadă sau nu se întâlnește deloc, ca în 1D, deci un set de flux comută în continuare termen cu termen, iar evoluția lui nu are eroare Trotter. Noutatea este că un set de flux este acum necorelat: setul est al unei rețele rows x cols constă din rows căi independente de la stânga la dreapta. Această deconectare este un avantaj, nu o complicație, după cum arată pasul 6.

3. Codifică operatorul​

Codificarea este o singură funcție care mapează un singur operator de transfer generalizat într-un șir Pauli, care este transmis lui map_transfer_vertex_generators(). Față de versiunea 1D, corpul are mai multe cazuri: diagonal, orizontal, vertical și orientările inverse prin Tkj=−VjVkTjkT_{kj} = -V_j V_k T_{jk}.

Există o problemă. map_transfer_vertex_generators() apelează înapoi doar cu un generator și nimic altceva, în timp ce stabilirea șirului Pauli al unei legături necesită și cols pentru a recupera coordonatele de rând și de coloană dintr-un index de mod. În loc să imbricăm funcțiile pentru a o captura, functools.partial() o leagă, ceea ce păstrează ambele funcții la nivelul superior și apelabile independent.

>>> from functools import partial
>>>
>>> from qiskit.quantum_info import SparseObservable
>>> from qiskit_fermions.mappers import map_transfer_vertex_generators
>>>
>>> def ancilla_qubit(mode, num_sites):
... """The auxiliary qubit paired with fermionic mode `mode`."""
... return num_sites + mode
>>>
>>> def vc_action(generator, width, cols):
... """Map one generalized transfer operator to its Verstraete-Cirac Pauli."""
... num_sites = width // 2
... ancilla = partial(ancilla_qubit, num_sites=num_sites)
... j, k = generator
... if j == k: # vertex operator V_j = Z_j
... return SparseObservable.from_sparse_list([("Z", [j], 1.0)], num_qubits=width)
... row_j, col_j = divmod(j, cols)
... row_k, col_k = divmod(k, cols)
... if row_j == row_k: # horizontal: weight-3 triangle
... left, right = (j, k) if col_j < col_k else (k, j)
... operator = SparseObservable.from_sparse_list(
... [("XXZ", [left, right, ancilla(left)], 0.5)], num_qubits=width
... )
... forward = (j, k) == (left, right)
... else: # vertical: weight-4 square
... up, down = (j, k) if row_j < row_k else (k, j)
... operator = SparseObservable.from_sparse_list(
... [("XYXY", [up, down, ancilla(up), ancilla(down)], 0.5)], num_qubits=width
... )
... forward = (j, k) == (up, down)
... if forward:
... return operator
... # reverse orientation: T_kj = -V_j V_k T_jk
... v_j = SparseObservable.from_sparse_list([("Z", [j], 1.0)], num_qubits=width)
... v_k = SparseObservable.from_sparse_list([("Z", [k], 1.0)], num_qubits=width)
... return (-1.0 * v_j.compose(v_k).compose(operator)).simplify()
>>>
>>> def vc_encoding(operator, width, cols):
... """Map a TransferVertexOperator to a qubit observable, Verstraete-Cirac style."""
... return map_transfer_vertex_generators(
... operator,
... partial(vc_action, width=width, cols=cols),
... identity=lambda: SparseObservable.identity(width),
... compose=SparseObservable.compose,
... ).simplify()
>>>
>>> # bind the lattice width once; the transpiler only ever passes (operator, width)
>>> encoding = partial(vc_encoding, cols=cols)
>>> num_qubits = 2 * num_sites
>>> num_qubits
18

Avantajul grupării înainte de mapare se vede imediat: fiecare set de flux se codifică într-o singură formă Pauli, uniformă pe întregul set.

>>> for index, name in enumerate(names):
... observable = encoding(hamiltonian.split_out_groups()[index], num_qubits)
... shapes = sorted({label for label, _, _ in observable.to_sparse_list()})
... print(f"{name:9s} {shapes}")
east ['XXZ']
west ['YYZ']
north ['YXXY']
south ['XYXY']
diagonal ['', 'Z', 'ZZ']

Cele patru forme de salt sunt distincte, deci fiecare set de flux primește propriul Clifford în pasul 5. Orientările inverse sunt Pauli diferiți, nu aceiași redenumiți; de aceea o sinteză de seturi de flux 2D are nevoie de patru circuite, în timp ce sinteza 1D are nevoie de unul.

4. Transpilează cu codificarea personalizată​

Această etapă este cea 1D cu un registru de qubiți mai larg: CustomF2QLayout mapează registrul de NN moduri pe 2N2N qubiți, MapperFnEvolutionSynthesis poartă codificarea, iar argumentul său product_formula selectează cum este descompus PauliEvolutionGate rezultat. Compară cu pasul 6 din ghidul 1D, care construiește același flux pentru o singură ancilă.

>>> from qiskit.circuit import QuantumRegister
>>> from qiskit.passmanager import MultiStagePassManager
>>> from qiskit.synthesis import LieTrotter
>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.circuit.library.synthesis import FermionicLieTrotter
>>> from qiskit_fermions.transpiler import FermionicCircuitToDAG, QuantumDAGToCircuit
>>> from qiskit_fermions.transpiler.passes import (
... CustomF2QLayout,
... F2QSynthesis,
... MapperFnEvolutionSynthesis,
... )
>>>
>>> total_time = 1.0
>>>
>>> def flow_set_pass_manager(mode_register, encoding, product_formula=None):
... """Build the pipeline mapping `mode_register` with the VC `encoding`."""
... synthesis = F2QSynthesis()
... synthesis.methods["Evolution"] = MapperFnEvolutionSynthesis(
... encoding, product_formula=product_formula
... )
... # this encoding spends one ancilla per fermionic mode
... qubit_register = QuantumRegister(2 * mode_register.size, "q")
... return MultiStagePassManager(
... input=FermionicCircuitToDAG(),
... layout=CustomF2QLayout({mode_register: qubit_register}),
... synthesis=synthesis,
... output=QuantumDAGToCircuit(),
... )
>>>
>>> circuit = FermionicCircuit(num_sites)
>>> circuit.append(
... Evolution(
... num_sites, hamiltonian, time=total_time, synthesis=FermionicLieTrotter()
... ),
... circuit.modes,
... )
>>>
>>> # as in 1D, one decompose() splits the Evolution group by group (five gates here,
>>> # the four hopping flow sets in group order, then the diagonal interaction)
>>> flow_set_circuit = circuit.decompose()
>>> flow_set_circuit.count_ops()["Evolution"]
5

Cinci porți, purtând cei 6, 6, 6, 6 și 22 de termeni numărați în pasul 2. Fiecare este mapată și sintetizată independent, astfel încât partiția decisă la nivel fermionic supraviețuiește până în circuit:

>>> pass_manager = flow_set_pass_manager(flow_set_circuit.register, encoding, LieTrotter())
>>> qubit_circuit = pass_manager.run(flow_set_circuit)
>>> qubit_circuit.num_qubits
18
>>>
>>> def two_qubit_stats(circuit):
... decomposed = circuit.decompose(reps=6)
... two_qubit = lambda instruction: len(instruction.qubits) == 2
... return decomposed.count_ops().get("cx", 0), decomposed.depth(two_qubit)
>>>
>>> two_qubit_stats(qubit_circuit)
(144, 47)

Aceasta este linia de bază: codificarea și gruparea sunt ambele la locul lor, dar LieTrotter sintetizează fiecare set de flux termen cu termen, iar adâncimea crește odată cu rețeaua.

5. Un Clifford de adâncime 2 pentru fiecare set de flux​

Mecanismul este același ca în 1D: conjugă un întreg set de flux comutativ cu un Clifford care mapează fiecare dintre termenii săi într-un Pauli distinct de pondere 1, astfel încât evoluția întregului set costă doar rotații ale unui singur qubit. În 1D, o structură de tip zidărie de porți CZ făcea acest lucru.

În 2D, fiecare set de flux are nevoie de propriul circuit, urmând Secțiunea IV C și Fig. 7 din Ref. [1]. Toate patru acționează pe câte o linie a rețelei odată (un rând pentru est/vest, o coloană pentru nord/sud) împreună cu ancilele acelei linii, și toate patru au aceeași formă în trei părți:

  • Un strat de rotații ale unui singur qubit, care pune fiecare qubit în baza de care are nevoie setul său de flux.

  • Un prim strat CX, mereu de la situs la ancila de pe același situs.

  • Un al doilea strat CX, care cuplează vecinii de-a lungul liniei.

Pașii 2 și 3 sunt singurele straturi cu doi qubiți, deci adâncimea cu doi qubiți este 22, indiferent cât de lungă este linia. Sunt scrise mai jos câte o funcție pentru fiecare set de flux. Rotațiile sunt așa cum sunt pentru că au fost găsite, conjugând termenii fiecărui set de flux și cerând imagini distincte de pondere 1, așa că tratează-le ca date și bazează-te pe verificarea care urmează.

Cele două seturi orizontale sunt cele mai simple. Termenii lor au pondere 3, XXZ și YYZ, și diferă doar prin SS-ul suplimentar care transformă XX în YY pe situsuri:

>>> from qiskit.circuit import QuantumCircuit
>>>
>>> def east_clifford(circuit, line, ancilla):
... """Diagonalize the east flow set (XXZ) on one lattice row."""
... for j in line:
... circuit.h(ancilla(j))
... for j in line[:-1]: # layer 1: each site with its own ancilla
... circuit.cx(ancilla(j), j)
... for j, k in zip(line, line[1:]): # layer 2: ancilla with the next site along
... circuit.cx(k, ancilla(j))
>>>
>>> def west_clifford(circuit, line, ancilla):
... """Diagonalize the west flow set (YYZ) on one lattice row."""
... for j in line:
... circuit.s(j) # the only difference from east: X -> Y on the sites
... circuit.h(ancilla(j))
... for j in line[:-1]:
... circuit.cx(ancilla(j), j)
... for j, k in zip(line, line[1:]):
... circuit.cx(k, ancilla(j))

Cele două seturi verticale au pondere 4, XYXY și YXXY, și sunt corespunzător mai încărcate: rotațiile alternează cu poziția de-a lungul liniei, iar al doilea strat CX alternează și el, cuplând situsuri pe pozițiile pare și ancile pe cele impare (sau invers):

>>> def south_clifford(circuit, line, ancilla):
... """Diagonalize the south flow set (XYXY) on one lattice column."""
... for position, j in enumerate(line):
... if position % 2 == 0:
... circuit.sdg(ancilla(j))
... circuit.h(ancilla(j))
... else:
... circuit.h(j)
... circuit.s(j)
... circuit.h(ancilla(j))
... circuit.s(ancilla(j))
... for j in line: # layer 1
... circuit.cx(ancilla(j), j)
... for position, (j, k) in enumerate(zip(line, line[1:])): # layer 2
... if position % 2 == 0:
... circuit.cx(ancilla(j), ancilla(k)) # ancilla to ancilla
... else:
... circuit.cx(j, k) # site to site
>>>
>>> def north_clifford(circuit, line, ancilla):
... """Diagonalize the north flow set (YXXY) on one lattice column."""
... for position, j in enumerate(line):
... if position % 2 == 0:
... circuit.h(ancilla(j))
... circuit.s(ancilla(j))
... else:
... circuit.h(j)
... circuit.s(j)
... circuit.sdg(ancilla(j))
... circuit.h(ancilla(j))
... for j in line: # layer 1
... circuit.cx(ancilla(j), j)
... for position, (j, k) in enumerate(zip(line, line[1:])): # layer 2
... if position % 2 == 0:
... circuit.cx(k, j) # site to site, and the other way round from south
... else:
... circuit.cx(ancilla(j), ancilla(k))

Desenat pe o singură linie de trei situsuri (întâi situsurile, apoi ancilele lor), întregul mecanism încape într-o singură imagine. Forma triunghi este cea mai clară dintre cele două: o schimbare de bază pe ancile, apoi două straturi CX, primul împerechează fiecare situs cu propria ancilă, iar al doilea ajunge la următorul situs de-a lungul liniei.

>>> line = [0, 1, 2] # three sites, with ancillas on qubits 3, 4, 5
>>> triangle = QuantumCircuit(6)
>>> east_clifford(triangle, line, lambda mode: 3 + mode)
>>> triangle.draw("mpl")
<Figure size ... with 1 Axes>

The east Clifford on a three-site line: three H gates on the ancillas followed by two layers of two CX gates each.

Forma pătrat are nevoie de mai multă muncă pe un singur qubit (rotațiile alternează de-a lungul liniei), iar al doilea strat CX alternează între cuplarea situsurilor și cuplarea ancilelor:

>>> square = QuantumCircuit(6)
>>> south_clifford(square, line, lambda mode: 3 + mode)
>>> square.draw("mpl")
<Figure size ... with 1 Axes>

The south Clifford on a three-site line: alternating single-qubit rotations followed by two CX layers, the second mixing site-site and ancilla-ancilla pairs.

Ambele au adâncimea de două straturi CX, iar adăugarea de situsuri pe linie le lărgește fără a le adânci; aceasta este întreaga afirmație de adâncime constantă, vizibilă prin inspecție. Clifford-ii vest și nord diferă doar prin straturile lor de un singur qubit, așa că nu sunt desenați separat.

Ceea ce sinteza încă are nevoie este să determine unde ajunge fiecare termen. O linie de LL situsuri are L−1L - 1 legături, fiecare contribuind cu un termen, deci funcția ajutătoare de mai jos returnează câte o intrare pe legătură: qubitul pe care a ajuns termenul și Pauli-ul de un singur qubit în care s-a transformat. Esențial, acestea sunt citite din conjugare, nu prezise. Spre deosebire de 1D, unde o structură CZ mapează Xj+1↦ZjXj+1Zj+2X_{j+1} \mapsto Z_j X_{j+1} Z_{j+2} în formă închisă, aici nu există o asemenea formulă, iar ancilele unei linii de coloană nu sunt contigue. Singurul lucru care diferă între seturile de flux este șirul Pauli de intrare, pe care pasul 3 l-a afișat deja:

>>> from qiskit.quantum_info import Clifford, SparsePauliOp
>>>
>>> def clifford_supports(clifford, pauli, line, ancilla, num_qubits):
... """Conjugate a flow set's terms to find which qubit each one lands on.
...
... `pauli` is the flow set's Pauli string, as printed in step 3. Its letters apply
... to the i-th term's qubits in the order below: the two sites of the bond, then
... their ancillas.
...
... This is the one check that needs SparsePauliOp: Clifford conjugation wants a
... `Pauli` and its symplectic bits, and SparseObservable exposes neither -- its own
... `evolve` conjugates by a Pauli, not by a Clifford.
... """
... supports = []
... for i in range(len(line) - 1):
... qubits = [line[i], line[i + 1], ancilla(line[i]), ancilla(line[i + 1])]
... term = SparsePauliOp.from_sparse_list(
... [(pauli, qubits[: len(pauli)], 1.0)], num_qubits=num_qubits
... )
... image = term.paulis[0].evolve(clifford, frame="s")
... # a weight-1 image has exactly one qubit with an X and/or Z bit set
... (support,) = np.nonzero(image.x | image.z)
... assert len(support) == 1, f"term {i} is not weight-1"
... supports.append((int(support[0]), image[support].to_label()))
... return supports

Imaginile trebuie să iasă distincte și de pondere 1, astfel încât fiecare termen să poată fi rotit independent pe propriul qubit; adâncimea cu doi qubiți de 22 este deja vizibilă în cele patru construcții de mai sus, fiecare emițând două straturi CX indiferent de lungimea liniei. Mai jos, ambele proprietăți sunt verificate pentru fiecare set de flux, fiecare linie și mai multe dimensiuni de rețea. Pasul 6 apelează apoi clifford_supports, deci este o parte funcțională a sintezei, nu doar o demonstrație.

6. Sinteză conștientă de seturile de flux​

Sinteza primește un avans util. Deoarece Evolution se împarte singur grup cu grup, synthesize este apelată o dată pentru fiecare set de flux (de cinci ori aici, patru seturi de salt plus grupul diagonal), deci fiecare termen de salt pe care îl vede are aceeași formă Pauli, iar un singur Clifford îi diagonalizează pe toți. Trebuie doar să citească acea formă din primul termen și să sorteze restul după linia pe care se află. Apoi urmează observația care face ca două dimensiuni să fie mai ieftine decât ne-am teme:

Important

Componentele unui singur set de flux sunt disjuncte pe vârfuri: rândurile setului vest nu au qubiți comuni. Deci toți Clifford-ii unui set de flux pot fi emiși împreună și împart aceleași două straturi cu doi qubiți, în loc să stea la coadă unul după altul. Un set de flux costă astfel un număr fix de straturi (două pentru Clifford și două pentru inversul său), indiferent câte rânduri sau coloane are rețeaua, motiv pentru care adâncimea totală ajunge constantă.

>>> from qiskit.synthesis.evolution import EvolutionSynthesis
>>>
>>> # The interaction couples nearest neighbors only, so every interior site carries four
>>> # Rzz gates -- one per bond -- and can only do one at a time: four layers is the best
>>> # any schedule can achieve. Horizontal bonds split by column parity, vertical ones by
>>> # row parity, which achieves it.
>>> def rzz_layers(circuit, angles, cols):
... """Emit the diagonal Rzz gates in four layers of disjoint qubit pairs."""
... horizontal = [(j, k) for j, k in angles if k - j == 1]
... vertical = [(j, k) for j, k in angles if k - j == cols]
... schedule = [
... [(j, k) for j, k in horizontal if (j % cols) % 2 == 0],
... [(j, k) for j, k in horizontal if (j % cols) % 2 == 1],
... [(j, k) for j, k in vertical if (j // cols) % 2 == 0],
... [(j, k) for j, k in vertical if (j // cols) % 2 == 1],
... ]
... # emit layer by layer, since Qiskit's depth follows the order gates were added
... for layer in schedule:
... for j, k in layer:
... circuit.rzz(angles[j, k], j, k)
>>>
>>> class FlowSetSynthesis(EvolutionSynthesis):
... """Synthesize one 2D line flow set at a time, via its depth-2 Clifford."""
...
... preserve_order = True
...
... def __init__(self, rows, cols):
... self.rows, self.cols = rows, cols
...
... def rows_of_lattice(self):
... return [[r * self.cols + c for c in range(self.cols)] for r in range(self.rows)]
...
... def columns_of_lattice(self):
... return [[r * self.cols + c for r in range(self.rows)] for c in range(self.cols)]
...
... def synthesize(self, evolution):
... num_qubits = 2 * self.rows * self.cols
... ancilla = partial(ancilla_qubit, num_sites=self.rows * self.cols)
... circuit = QuantumCircuit(evolution.operator.num_qubits)
... terms = [
... (label, indices, 2 * evolution.time * coeff.real)
... for label, indices, coeff in evolution.operator.to_sparse_list()
... ]
...
... # `Evolution.decompose()` hands us one gate per flow set, so every hopping term
... # here shares a single Pauli shape and one Clifford diagonalizes all of them
... shapes = tuple({label for label, _, _ in terms} - {"", "Z", "ZZ"})
... match shapes:
... case ("XXZ",):
... clifford_function, lines = east_clifford, self.rows_of_lattice()
... along_rows = True
... case ("YYZ",):
... clifford_function, lines = west_clifford, self.rows_of_lattice()
... along_rows = True
... case ("XYXY",):
... clifford_function, lines = south_clifford, self.columns_of_lattice()
... along_rows = False
... case ("YXXY",):
... clifford_function, lines = north_clifford, self.columns_of_lattice()
... along_rows = False
... case (): # the diagonal group: no conjugation needed
... self.diagonal(circuit, terms)
... return circuit
... case _:
... raise ValueError(f"expected one known flow set per gate, got {shapes}")
...
... # a term's line is the row it sits in and its slot the column, or vice versa
... rotations: dict[int, list] = defaultdict(list)
... for _, indices, angle in terms:
... row, col = divmod(indices[0], self.cols)
... line, slot = (row, col) if along_rows else (col, row)
... rotations[line].append((slot, angle))
...
... active = sorted(rotations)
... # the lines of one flow set are vertex-disjoint, so all of their Cliffords
... # are emitted together and share the same two two-qubit layers
... conjugation = QuantumCircuit(num_qubits)
... for line in active:
... clifford_function(conjugation, lines[line], ancilla)
... circuit.compose(conjugation, inplace=True)
... for line in active:
... supports = clifford_supports(
... Clifford(conjugation), shapes[0], lines[line], ancilla, num_qubits
... )
... for slot, angle in rotations[line]:
... match supports[slot]:
... case qubit, "X":
... circuit.rx(angle, qubit)
... case qubit, "Y":
... circuit.ry(angle, qubit)
... case qubit, "Z":
... circuit.rz(angle, qubit)
... circuit.compose(conjugation.inverse(), inplace=True)
... return circuit
...
... def diagonal(self, circuit, terms):
... """Synthesize the diagonal group: a phase, single-qubit Z and paired Rzz."""
... rzz = {}
... for label, indices, angle in terms:
... match label, indices:
... case "", _: # the identity contributes only a global phase
... circuit.global_phase -= angle / 2
... case "Z", [qubit]: # a lone vertex operator
... circuit.rz(angle, qubit)
... case "ZZ", [j, k]: # interaction, see rzz_layers above
... rzz[min(j, k), max(j, k)] = angle
... case _:
... raise ValueError(f"unexpected Pauli term: {label} on {indices}")
... rzz_layers(circuit, rzz, self.cols)

Ca în 1D, aceasta este scrisă deliberat pentru această codificare: recunoaște cele patru forme Pauli și generează o eroare pentru orice altceva, în loc să revină tăcut la altă variantă. De asemenea presupune împărțirea de mai sus, un set de flux pe poartă, și o spune; mai mult de o formă de salt într-o singură poartă înseamnă că gruparea nu a fost aplicată, ceea ce este o eroare, nu ceva ce trebuie ocolit.

Notă

Ca omologul ei 1D, această sinteză nu primește un număr de pași Trotter. Un pas Trotter trebuie să circule între seturile de flux, dar transpile_trotter_step de mai jos descompune circuitul fermionic într-o poartă Evolution pe set, așa că această metodă vede mereu un singur set odată și ar putea repeta doar în interiorul lui, ceea ce nu schimbă nimic: repetarea unui singur operator doar își împarte propria evoluție în bucăți egale.

Trotterizarea cu mai mulți pași are deci nevoie de o formulă de produs care respectă groups ale operatorului în interiorul unei singure porți, înainte de descompunere. FermionicLieTrotter face asta, iar pasul 10 îl folosește pentru a măsura convergența.

Observă cele două clase cu nume asemănătoare. LieTrotter din Qiskit, folosită mai sus ca linie de bază, acționează pe Hamiltonianul mapat, unde gruparea a dispărut deja. FermionicLieTrotter este omologul fermionic și este cea care încă poate vedea seturile de flux.

7. Rezultatul: adâncime constantă​

Tot ce s-a făcut până acum a rulat pe singura rețea 3×33 \times 3 construită în pasul 1. Compararea celor două căi de sinteză pe rețele în creștere necesită repetarea întregii secvențe (operator, grupare, codificare, circuit, manager de treceri) pentru fiecare dimensiune, așa că merită împachetată o singură dată:

>>> def transpile_trotter_step(rows, cols, product_formula, steps=1):
... """Transpile `steps` Trotter steps on a `rows` x `cols` lattice."""
... num_sites = rows * cols
... operator = fermi_hubbard_2d(rows, cols, 1.0, 2.0)
... operator.groups = flow_set_groups(operator, cols)
... encoding = partial(vc_encoding, cols=cols)
...
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(
... num_sites,
... operator,
... time=total_time,
... synthesis=FermionicLieTrotter(reps=steps),
... ),
... circuit.modes,
... )
... circuit = circuit.decompose() # split into one Evolution per flow set
...
... pass_manager = flow_set_pass_manager(circuit.register, encoding, product_formula)
... return pass_manager.run(circuit)

Colectarea ambelor metrici pe un interval de dimensiuni ale rețelei și reprezentarea lor grafică în raport cu latura rețelei face diferența evidentă:

>>> sizes = [3, 4, 5, 6, 7, 8]
>>> lie_trotter = [
... two_qubit_stats(transpile_trotter_step(n, n, LieTrotter())) for n in sizes
... ]
>>> flow = [
... two_qubit_stats(transpile_trotter_step(n, n, FlowSetSynthesis(n, n))) for n in sizes
... ]
>>>
>>> figure, axes = plt.subplots(1, 2, figsize=(9, 3.5), layout="constrained")
>>> for panel, index, title in zip(axes, (1, 0), ("two-qubit depth", "CX count")):
... _ = panel.plot(sizes, [stat[index] for stat in lie_trotter], "o-", label="qubit-level")
... _ = panel.plot(sizes, [stat[index] for stat in flow], "s-", label="flow set")
... _ = panel.set(xlabel="lattice edge $L$ (an $L \\times L$ lattice)", title=title)
... _ = panel.legend()
>>> figure
<Figure size ... with 2 Axes>

Two-qubit depth and CX count against lattice edge; the qubit-level LieTrotter grows linearly while the flow-set synthesis stays flat at 24.

Adâncimea cu doi qubiți este 24 și constantă de la 3×33 \times 3 la 8×88 \times 8 (adică de la 18 qubiți până la 128), în timp ce LieTrotter aplicat Hamiltonianului codificat identic crește liniar cu latura rețelei, de la 47 la 92. Numărul de porți crește în ambele cazuri, deoarece există O(N)O(N) termeni de aplicat oricum; ceea ce elimină seturile de flux este creșterea numărului de straturi pe care le ocupă acele porți.

Bugetul se împarte în 16 pentru salt și opt pentru interacțiune:

  • Fiecare dintre cele patru seturi de flux costă 2+22 + 2 straturi cu doi qubiți: Clifford-ul său la intrare și inversul la ieșire, ambele de adâncime doi prin construcție, cu doar rotații de un singur qubit între ele. Asta înseamnă 4×4=164 \times 4 = 16, cifra pe care o citează Ref. [1], deoarece numără doar termenii de salt. Setarea U=0U = 0 în codul de mai sus o reproduce.

  • Grupul diagonal adaugă restul de opt. Porțile sale Rzz se împachetează în patru straturi, iar fiecare Rzz se descompune în două porți CX în jurul unei rotații de un singur qubit, deci 4×2=84 \times 2 = 8. Patru straturi este optim: pe o rețea pătrată, interacțiunea ajunge doar la cei mai apropiați vecini, deci fiecare situs interior poartă patru porți Rzz (una pe legătură) și poate participa la una singură odată.

Nimic din niciuna dintre sume nu se referă la dimensiunea rețelei, ceea ce este tot scopul.

8. Verifică circuitul​

Sinteza pe seturi de flux și LieTrotter descriu același pas Trotter al aceluiași Hamiltonian codificat, deci unitarele lor trebuie să coincidă până la o fază globală. Vectorii de stare aleatori detectează orice discrepanță mult mai ieftin decât construirea unitarei complete:

Notă

Ambele circuite sunt descompuse până la porți cu unul și doi qubiți înainte de a fi aplicate unei stări. Dacă rămân nedescompuse, fiecare instrucțiune de nivel înalt Evolution obligă Statevector să construiască operatorul dens complet 22N×22N2^{2N} \times 2^{2N}; descompunerea mai întâi îi permite să aplice porțile pe rând, ceea ce este aici de câteva sute de ori mai rapid.

>>> import numpy as np
>>> from qiskit.quantum_info import Statevector
>>>
>>> rng = np.random.default_rng(1234)
>>>
>>> def agrees_up_to_one_phase(rows, cols, samples=3):
... """Compare both syntheses on random states.
...
... Returns whether they agree up to a phase, and whether it is the *same* phase
... for every input state.
... """
... lie_trotter = transpile_trotter_step(
... rows, cols, LieTrotter()
... ).decompose(reps=6)
... flow = transpile_trotter_step(
... rows, cols, FlowSetSynthesis(rows, cols)
... ).decompose(reps=6)
... dimension = 2**lie_trotter.num_qubits
... residuals, phases = [], []
... for _ in range(samples):
... vector = rng.normal(size=dimension) + 1j * rng.normal(size=dimension)
... state = Statevector(vector / np.linalg.norm(vector))
... a = state.evolve(lie_trotter).data
... b = state.evolve(flow).data
... overlap = np.vdot(a, b)
... phases.append(overlap / abs(overlap))
... residuals.append(np.linalg.norm(b - a * overlap / abs(overlap)))
... # the SAME global phase must work for every input state
... spread = max(abs(phase - phases[0]) for phase in phases)
... return bool(max(residuals) < 1e-9), bool(spread < 1e-9)
>>>
>>> agrees_up_to_one_phase(rows, cols)
(True, True)
Notă

Ambele jumătăți ale acelei verificări contează. O suprapunere de modul unitar pe o singură stare este necesară, dar nu suficientă: o sinteză care greșește o fază dependentă de stare ar fi totuși trecută. Cerința aceleiași faze pe mai multe stări aleatorii este ceea ce închide breșa.

9. Verifică fizica pe subspațiul stabilizator​

Aceasta este particularitatea bidimensională. În 1D, codificarea era o izometrie, iar compararea spectrelor cu o referință Jordan-Wigner era suficientă. VC nu este o izometrie: folosește 2N2N qubiți pentru NN moduri, deci un spațiu de dimensiune 22N2^{2N} conține unul fizic de dimensiune 2N2^N. Aceasta este generică pentru codificările locale în două dimensiuni (qubiții în plus sunt constrânși de simetrii locale comutative, una pe plachetă [2] [3]) și înseamnă că Hamiltonianul codificat reprezintă fidel pe cel fermionic doar pe spațiul propriu comun +1+1 al acelor constrângeri.

Avertisment

Cuvântul stabilizator are o dublă folosire în acest domeniu, iar cele două sensuri nu au legătură. Ref. [1] îl folosește pentru operatorii de transfer comutativi ai unui set de flux, care formează un grup stabilizator pe care Clifford-ii din pasul 5 îl diagonalizează (un artificiu de compilare, și sensul avut în vedere peste tot mai sus). Această secțiune îl folosește în sensul corecției de erori: operatori al căror spațiu propriu +1+1 este subspațiul fizic. Ref. [1] semnalează explicit această coliziune și lasă deoparte al doilea sens; constrângerile de plachetă de mai jos sunt o proprietate a codificării VC [2], nu un rezultat al cadrului seturilor de flux.

Generatorii sunt construiți aici, nu citați: câte unul pe fiecare față pătrată, iar fiecare este pur și simplu bucla de operatori de muchie codificați în jurul acelei fețe.

Operatorii de muchie sunt obiectul natural pentru aceasta. O buclă închisă de ei este o simetrie a oricărui Hamiltonian care conservă numărul de particule, în timp ce bucla corespunzătoare de operatori de transfer nu este: cele două diferă printr-un operator de vârf pe situsul sursă al fiecărei legături,

Tjk=i2 VjEjk⟺Ejk=−2i VjTjk ,T_{jk} = \tfrac{i}{2} \, V_j E_{jk} \qquad \Longleftrightarrow \qquad E_{jk} = -2i \, V_j T_{jk} \, ,

În jurul unei bucle închise, fiecare situs este sursa exact unei legături, deci bucla colectează câte un VjV_j pe situs, adică paritatea locală a celor patru situsuri ale plachetei. Acea paritate locală face diferența: Hamiltonianul transferă particule peste frontiera plachetei, deci conservă doar paritatea globală. Bucla simplă de transfer nu comută deci cu ea, în timp ce bucla de muchie (care poartă factorul de paritate) comută.

Deoarece codificarea este definită pe operatori de transfer, maparea unui operator de muchie necesită relația de mai sus. Acesta este un callback de cinci rânduri, care se potrivește în map_edge_vertex_generators() exact așa cum vc_action se potrivește în map_transfer_vertex_generators():

>>> from qiskit_fermions.mappers import map_edge_vertex_generators
>>> from qiskit_fermions.operators import EdgeVertexOperator
>>>
>>> def vc_edge_action(generator, width, cols):
... """Map one generalized *edge* operator to its Verstraete-Cirac Pauli."""
... j, k = generator
... if j == k: # a vertex operator is the same object in both algebras
... return SparseObservable.from_sparse_list([("Z", [j], 1.0)], num_qubits=width)
... # The library decomposes T_jk = (i/2) V_j E_jk, so E_jk = -2i V_j T_jk. Reuse the
... # transfer encoding for T_jk rather than repeating its four cases here. Note that
... # `compose` left-multiplies its argument, so this composes to V_j T_jk; the other
... # order carries the opposite sign, since a vertex and a transfer operator sharing a
... # mode anticommute.
... vertex = SparseObservable.from_sparse_list([("Z", [j], 1.0)], num_qubits=width)
... return (-2.0j * vc_action(generator, width, cols).compose(vertex)).simplify()
>>>
>>> def vc_edge_encoding(operator, width, cols):
... """Map an EdgeVertexOperator to a qubit observable, Verstraete-Cirac style."""
... return map_edge_vertex_generators(
... operator,
... partial(vc_edge_action, width=width, cols=cols),
... identity=lambda: SparseObservable.identity(width),
... compose=SparseObservable.compose,
... ).simplify()
>>>
>>> def plaquette_loops(rows, cols):
... """The directed edge loop around each square face."""
... at = lambda r, c: r * cols + c
... return [
... [(at(r, c), at(r, c + 1)), (at(r, c + 1), at(r + 1, c + 1)),
... (at(r + 1, c + 1), at(r + 1, c)), (at(r + 1, c), at(r, c))]
... for r in range(rows - 1)
... for c in range(cols - 1)
... ]
>>>
>>> def stabilizers(rows, cols):
... """One plaquette stabilizer per square face of the lattice."""
... num_qubits = 2 * rows * cols
... out = []
... for loop in plaquette_loops(rows, cols):
... loop_operator = EdgeVertexOperator.from_dict({tuple(loop): 1.0})
... encoded_loop = vc_edge_encoding(loop_operator, num_qubits, cols)
... out.append(encoded_loop / complex(encoded_loop.coeffs[0]))
... return out
>>>
>>> plaquette_stabilizers = stabilizers(rows, cols)
>>> for stabilizer in plaquette_stabilizers:
... print(stabilizer.to_sparse_list())
[('ZZYXXY', [1, 4, 9, 10, 12, 13], (1+0j))]
[('ZZYXXY', [2, 5, 10, 11, 13, 14], (1+0j))]
[('ZZYXXY', [4, 7, 12, 13, 15, 16], (1+0j))]
[('ZZYXXY', [5, 8, 13, 14, 16, 17], (1+0j))]

Fiecare generator are ponderea 66, răspândită pe doi dintre qubiții fizici ai plachetei sale și patru ancile. Sunt involutivi și comută cu Hamiltonianul codificat, deci subspațiul fizic este păstrat de dinamică. Ambele proprietăți sunt enunțuri despre operatori, deci sunt verificate simbolic pe algebra SparseObservable, fără a necesita matrice 218×2182^{18} \times 2^{18}:

>>> encoded = encoding(hamiltonian, num_qubits)
>>> identity = SparseObservable.identity(num_qubits)
>>> for stabilizer in plaquette_stabilizers:
... square = stabilizer.compose(stabilizer).simplify()
... print(square == identity, stabilizer.commutes(encoded))
True True
True True
True True
True True

Cu stabilizatorii la îndemână, comparația nu are nevoie de nicio corespondență de bază, deoarece Vj=ZjV_j = Z_j înseamnă că ocupările stau pe aceiași qubiți fizici în ambele imagini. Deci: scrie o stare produs direct pe qubiții fizici cu ancilele în ∣0⟩|0\rangle, proiecteaz-o pe subspațiul stabilizator, evoluează cu Hamiltonianul codificat și citește densitățile de situs ⟨nj⟩=(1−⟨Zj⟩)/2\langle n_j \rangle = (1 - \langle Z_j \rangle)/2 din ambele părți.

Fiecare pas de aici rămâne la nivel de vector. Proiectorul este aplicat câte un factor (1+S)/2(1 + S)/2 odată, în loc să fie înmulțit complet, iar evoluția în timp folosește expm_multiply(), care calculează e−iHt∣ψ⟩e^{-iHt} |\psi\rangle fără a forma vreodată e−iHte^{-iHt}. Deoarece Hamiltonianul codificat este un operator rar de dimensiune 22N2^{2N}, această distincție face secțiunea ieftină.

>>> from scipy.sparse.linalg import expm_multiply
>>> from qiskit_fermions.mappers.library import transfer_vertex_jordan_wigner
>>>
>>> # an exact evolution time, unrelated to the Trotter step above
>>> evolution_time = 0.6
>>> reference = transfer_vertex_jordan_wigner(hamiltonian, num_sites).simplify()
>>>
>>> def to_sparse_matrix(observable):
... # SparseObservable carries no dense- or sparse-matrix method, so route through
... # SparsePauliOp. This is only ever needed for the numerical checks below --
... # the encoding itself never leaves SparseObservable.
... return SparsePauliOp.from_sparse_observable(observable).to_matrix(sparse=True)
>>>
>>> def basis_state(occupation, num_qubits):
... """The computational basis state with `occupation` on the first qubits.
...
... Qubit `q` carries bit `2**q`, and the ancillas are the *high* qubits `N..2N-1`,
... so the same integer indexes the same site occupations on either register and
... leaves every ancilla in |0>.
... """
... index = int("".join(str(bit) for bit in reversed(occupation)), 2)
... state = np.zeros(2**num_qubits, dtype=complex)
... state[index] = 1.0
... return state
>>>
>>> def project_onto_codespace(state, stabilizers):
... """Apply the projector one (1 + S) factor at a time, then renormalize.
...
... The factors commute, so their product is itself a projector and the order does
... not matter. Multiplying it out would build a dense 2^2N x 2^2N operator; applied
... factor by factor this stays a handful of sparse matrix-vector products.
...
... Each factor is really (1 + S)/2, but every one contributes the same scalar, so the
... halvings only rescale the result and the final normalization absorbs them.
... """
... for stabilizer in stabilizers:
... state += to_sparse_matrix(stabilizer) @ state
... return state / np.linalg.norm(state)
>>>
>>> occupation = [1, 0, 1, 0, 1, 0, 0, 1, 0] # site occupations of the initial state
>>> initial_reference = basis_state(occupation, num_sites)
>>> initial_encoded = project_onto_codespace(
... basis_state(occupation, num_qubits), plaquette_stabilizers
... )
>>>
>>> evolved_encoded = expm_multiply(
... -1j * evolution_time * to_sparse_matrix(encoded), initial_encoded
... )
>>> evolved_reference = expm_multiply(
... -1j * evolution_time * to_sparse_matrix(reference), initial_reference
... )
>>>
>>> def density(state, qubit, width):
... """Site density <n_j> = (1 - <Z_j>) / 2 of a statevector."""
... z = to_sparse_matrix(
... SparseObservable.from_sparse_list([("Z", [qubit], 1.0)], num_qubits=width)
... )
... return (1 - (state.conj() @ (z @ state)).real) / 2
>>>
>>> encoded_densities = [density(evolved_encoded, j, num_qubits) for j in range(num_sites)]
>>> reference_densities = [density(evolved_reference, j, num_sites) for j in range(num_sites)]
>>>
>>> for j, density_value in enumerate(encoded_densities):
... print(f"site {j}: {density_value:.10f}")
site 0: 0.6750993356
site 1: 0.3689791258
site 2: 0.6750993356
site 3: 0.3818130396
site 4: 0.4868585996
site 5: 0.3818130396
site 6: 0.2693544696
site 7: 0.4916285849
site 8: 0.2693544696
>>> np.allclose(encoded_densities, reference_densities, atol=1e-12)
True

Acestea sunt densitățile codificate; coincid cu referința Jordan-Wigner până la precizia mașinii, pe o rețea a cărei codificare este fidelă doar după proiecție.

10. Măsoară convergența Trotter​

Pasul 8 a stabilit că cele două sinteze coincid între ele, ceea ce este un enunț despre echivalență, nu despre acuratețe: ambele descriu același pas Trotter, deci ambele poartă aceeași eroare Trotter. Măsurarea acelei erori necesită o referință exactă, iar pasul 9 tocmai a construit una.

Numărul de pași provine din FermionicLieTrotter, omologul fermionic al formulei la nivel de qubiți folosite ca linie de bază mai devreme. Aplicată întregului operator grupat înainte de descompunere, repetiția sa circulă între cele patru seturi de flux și grupul diagonal, în loc să rămână în interiorul vreunuia dintre ele:

>>> initial_state = project_onto_codespace(
... basis_state(occupation, num_qubits), plaquette_stabilizers
... )
>>> exact = expm_multiply(
... -1j * total_time * to_sparse_matrix(encoded), initial_state
... )
>>>
>>> def trotter_fidelity(steps):
... # `steps` goes to the fermionic formula inside transpile_trotter_step, so the
... # repetition cycles between the flow sets rather than within any one of them
... circuit = transpile_trotter_step(
... rows, cols, FlowSetSynthesis(rows, cols), steps=steps
... )
... evolved = Statevector(initial_state).evolve(circuit.decompose(reps=6))
... return abs(np.vdot(evolved.data, exact)) ** 2
>>>
>>> for steps in (1, 2, 4, 8):
... print(f"{steps} step(s): fidelity {trotter_fidelity(steps):.6f}")
1 step(s): fidelity 0.053820
2 step(s): fidelity 0.704014
4 step(s): fidelity 0.932937
8 step(s): fidelity 0.982687

Fidelitatea crește monoton spre unu pe măsură ce pașii se scurtează, ceea ce este comportamentul pe care trebuie să-l arate o formulă de produs. Scurtarea pașilor este doar unul dintre cele două butoane; FermionicSuzukiTrotter le echilibrează una față de cealaltă la un ordin mai înalt, după cum măsoară Sintetizează o poartă Evolution în spațiu fermionic.

Un singur pas al acestui Hamiltonian la t=1t = 1 este o aproximare slabă (cele patru seturi de salt și interacțiunea nu comută), deci primul rând este departe de unu, și tocmai asta este ideea: convergența este cea care confirmă construcția, nu vreun rând izolat.

Notă

Fiecare factor al programului acoperă toate modurile acestei rețele, deci îngustarea unui factor la suportul său este o operație nulă aici, iar indicii absoluți de qubit pe care îi potrivește FlowSetSynthesis rămân neschimbați. O sinteză scrisă pentru un operator ale cărui grupuri ating doar o parte a registrului ar trebui să țină cont de această reetichetare.

Ce să reții​

  • Două dimensiuni schimbă contabilitatea, nu rețeta. Fiecare ingredient din ghidul 1D se transferă nemodificat: groups pentru grupare, map_transfer_vertex_generators() pentru codificare, CustomF2QLayout pentru registrul nepotrivit și MapperFnEvolutionSynthesis pentru a-l lega de transpiler. Ceea ce a crescut este numărul de cazuri din fiecare.

  • Disjuncția este ceea ce face 2D de adâncime constantă. Un set de flux se descompune în linii disjuncte pe vârfuri, deci Clifford-ii săi sunt emiși în paralel și împart două straturi cu doi qubiți indiferent cât de mare este rețeaua. Patru seturi de flux și un grup diagonal dau apoi un buget fix: aici 1616 pentru cele patru seturi de flux plus 88 pentru grupul de interacțiune diagonală.

  • Fiecare orientare este propria ei formă Pauli. Tkj=−VjVkTjkT_{kj} = -V_j V_k T_{jk} transformă XXZ în YYZ și XYXY în YXXY, deci cele patru seturi de flux au nevoie de patru Clifford-i. Presupunerea că un singur Clifford acoperă ambele orientări ale unei axe este cea mai ușoară cale de a greși o sinteză de seturi de flux 2D.

  • Citește suporturile din conjugare. În 1D, o structură CZ dă imaginile în formă închisă. În 2D nu există o asemenea formulă, iar liniile de coloană au ancile necontigue, deci o presupunere derivată manual despre unde ajunge fiecare termen este fragilă. Conjugarea și inspectarea rezultatului costă o singură construcție Clifford și nu poate fi greșită; înseamnă și că un Clifford la fel de valid, care duce termenii în altă parte, continuă să funcționeze.

  • Lasă gruparea să facă sortarea. Deoarece Evolution se împarte singur pe grupuri, sinteza primește câte un set de flux odată și nu trebuie niciodată să rederive partiția: citește o formă Pauli din primul termen și alege Clifford-ul potrivit. Presupunerea aceasta, și generarea unei erori când nu se verifică, este ceea ce păstrează synthesize scurtă.

  • Numărul de pași se află deasupra împărțirii. Sinteza de aici vede un set de flux pe poartă, deci nu poate subdiviza evoluția între seturi. FermionicLieTrotter face asta în spațiu fermionic, înainte de descompunere, ceea ce face posibil un test de convergență.

  • Verifică pe subspațiu. O codificare cu 2N2N qubiți a NN moduri este fidelă doar acolo unde spun stabilizatorii. Deoarece VC păstrează Vj=ZjV_j = Z_j, cea mai ieftină verificare fiabilă este să proiectezi o stare produs și să compari densitățile: fără intertwiner și fără potrivire spectrală, care este nesigură când Hamiltonianul restrâns este degenerat.

Notă

Hamiltonianul codificat comută cu stabilizatorii, deci proiecția trebuie aplicată doar stării inițiale. Evoluția păstrează subspațiul de la sine. Pe hardware real, aceasta face codificarea utilă și ca resursă de detecție a erorilor: stabilizatorii pot fi măsurați.

Următorii pași​

Două direcții extind ce este mai sus, și ambele sunt schimbări de ingredient, nu de metodă. Prima este codificarea: Verstraete-Cirac a fost aleasă aici deoarece Ref. [1] dă Clifford-ii săi diagonalizatori în formă închisă, dar Derby-Klassen folosește o ancilă pe plachetă impară în loc de una pe situs, pentru un raport qubit-mod de 3/23/2 în loc de 22. Operatorii săi de transfer au forme Pauli diferite, deci o portare înseamnă rescrierea funcției de codificare din pasul 3 și găsirea Clifford-ului pentru fiecare formă nouă; gruparea, layout-ul și scheletul de sinteză se păstrează neatinse.

A doua este rețeaua în sine. Tot ce este aici presupune frontiere deschise și o singură specie fermionică. Frontierele periodice închid fiecare linie într-un ciclu: săgețile comută în continuare, deoarece fiecare se întâlnește cu următoarea cap la coadă, dar cele două forme Clifford din pasul 5 sunt construite pentru o linie cu capete. Un ciclu de LL situsuri are LL legături pe orientare, în timp ce o linie are L−1L - 1, deci legătura de închidere nu are loc în construcție și necesită tratament propriu. Un model cu spin este mai ușor: dublează registrul și adaugă termeni de interacțiune pe situs grupului diagonal, fără a atinge seturile de flux. Niciuna nu perturbă argumentul adâncimii constante, dar ambele schimbă contabilitatea, care, după cum spune prima concluzie, este partea pe care două dimensiuni o fac mai mare.

Important

Conectivitatea hardware nu este tratată aici. Fiecare circuit de mai sus este construit pe un registru abstract în care orice pereche de qubiți poate interacționa, iar adâncimile raportate sunt numărate în acel cadru. Dispozitivele reale au o hartă de cuplare restrânsă, iar maparea acestei codificări pe una este o problemă substanțială în sine: registrul VC este un graf de 2N2N qubiți ale cărui porți merg între un situs și propria ancilă, între situsuri vecine și între ancile vecine, deci un layout trebuie să păstreze toate cele trei tipuri de perechi adiacente simultan. Dacă nu poate, transpiler-ul inserează porți SWAP, iar straturile pe care le adaugă sunt ceea ce construcția cu adâncime constantă cumpăra înapoi. Ref. [1] citează de asemenea cifra sa de adâncime sub un graf de conectivitate pătrată presupus, cu CX ca singurul entangler nativ. Găsirea de layout-uri și strategii de rutare bune pentru codificări locale pe o topologie dată de dispozitiv depășește scopul acestui ghid.

[1] (1,2,3,4,5,6,7,8,9,10)

A. Gandon, S. Piccinelli, M. Rossmannek, F. Tacchino, A. Baiardi, J. Nys, and I. Tavernelli, Stabilizer-based quantum simulation of fermion dynamics with local qubit encodings (2026), arXiv:2512.11418v2.

[2] (1,2,3,4)

F. Verstraete and J. I. Cirac, Mapping local Hamiltonians of fermions to local Hamiltonians of spins, J. Stat. Mech. 2005, P09012 (2005).

[3] (1,2)

Y.-A. Chen and Y. Xu, Equivalence between fermion-to-qubit mappings in two spatial dimensions, PRX Quantum 4, 010326 (2023).