Přeskočit na hlavní obsah

Souhrnná kvantová diagonalizace založená na vzorcích pro jaderný hamiltonián

Odhad využití: 32 sekund na procesoru Nighthawk r2 (POZNÁMKA: Jde pouze o odhad. Skutečná doba běhu se může lišit.)

Výukové cíle​

  • Zjisti, jak se hamiltonián jaderného slupkového modelu, tabelovaný v bázi orbitalů vázané na JJ, mění na hamiltonián qubitů v mm-schématu, kde jeden qubit odpovídá jednomu jednočásticovému stavu.

  • Sestav pevný, nevariační excitační ansatz, jehož úhly pocházejí z poruchové teorie druhého řádu, takže nepotřebuješ žádnou klasickou optimalizační smyčku.

  • Porovnej qubitové a fermionové excitace a změř, jak tato volba ovlivní dvouqubitovou hloubku souboru.

  • Spusť samokonzistentní obnovu konfigurací pomocí qiskit-addon-sqd, když zachovávanými veličinami jsou počty nukleonů, MJM_J a parita místo počtu elektronů a spinu.

  • Použij jeden pracovní postup od 24qubitové úlohy, kterou lze přesně ověřit, až po 40qubitovou úlohu s téměř dvěma miliony bázových stavů, která přesahuje kapacitu přesné diagonalizace v tomto tutoriálu.

Předpoklady​

Než začneš, projdi si následující témata:

Pozadí​

Jaderný slupkový model chápe jádro jako několik valenčních nukleonů pohybujících se v malé sadě jednočásticových orbitalů nad netečným jádrem, které na sebe působí empirickou dvoutělesovou silou nafitovanou na změřená spektra. Je široce používán v jaderné struktuře při nízkých energiích. Jeho výpočetní náročnost je kombinatorická: báze tvoří všechny způsoby rozložení valenčních protonů a neutronů mezi dostupné stavy a tento růst omezuje modelové prostory přístupné přesné diagonalizaci.

Souhrnná kvantová diagonalizace založená na vzorcích (pooled SQD) [1] rozděluje tento problém na dvě části. Kvantový obvod se používá pouze k navržení, které bázové stavy jsou důležité. Měří se ve výpočetní bázi a každý změřený bitový řetězec určuje jeden Slaterův determinant. Hamiltonián se poté sestaví a diagonalizuje klasicky v prostoru generovaném těmito determinanty. Protože klasický krok je přesná diagonalizace uvnitř podprostoru, vrací variační horní mez energie základního stavu a tato mez může s přidáváním determinantů jedině klesat.

Toto rozdělení práce činí metodu odolnou vůči šumu, s jedním důležitým omezením. Šum mění, které determinanty obvod navrhne. Do klasického hamiltoniánu nevstupuje, takže nemůže posunout vlastní hodnotu daného podprostoru: měření, které porušuje zachovávanou veličinu, se zahodí nebo opraví, a měření, které přežije, je legitimní bázový vektor bez ohledu na to, jak vzniklo. Šum tě tedy stojí kvalitu podprostoru, ne správnost, a uváděné číslo je v obou případech horní mez.

Jaderná struktura poskytuje několik přesných kvantových čísel pro filtrování vzorků. Fyzikální determinant musí nést správný počet valenčních protonů a správný počet valenčních neutronů, správnou celkovou projekci momentu hybnosti MJM_J a správnou paritu. Každé z nich lze ověřit celočíselným testem na bitovém řetězci. Podíl zamítnutých vzorků závisí na omezení a modelovém prostoru.

The 24-qubit sd-shell register: three proton orbitals and three neutron orbitals, each split into 2j+1 magnetic substates, one qubit per substate, with the reference determinant of neon-20 filled on the maximal magnetic substates of the 0d5/2 orbital.

Každý qubit je jeden jednočásticový stav mm-schématu (n,ℓ,j,mj,tz)(n, \ell, j, m_j, t_z) a ∣1⟩|1\rangle znamená obsazený. Registr používá pevné pořadí: nejprve protony, pak neutrony; v rámci druhu orbitaly v pořadí souboru; v rámci orbitalu mjm_j sestupně. Dvě poloviny bitového řetězce jsou tedy protonová konfigurace a neutronová konfigurace. Toto je rozdělení na dvě části, které očekávají nástroje pro následné zpracování pooled SQD.

Pracovní postup​

Workflow diagram: a reference determinant feeds an ensemble of shallow excitation circuits, which are sampled on a QPU to produce bitstrings; the bitstrings are repaired and post-selected on proton and neutron number, recombined into a product subspace where the magnetic projection and parity are imposed, and diagonalized to give a variational upper bound; average occupancies from the resulting eigenvector feed back into the next repair.

Jadernými symetriemi se v diagramu zabývají dvě fáze.

Oprava a následný výběr zpracovávají vzorky zasažené šumem hardwaru. Počty nukleonů v obou polovinách registru jsou Hammingovy váhy, takže je qiskit-addon-sqd zpracovává přímo: recover_configurations opraví poškozený bitový řetězec překlopením bitů, které nejméně odpovídají aktuálnímu odhadu průměrných obsazení orbitalů, místo aby měření zahodila.

Součinový podprostor zavádí MJM_J. Protože MJ=Mp+MnM_J = M_p + M_n propojuje obě poloviny, není vlastností žádné z nich, a proto se nesmí používat k filtrování celých měření: bitový řetězec, jehož protonová i neutronová polovina jsou platné, stále přispívá dvěma dobrými polovičními konfiguracemi, i když je jeho celkové MJM_J chybné. Podprostor je proto generován každým součinem vzorkované protonové konfigurace se vzorkovanou neutronovou konfigurací, přičemž se ponechají součiny, které padnou do cílového sektoru MJM_J a parity. To je konstrukce podprostoru pooled SQD a znamená to, že několik tisíc bitových řetězců může generovat podprostor mnohem větší, než je počet vzorků.

Dvě určující rovnice​

Hamiltonián slupkového modelu je jednotělesový člen plus dvoutělesová interakce,

H=∑pεp ap†ap+14∑pqrs⟨pq∥rs⟩ ap†aq†asar,\begin{equation} \tag{1} H = \sum_{p} \varepsilon_p\, a_p^\dagger a_p + \tfrac{1}{4}\sum_{pqrs} \langle pq \| rs \rangle\, a_p^\dagger a_q^\dagger a_s a_r , \end{equation}

kde p,q,r,sp,q,r,s označují stavy mm-schématu a tz=−1t_z = -1 pro proton, +1+1 pro neutron. Empirické interakce jako USDA [2] a GXPF1 [3] jsou tabelovány ne v mm-schématu, ale v bázi vázané na JJ, jako maticové elementy ⟨ab;J∣V∣cd;J⟩\langle ab; J | V | cd; J \rangle mezi normalizovanými antisymetrizovanými dvoutělesovými stavy orbitalů a,b,c,da,b,c,d. Získání elementu mm-schématu je Clebschovo-Gordanovo přepojení,

⟨pq∥rs⟩=1+δab1+δcd∑J⟨jpmp jqmq∣JM⟩⟨jrmr jsms∣JM⟩⟨ab;J∣V∣cd;J⟩,\begin{equation} \tag{2} \langle pq \| rs \rangle = \sqrt{1 + \delta_{ab}}\sqrt{1 + \delta_{cd}} \sum_{J} \langle j_p m_p\, j_q m_q | J M \rangle \langle j_r m_r\, j_s m_s | J M \rangle \langle ab; J | V | cd; J \rangle , \end{equation}

přičemž faktory 1+δ\sqrt{1+\delta} ruší normalizační konvenci tabelovaných stavů. Vše ostatní v tomto tutoriálu je postaveno na těchto dvou rovnicích.

Tři běhy​

JádroSlupkaQubityBáze povolená symetriemiLze přesně ověřit?
Malý rozsah20Ne^{20}\mathrm{Ne} (2p + 2n)sdsd24640Ano
Velký rozsah44Ti^{44}\mathrm{Ti} (2p + 2n)pfpf404,000Ano
Velký rozsah48Cr^{48}\mathrm{Cr} (4p + 4n)pfpf401,963,461Ne

Běh v malém rozsahu je průvodcem postupem. Oba běhy ve velkém rozsahu používají 40qubitový registr: první je stále dost malý na to, aby se dal přesně diagonalizovat na notebooku, takže můžeš porovnat výsledek z hardwaru s přesnou referencí. Druhý přesahuje kapacitu přesné diagonalizace tohoto tutoriálu.

Každý zde uvedený běh se provádí na QPU. Je to volba učiněná pro tento tutoriál, nikoli požadavek metody: všechny tři běhy sdílejí backend a rozpočet hradel, abys mohl porovnat jejich výkon při různých velikostech úlohy.

Požadavky​

Před začátkem nainstaluj následující balíčky:

  • Qiskit SDK v2.0 nebo novější (pip install qiskit)

  • qiskit-ibm-runtime v0.40 or later (pip install qiskit-ibm-runtime)

  • Doplněk SQD v0.12 nebo novější (pip install qiskit-addon-sqd)

  • NumPy, SciPy a Matplotlib (pip install numpy scipy matplotlib)

Dále potřebuješ účet IBM Quantum® s lokálně uloženými přihlašovacími údaji a přístup k QPU s alespoň 40 qubity.

Není potřeba žádný balíček simulátoru a není třeba stahovat žádné datové soubory. Dva soubory interakcí, které tento tutoriál používá, jsou vloženy do následující buňky nastavení a po jejím spuštění se zapíší do dočasného adresáře.

Nastavení​

Tato část importuje nástroje a definuje pomocné funkce slupkového modelu, které pracovní postup potřebuje, v pořadí, v jakém je pracovní postup používá. Fyzika za každou z nich je odvozena v příloze; komentáře popisují roli každé funkce v pracovním postupu.

Nejprve se rozbalí dva soubory interakcí. Oba jsou publikované sady parametrů, vložené sem, aby notebook byl samostatný: usda.snt je hamiltonián USDA pro slupku sdsd [2] a gxpf1.snt je hamiltonián GXPF1 pro slupku pfpf [3].

# Added by doQumentation — installs the packages this notebook needs if they are missing
import importlib.util

_needed = {"matplotlib": "matplotlib", "numpy": "numpy", "qiskit": "qiskit", "qiskit_addon_sqd": "qiskit-addon-sqd", "qiskit_ibm_runtime": "qiskit-ibm-runtime", "scipy": "scipy"}
_missing = [pip for module, pip in _needed.items()
if importlib.util.find_spec(module) is None]
# One at a time, so a package that fails to install does not block the others
for _pip in _missing:
%pip install -q {_pip}
if not _missing:
print("\u2713 All required packages are installed")
from __future__ import annotations

import base64
import gzip
import itertools
import tempfile
from dataclasses import dataclass
from functools import lru_cache
from math import factorial, sqrt
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

from qiskit import QuantumCircuit
from qiskit.circuit.library import PauliEvolutionGate
from qiskit.quantum_info import Operator, SparsePauliOp
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit_addon_sqd.configuration_recovery import recover_configurations
from qiskit_addon_sqd.counts import bit_array_to_arrays
from qiskit_addon_sqd.subsampling import postselect_by_hamming_right_and_left
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2

from scipy.linalg import eigh

_USDA_SNT_GZ = (
"H4sIAI5wqWoC/4WY224bRwyG7/UU9F0CONsZco5AUyBuC/QmQNG06KWhWGql1LYMy+kJefiOTrucGXJrQAgsf5nl8ecOr+CX"
"D9+9g/0K9pv1/T389rx7gPLrsH+Cz/vVctg+vgAsrgB+HeDdAD9t7zYv6+dr+DDA+z8223/X17B8XMFN+ev9+m+4ed799XjA"
"f9z8sy/4+s8BvoWYrsEERwbhFRqTXi+uCvOwW63vYf+0vFvDAgDo/AFIh8/hK7DH30354Omvb8o3V8c/vIUnMKtboK/wiGKF"
"+gnFEfVn9PQUe8bthNIRtftbsGfUtQbAGXUFfawM8K0BF9SP6MWA0BpwQcMRvRhwBSX86+fl3ct2d4jq4+eHa3hYv2x2q7fX"
"AJuPy+fb3cP69+Uh4luAT8djv95++eGV/fj6y6dvFudnmcX4lGOoBmttNOVncTL2FLs3NGT0l++ndJTv0cR8/t6NUanP8WMI"
"6nPC6C8/5wp+vnn//QLOP9ank3k2wRszkDn/Zwv15xiv41l2SDmjhuEFM4PJ0QkYcgzM4A1Jp1GDkcvtaWMAR9tosAa9huHk"
"AtoUBYwaF8hGarAxOywgxnoeN+Se2smF4H3QMPZQ653XMJpOi6GyrcLcZJtzLgoYcdsOyXKibV1Aom0fikJO0VLUMDeW3kAl"
"qQLWpd66qGE02eZ9lXqSPD3UWyl5DeOeGnINJnmaUggCVrtgh2DJNm3fVy8WF3LSMFa9hYga5iYX0IQsYK2nmTpJkoq81Acy"
"jLTTnPEaxqo3JGcbvZMeGnJVbw6mf2cUqcIsO80nFdOFq8JoSlY+Zb7FfNNZlnw2PqOE8SL3PtlSJhJGXGqsDRjbh4bmocWy"
"oiIxSxjv09JYgaxlGLZxA9EFFOImuIBt3EB04TK3Z5S8wizrrFCJQ4Xpgl9hzDYbu0LCNm6HzkJHweUkYazITalyF7NjGAnh"
"FZJFQniFZFHnqWgbdZ6qtoX50VZhvEJ81TJe7AUcQozH8yQMJ8ymUS07jLin6LXT3FRIhmwSTmtbJpEjr2AsvM7E1ra+ZaiM"
"NoxewZinBlPQTmPJyikn7TQ2630SbWuz4CLlwzuGhCHPqbUmRZQwpkiBDn1vsoQ55oKjbEvNNVgbXls0WvA0NKnPpku91Fkx"
"pahhXPDJtxVCQkBcMtJD27I8VJGAhWYYlVuZzc5e+jSIZWnLa57nOQ1iWZY+jSE3GP5/QIKi5E1Agqi9BbMZiUjEWNwChqKq"
"KTZY11ku5ca2Pqc4YKk1o2DstBjJC1htW2nAgEZ4aDdPvQmmmLeY4tWFt9Y3VAdlpW+oDcpa3zpM1jdUB2XV9agOyqrrcWZQ"
"sq7vMMffBseux5l5yjoLZ+Yp6yzU5mmt5DgzT5mSozZPoVIknJmnTJFQHZRV12OrlvpFQFbycoGKOWiYfl9QlXySGtSUvL9W"
"VJi/YG4grC4CmuCX6CZrKUsY64UyJolCQoZJqWeKhDOCzxQJNcGXbZMEX7EtzF+gvCI1npxjmKaW5Q06ewVjOY0u2+Y0kt5D"
"JhlETS2P+5CqyIMYkCI1NK50SO3TarSR2qfVaKOZPmXjg2beVJmS01zqp/CSmvoqvNSOD/kaS+qUoSGaTM2yz83fdjtMvsZy"
"xOv7N44Fff/GT5q5tXWYfB3jWND3b9yumUvK5SQnvK6w/VuHyfu3DpP3bx0m7984FvT9W4fJ+zceWq/v3zpM3r9xLOj7tw6T"
"928X053QgGz/1mHy/u3yZ8lTtn/jWND3b74NiDx2vRbeep56Lbz1oPRaeOv9G0dmxofXPK33bx0m798C1B9RuP4DPJ0WcLAa"
"AAA="
)

_GXPF1_SNT_GZ = (
"H4sIAI5wqWoC/4WcQY8dtw3H7/kU41sCeF8lUqKkQw9NgbaXoEHQQ26BkdiIEWdt2E6L9tNXb9+ORIn8PwdZILB/S3I4IsW/"
"ZiYvjr//+P3f4nG8ef/x+PDm4dOvr9+9++rF8d3l+Mf7x99fvTz+dTn++fnTH7/1//z28pfL8e3H9/95fHm8evzl+lffvf3f"
"H59e/fb26L9zHN//+t9Pl+OH1/++HH89JL88gkQO8esfvjm+phDomyvW//39/S+v3x2fPrz6+fVX/dfS889B4fpz/aN43P7p"
"f3Bw/ynH8fD0Zy+uf/fn48MR3vxU/kRXlp7Z+PzDiqUnNn74iW8sb3azYvm0m29s2uxGxabTbryx2cZ7nGzu7KOKV2y8g5Un"
"dsZbbLyDLafd53irjXew9bR7jffF8fbx8+uPr37+/Pb941dPv3P93ZH4W/If6kUohXCm+Jbmh3yR0jicybwl9CFeuFILy908"
"HtIlcrlZyCNJ2q6MdGi7ZVy4tlvHJS52Y71FnHp8D+HCIYTlQuYFhdsv0yVxzZB6vsxwaZwJUumkIjWB1POdDhcqlR2KVo85"
"SoDU8NgdNodiZevqMUqB1LCVWsO2ZMQlyYs+bXEFk4m5bMJJlVgSpGjaouhQvFKxUIbUjIvI85hWW4FNvubyDnMxMkFKRc8V"
"Uml65OhQW1x9fe3XOAtsZPXJ36TIX18SaoQUnysnZsHUXPfcKqTyaSu36FHbWg0tN0jxWIXEAVLzbqcqkMpj3T8lwlBp9RhD"
"qpAaHpnzni9nRXPh6lDbysmJBVLzDtU71KztnDxqW1+JhDaKnTtUa4DUzH00Hp0VXQMHSM3c16irlpf7GEcN1dogpfpEDpAa"
"WRUOBKl0UqkIQ2qs+5IYU6OvthbFodIaF6/9i51VeF0TLZSN2lfh9T729utQOqtXKi/dl/21mihhW+M+BpIKqblypDWHSntc"
"mSA1O3nMe+6dHp0DEaRmXCU0h9p7dOYCqVm1MehOnvy7nWphSKnuu+Q++X2Ca4uQGrYkxp3yMkFLL0ygavPovmR64dhFY5QM"
"KVK1XRxqX4UydlHCXU44hY1ydndKuUBK11BzqH1NNOKNMvt27PMXVUWBemSqCVIjrr4nYFtzTSQJkJp3O7se09p9YygCKVL7"
"UN4opx5DNnE5q5AlerZ2jyExpEYmggTtMYFrzC1Bak58jetGeSvHenSip9ROik0m5lqdsxzjrCYhTM1uImOHYZzVPvnWjXIm"
"0RoaKyqhjjlqiEE3uWoYHqopbT/KY0pDD+Xtx9d8lvI0n6U8zWcpT/NpQqDms5Sn+TRVoOazlKf5LOVpPk1VqPl05AI1n6U8"
"zaepAjWfpTzNp6kKNZ/OQoGaz1Ke5rOUp/k0VaHm03mvUPOd1+esL6X5LOVpPkt5ms9SnubTVIGaz1Ke5rOUp/ks5Wk+TVWo"
"+SzlaT69mgVqPk0VqPks5Wk+S3maT1MVaj5dPwVqPkt5mk9TFWo+S3ma77x/8z56ms9SnuazlKf5LOVpPkt5ms9SnubTVIWa"
"z1Ke5jupfRWumk9TBWo+S3maz1Ke5rOUp/k0VaHms5Sn+XTnLVDzWcrTfJqqUPNZytN85/1z7rbSfJbyNN/5t06fUJrPUp7m"
"OykvE7T0wgqqdmo+Mb3Q03yCO6bSfII7ptJ8gruc0nxi+pen+eROL5yaT3CXU5pPx1Sh5hNcj0rzCajHVfMJqMdV88mdesyu"
"xwo1n+B6VJpPcD0qzSe4HpXmE1yPSvMJrkelwERVR4Waz1Ke5hNTQ3THoxO90nzFZMLTfAVnVWm+grOqNF/BWVWaTxMVar6C"
"r1FpvgK6yar56vbja76onormu8/5ViqOrNblZH6l0NPAlRq9sKaIbaFnhis15tUuH/E1oieLK1XOTIgsz3ROap9XpQ/SJWqK"
"bCYcKm6K4tp9iUqqzbd1j4qO+g2tpTV6sll1qLipk6fnw32/isDWPSruPborwz7x9eakKbbry6Gi7RN9nrhuHr4tPXXsVLT9"
"vvTdKlUQ18i9Q0Wn+2ZOlYpva0yiDhXthNz69p4S+7ZG7h0q2jma+/BIIfm2RnU4VLQ9R6hG4iWryVurhorOlNbzEJr4tubk"
"bim6e25iqbFbJY4MKfREfaXGNXKK2aF2BSapD6Oy5162yd1S5Og0iq3k5tua1WEpsus+ZO4ZS76tqU4sRc7+mAtJyL6teR8t"
"RVbDUO9yXM2a2O6jQ5GjdCi3HKpva04dluK7J1uWGtFLjhFS6J2HlZpKhzOm0JsRK5XnmjD7Izv5av0eBd7zVb5IsVU6ffTN"
"oQXf1peoeveNjZWaNURFZ0L8fTsX1s9hwO4eeHkOA3Z3Zct6ZOccgPAM0O91hR4ZPEcWMAPkdBWQvsdZj72xZuTRs2U95qFY"
"a8FZHVQpy/Mhd55YbGmPew2VGB2PRvOVsNlib0orYbO1U8fi0cbF6uQhw7jUycMybYs/dSiKwGxyLB5tXEmdM5WM4lKnGLFm"
"FFeaPTruK8ehlEcbV55n2yIwrnlOXorAuPKsxyIwrjz7l3greptzSgve+tqo2lLbcu9MQ4oiPDMpjzaueR8rUUZxqaql/T46"
"k5WiCM9fyuNpy5uZpM9WkvfaxpS1Ndd96JKV2LflUdrW1idaKOT0HNMnTCdnT/OZvcOZv5RHG5fqAJFhXLO2WykwrtlzJkV4"
"llMebVxp7kOUMopr7h1dSmcUl9LuxesT2z6kPOq4ttkkk7sj1/1JWWWnHvfdfVKE50LlUV+jObPqs2hO+zViytpSJ+CFSyu+"
"LY+yttRM3gf3HH1bHmVtzR5NXZQXcI0epW1tdyh2d86a2O52F9vR6V8FUgTm1dWjjWuepZUgMK45k2cKMC6PIjD7rh6j+gpi"
"0Y904b675/n2h3u6QteTedkoc7qy2LIeaUxpkXNAHgeV53khozOYxZb1qDpTrvAa+TxBClECvEbPlvWY5o4cA7zGNDSfFIbX"
"6NmyHmd1SEnwGgfVJ6YKr9GzZT3KebdDZIYe5bzGQLFAj54t7XHXfDkmJ6v789r17e7iTEMrxXhmUh5tXHNmainCuObMVBOO"
"y6MYzEyrx9MW2VMf4cC9NWlbzpmVoqytucPkIO12SsZ4/lKUtTX7V6LGsfi2phadlLa1nX89naUHc437uYnQni/nlEzWN/S8"
"s7TVo41LvUsW9+pwqFBl79HsPYkVr/sK9GjjYnVWSzBfqjoowXx5FN+ZC6dHG5d6WybjfM15ouYE8+VRDObC1aOOa3v+WEJq"
"Tlzb2VCRELOtbYEU47NH5dHGpd5nagzj0s+HAozLoxjMq6tHnfutHmNjblz23G/1qChra3rMOT6f3jGefRVlbamZKfR1COKa"
"PWdS1tZcXy3mnMm3pd4IGpS1Nd9Uai0Wir6t+fxxUtrWPvu27M1MG8XJ1KMz+yqK8Vmt8mjjmn1CKmcUF88zhVIzimtOtZNi"
"fO6rPGpbW9XGLrg50G5rfwNhUueu7swmEtI55yQ0m/RpqI3JPd15ujVtWY9TSbcxpSU8dSShAj16tk4b3r6dmUlI2/L27Ulp"
"W7vKLFK3uByK4nI2VP19SFEJ7EOrRxvXyESr413YhPchLsssV/0dRlEJ70PKo7a1n/vWa1rLbsu8mTooa0up8tpyEd+Wep42"
"KG1rf6su5ebkvpqztLzl3qltRSVc28ojmTM+9F2TwNm3Qgp9/eSeKnaNXFtwqH3nk1KaJE05M6ZDOdNjv9NJIvm2Zr+3lFeP"
"EvqGnH1bM/eWMtNQvFCRHnzUlJlzXMqZYFKqHHLxbdFUFIbiu+8XWmo+50vL6R06l1u/PBO/asN8B5zAmdXTDtP7r8h+jeWL"
"lFOPocYumti39SWq3v0iTvxdtDw9hhmUq9N6PbaqvzJyqyNeMi0nIm51LLbojgIrOUKP+l2yBj16tghrqxib1gqg0pLknfIq"
"bdoirJqkMMNrnDNATPgaPVuE9VBv0J7HbU107V42W44eUhSjPrF4JKyHUokZxqXeTV+exAI9pCjGp/zKIwE9dO2+XSTX1vbc"
"b6pJUYT1UOCWWw2+rdmZJkVYD1Ef0Uoi39acySdFWA9RztLVjm9LPQ0cFAENc3unJjrVsWuY0mK297EYxbrbcs7vlUfCGib3"
"bQ/GNWffMijCGkZRDHr0w+KRsIbpnem6dnZbe9VOitTsW7e3I+e7Pgnv7tfHQxvl7O7KlvU4zu95fouUUNV21RSGFk2oHhdb"
"ZGbf+b4cXf8nRlnb8maASVlbc+UkSnSb0hKox4eFIjNHz7c/aHydkvC6T9Lydo3Je+u8ZSdf+xsu06O2ta2vPr70W1l2W9v6"
"UhSbPoG+kwYnSDVnTKGvqYvfv3JuDVLom2vvpOZpvh9fizHuX71jVp79vqDcG8rrJolLnecTxe8TgMLfBTDoJteOGUVntYJr"
"5PHOVsKnUaEuJw8oE9MWA5V5e2tA9wmQrw5xQB6RrernS33VkEB1PCmKW5f7P6zudeW7TwAA"
)

DATA = Path(tempfile.mkdtemp(prefix="nuclear_sqd_"))
for name, blob in (("usda.snt", _USDA_SNT_GZ), ("gxpf1.snt", _GXPF1_SNT_GZ)):
(DATA / name).write_bytes(gzip.decompress(base64.b64decode(blob)))

if not (DATA / name).is_file():
raise RuntimeError(f"{name} did not unpack to {DATA}")

Modelový prostor a registr qubitů​

Soubor .snt obsahuje modelový prostor, jednočásticové energie a dvoutělesové maticové elementy vázané na JJ. U interakcí závislých na hmotnosti, které se zde používají, třetí a čtvrté pole hlavičky dvoutělesové části udává referenční hmotnost ArefA_{\mathrm{ref}}, při které byla interakce nafitována, a exponent její závislosti na hmotnosti. Oba soubory mají exponent −0.3-0.3, s Aref=18A_{\mathrm{ref}} = 18 pro USDA a 4242 pro GXPF1, takže tabelované maticové elementy je třeba přeškálovat faktorem (A/Aref)−0.3(A/A_{\mathrm{ref}})^{-0.3} pro počítané jádro [2], [3]. Jednočásticové energie se nepřeškálovávají. Vynechání tohoto kroku změní korelační energii o několik procent.

Energie, které následují, jsou valenční energie, měřené od netečného jádra; nejsou to experimentální separační energie.

@dataclass(frozen=True)
class Orbital:
idx: int
n: int
ell: int
j2: int
tz: int # j2 = 2j; tz = -1 proton, +1 neutron

@dataclass(frozen=True)
class SPState:
"""One m-scheme single-particle state, i.e. one qubit."""

orb: int
j2: int
mj2: int
tz: int
ell: int
spe: float # mj2 = 2 * m_j

@dataclass
class ModelSpace:
orbitals: list
spes: dict
tbmes: dict
core_z: int
core_n: int
mass_number: int
a_ref: int
mass_exponent: float
mass_factor: float

def read_snt(path, n_protons, n_neutrons):
"""Parse a .snt interaction file, applying its mass dependence for this nucleus.

The two-body header line is ``n_tbme method A_ref exponent``. When ``method`` is 1 the
tabulated matrix elements are rescaled by ``(A / A_ref) ** exponent``, where A is the mass
number of the whole nucleus -- the core plus the valence nucleons. A is derived from the
file's own core numbers rather than passed in, so it cannot silently disagree with the
valence counts the rest of the workflow uses. Single-particle energies are not rescaled.
"""
rows = [
ln.split("!")[0].split() for ln in Path(path).read_text().splitlines()
]
rows = iter([r for r in rows if r])

n_p_orb, n_n_orb, core_z, core_n = (int(x) for x in next(rows)[:4])
orbitals = [
Orbital(*(int(x) for x in next(rows)[:5]))
for _ in range(n_p_orb + n_n_orb)
]

spes = {}
for _ in range(int(next(rows)[0])): # "i i <i|H(1b)|i>"
field = next(rows)
spes[int(field[0])] = float(field[2])

n_tbme, method, a_ref, exponent = next(rows)[:4]
n_tbme, method, a_ref, exponent = (
int(n_tbme),
int(method),
int(a_ref),
float(exponent),
)
mass_number = core_z + core_n + n_protons + n_neutrons
factor = (mass_number / a_ref) ** exponent if method == 1 else 1.0

tbmes = {}
for _ in range(n_tbme): # "a b c d J value"
field = next(rows)
tbmes[tuple(int(x) for x in field[:5])] = float(field[5]) * factor

return ModelSpace(
orbitals,
spes,
tbmes,
core_z,
core_n,
mass_number,
a_ref,
exponent,
factor,
)

def m_scheme_states(ms):
"""The qubit register: protons then neutrons, orbitals in file order, m_j descending."""
return [
SPState(o.idx, o.j2, m2, o.tz, o.ell, ms.spes[o.idx])
for tz in (-1, +1)
for o in ms.orbitals
if o.tz == tz
for m2 in range(o.j2, -o.j2 - 1, -2)
]

Clebschovo-Gordanovo přepojení​

Rovnice (2) vyžaduje Clebschovy-Gordanovy koeficienty pro poločíselné momenty hybnosti. Každý argument se předává jako dvojnásobek své fyzikální hodnoty, takže j=5/2j = 5/2 vstupuje jako 5 a aritmetika zůstává přesná.

Interaction.v_ms zajišťuje vyhledávání maticových elementů interakce. Soubor .snt ukládá každý maticový element jen jednou, takže vyhledání může vyžadovat antisymetrizovanou výměnnou fázi páru −(−1)ja+jb−J-(-1)^{j_a + j_b - J} na jedné z obou stran a bra a ket mohou být uloženy v libovolném pořadí.

@lru_cache(maxsize=None)
def clebsch_gordan(j1_2, j2_2, J_2, m1_2, m2_2, M_2):
"""<j1 m1 j2 m2 | J M>. Every argument is twice its physical value."""
if m1_2 + m2_2 != M_2 or not abs(j1_2 - j2_2) <= J_2 <= j1_2 + j2_2:
return 0.0
if abs(m1_2) > j1_2 or abs(m2_2) > j2_2 or abs(M_2) > J_2:
return 0.0
if (j1_2 + j2_2 - J_2) % 2 or (j1_2 - m1_2) % 2 or (j2_2 - m2_2) % 2:
return 0.0

f, half = factorial, lambda x: x // 2
prefactor = sqrt(
(J_2 + 1)
* f(half(j1_2 + j2_2 - J_2))
* f(half(j1_2 - j2_2 + J_2))
* f(half(-j1_2 + j2_2 + J_2))
/ f(half(j1_2 + j2_2 + J_2) + 1)
* f(half(J_2 + M_2))
* f(half(J_2 - M_2))
* f(half(j1_2 - m1_2))
* f(half(j1_2 + m1_2))
* f(half(j2_2 - m2_2))
* f(half(j2_2 + m2_2))
)
total = 0.0
for k in range(half(j1_2 + j2_2 - J_2) + 1):
d = [
half(j1_2 + j2_2 - J_2) - k,
half(j1_2 - m1_2) - k,
half(j2_2 + m2_2) - k,
half(J_2 - j2_2 + m1_2) + k,
half(J_2 - j1_2 - m2_2) + k,
]
if all(x >= 0 for x in d):
total += (-1) ** k / (
f(k) * f(d[0]) * f(d[1]) * f(d[2]) * f(d[3]) * f(d[4])
)
return prefactor * total

class Interaction:
"""Antisymmetrized m-scheme two-body matrix elements <pq||rs>, per Eq. (2)."""

def __init__(self, model_space, sp):
self.ms, self.sp, self._cache = model_space, sp, {}

def _tbme(self, oa, ob, oc, od, J, j_ab_2, j_cd_2):
"""<oa ob; J|V|oc od; J>, allowing for how the file happens to order each pair."""
table = self.ms.tbmes
# |ba; J> = -(-1)^(j_a + j_b - J) |ab; J> for a normalized antisymmetrized pair;
# dropping the leading minus makes v_ms symmetric instead of antisymmetric, and
# the Hamiltonian then fails the rotational-invariance check in Step 1.
phase_ab = -1.0 if (j_ab_2 // 2 - J) % 2 == 0 else 1.0
phase_cd = -1.0 if (j_cd_2 // 2 - J) % 2 == 0 else 1.0
for keys, phase in (
(((oa, ob, oc, od), (oc, od, oa, ob)), 1.0),
(((ob, oa, oc, od), (oc, od, ob, oa)), phase_ab),
(((oa, ob, od, oc), (od, oc, oa, ob)), phase_cd),
(((ob, oa, od, oc), (od, oc, ob, oa)), phase_ab * phase_cd),
):
for key in keys:
value = table.get(key + (J,))
if value is not None:
return value * phase
return 0.0

def v_ms(self, p, q, r, s):
"""<pq||rs>, zero unless M_J and charge are conserved."""
cached = self._cache.get((p, q, r, s))
if cached is not None:
return cached

P, Q, R, S = (self.sp[i] for i in (p, q, r, s))
value = 0.0
if P.mj2 + Q.mj2 == R.mj2 + S.mj2 and P.tz + Q.tz == R.tz + S.tz:
M = P.mj2 + Q.mj2
# sqrt(1 + delta): undo the normalization of the tabulated pair states
c12 = sqrt(2.0) if (P.tz == Q.tz and P.orb == Q.orb) else 1.0
c34 = sqrt(2.0) if (R.tz == S.tz and R.orb == S.orb) else 1.0
for J2 in range(
max(abs(P.j2 - Q.j2), abs(R.j2 - S.j2)),
min(P.j2 + Q.j2, R.j2 + S.j2) + 1,
2,
):
cg_bra = clebsch_gordan(P.j2, Q.j2, J2, P.mj2, Q.mj2, M)
cg_ket = clebsch_gordan(R.j2, S.j2, J2, R.mj2, S.mj2, M)
if abs(cg_bra) < 1e-12 or abs(cg_ket) < 1e-12:
continue
value += (
c12
* c34
* cg_bra
* cg_ket
* self._tbme(
P.orb,
Q.orb,
R.orb,
S.orb,
J2 // 2,
P.j2 + Q.j2,
R.j2 + S.j2,
)
)

self._cache[(p, q, r, s)] = value
return value

Maticové elementy a test symetrie​

Determinant je seřazená n-tice indexů obsazených qubitů. Dva determinanty lišící se ve více než dvou obsazených stavech mají nulový maticový element; jinak dávají Slaterova-Condonova pravidla krátký součet přes interakci, vynásobený fermionovým znaménkem, které počítá, kolik obsazených stavů leží mezi operátory v pevném uspořádání registru.

symmetry_allowed je celočíselný test, na který se redukují všechna čtyři přesná kvantová čísla. Používá se jak k filtrování vzorků, tak k vyčíslení přesné báze u běhů dostatečně malých na ověření.

def matrix_element(inter, det_a, det_b):
"""<A|H|B> for two determinants, each a sorted tuple of occupied qubit indices."""
set_a, set_b = set(det_a), set(det_b)
out_a, out_b = sorted(set_a - set_b), sorted(set_b - set_a)
if len(out_a) != len(out_b) or len(out_a) > 2:
return 0.0

if not out_a: # diagonal: one-body plus two-body
return sum(inter.sp[i].spe for i in det_a) + sum(
inter.v_ms(i, j, i, j)
for i, j in itertools.combinations(det_a, 2)
)

if len(out_a) == 1: # one state moves, p -> q
p, q = out_a[0], out_b[0]
crossings = sum(1 for k in set_a if min(p, q) < k < max(p, q))
return (-1.0) ** crossings * sum(
inter.v_ms(p, j, q, j) for j in det_a if j not in (p, q)
)

(p, r), (q, s) = out_a, out_b # two states move
crossings = sum(1 for k in set_a if p < k < r) + sum(
1 for k in set_b if q < k < s
)
return (-1.0) ** crossings * inter.v_ms(p, r, q, s)

def subspace_hamiltonian(inter, dets):
"""Dense real-symmetric H projected onto the span of `dets`."""
H = np.zeros((len(dets), len(dets)))
for a, det_a in enumerate(dets):
H[a, a] = matrix_element(inter, det_a, det_a)
for b in range(a + 1, len(dets)):
H[a, b] = H[b, a] = matrix_element(inter, det_a, dets[b])
return H

def ground_state(inter, dets):
"""Lowest eigenvalue and eigenvector of H over `dets`."""
values, vectors = np.linalg.eigh(subspace_hamiltonian(inter, dets))
return values[0], vectors[:, 0]

def symmetry_allowed(
sp, det, n_protons, n_neutrons, mj2_target=0, parity_target=0
):
"""The four exact shell-model quantum numbers, as integer tests on one determinant."""
n_p = sum(1 for i in det if sp[i].tz == -1)
return (
n_p == n_protons
and len(det) - n_p == n_neutrons
and sum(sp[i].mj2 for i in det) == mj2_target
and sum(sp[i].ell for i in det) % 2 == parity_target
)

def full_basis(sp, n_protons, n_neutrons, **targets):
"""Every symmetry-allowed determinant. Only tractable for small model spaces."""
protons = [i for i, s in enumerate(sp) if s.tz == -1]
neutrons = [i for i, s in enumerate(sp) if s.tz == +1]
return [
p + n
for p in itertools.combinations(protons, n_protons)
for n in itertools.combinations(neutrons, n_neutrons)
if symmetry_allowed(sp, p + n, n_protons, n_neutrons, **targets)
]

def count_basis(sp, n_protons, n_neutrons, mj2_target=0, parity_target=0):
"""How many determinants `full_basis` would return, without enumerating them.

A dynamic program over (occupied count, sum of 2*m_j, parity) per species. This stays
cheap when the basis itself is far too large to build, which is how the largest run below
can report the size of the space it is sampling from.
"""

def species(states, k):
table = {(0, 0, 0): 1}
for s in states:
for key, value in list(table.items()):
count, m_sum, parity = key
if count < k:
nxt = (count + 1, m_sum + s.mj2, (parity + s.ell) % 2)
table[nxt] = table.get(nxt, 0) + value
totals = {}
for (count, m_sum, parity), value in table.items():
if count == k:
totals[(m_sum, parity)] = (
totals.get((m_sum, parity), 0) + value
)
return totals

left = species([s for s in sp if s.tz == -1], n_protons)
right = species([s for s in sp if s.tz == +1], n_neutrons)
return sum(
a * b
for (mp, pp), a in left.items()
for (mn, pn), b in right.items()
if mp + mn == mj2_target and (pp + pn) % 2 == parity_target
)

Referenční determinant​

Ansatz je postaven na jediném determinantu, takže tento determinant by měl být nejlepší dostupný. Zaplnění nejnižších jednočásticových energií ignoruje dvoutělesovou interakci. V těchto modelových prostorech tato volba dává energii o 1–2 MeV vyšší než determinant s nejnižší energií.

Omezení na zaplnění tvořená časově obrácenými páry (+mj,−mj)(+m_j, -m_j) vynutí přesně MJ=0M_J = 0 a zanechá jen (npairsk)\binom{n_{\mathrm{pairs}}}{k} kandidátů na druh (nejvýše několik tisíc), takže nejlepšího lze najít prohledáním všech podle celé diagonály ⟨Φ∣H∣Φ⟩\langle \Phi | H | \Phi \rangle. Remízy připadnou nejsilněji zarovnaným párům, kde je párovací síla J=0J = 0 nejsilnější. V každém případě v tomto tutoriálu, který lze ověřit úplným vyčíslením, hledání vrátí globální determinant s nejnižší diagonálou, což je také největší jednotlivá složka přesného základního stavu.

def reference_determinant(sp, inter, n_protons, n_neutrons):
"""Lowest-diagonal determinant built from time-reversed (+m_j, -m_j) orbital pairs."""
if n_protons % 2 or n_neutrons % 2:
raise ValueError(
"an odd valence count has no time-reversed paired reference at M_J = 0"
)

def species_pairs(tz):
return [
(
q,
next(
p
for p, t in enumerate(sp)
if t.tz == tz and t.orb == s.orb and t.mj2 == -s.mj2
),
)
for q, s in enumerate(sp)
if s.tz == tz and s.mj2 > 0
]

best = None
for chosen_p in itertools.combinations(species_pairs(-1), n_protons // 2):
protons = tuple(q for pair in chosen_p for q in pair)
for chosen_n in itertools.combinations(
species_pairs(+1), n_neutrons // 2
):
det = tuple(
sorted(protons + tuple(q for pair in chosen_n for q in pair))
)
# break ties toward the most aligned pairs, where J = 0 pairing is strongest
score = (
matrix_element(inter, det, det),
-sum(abs(sp[q].mj2) for q in det),
)
if best is None or score < best[0]:
best = (score, det)
return best[1]

Excitační fond a jeho poruchové řazení​

Korelaci nesou dvoučásticově-dvoudírové (2p2h2p2h) excitace z reference. Dvě pravidla výběru zmenší fond ještě před sestavením jakéhokoli obvodu: excitace musí zachovávat MJM_J a dvojice děr a dvojice částic se musí umět spojit na společné celkové JJ, což je trojúhelníková nerovnost.

Zbývající excitace se řadí podle Epsteinova-Nesbetova skóre druhého řádu vybrané konfigurační interakce [4],

sα=∣⟨Φref∣H∣α⟩∣2∣Δα∣,Δα=Href,ref−Hαα,\begin{equation} \tag{3} s_\alpha = \frac{|\langle \Phi_{\mathrm{ref}} | H | \alpha \rangle|^2}{|\Delta_\alpha|}, \qquad \Delta_\alpha = H_{\mathrm{ref},\mathrm{ref}} - H_{\alpha\alpha}, \end{equation}

které odhaduje, kolik korelační energie každá excitace nese. Stejná dvě čísla určují úhel obvodu: s V=⟨Φref∣H∣α⟩V = \langle \Phi_{\mathrm{ref}} | H | \alpha \rangle je amplituda prvního řádu tα=V/Δαt_\alpha = V / \Delta_\alpha. Příloha vysvětluje, proč se v tomto tutoriálu používá amplituda prvního řádu místo přesného dvouúrovňového úhlu.

def excitation_pool(sp, occ):
"""2p2h quadruples (h1, h2, v1, v2): same-species pairs, then proton-neutron pairs."""
holes = {tz: [i for i in occ if sp[i].tz == tz] for tz in (-1, +1)}
virtuals = {
tz: [i for i, s in enumerate(sp) if s.tz == tz and i not in occ]
for tz in (-1, +1)
}
pool = [
(h1, h2, v1, v2)
for tz in (-1, +1)
for h1, h2 in itertools.combinations(holes[tz], 2)
for v1, v2 in itertools.combinations(virtuals[tz], 2)
]
pool += [
(h1, h2, v1, v2)
for h1 in holes[-1]
for h2 in holes[+1]
for v1 in virtuals[-1]
for v2 in virtuals[+1]
]
return pool

def conserves_symmetry(sp, op):
"""Keeps M_J, and the hole and particle pairs share a reachable total J."""
h1, h2, v1, v2 = op
if sp[v1].mj2 + sp[v2].mj2 != sp[h1].mj2 + sp[h2].mj2:
return False
return max(abs(sp[v1].j2 - sp[v2].j2), abs(sp[h1].j2 - sp[h2].j2)) <= min(
sp[v1].j2 + sp[v2].j2, sp[h1].j2 + sp[h2].j2
)

def en_denominator(inter, occ, holes, virtuals, floor=0.1):
"""Epstein-Nesbet gap: bare gap, spectator rearrangement, and the pair's own term."""
gap = sum(inter.sp[h].spe for h in holes) - sum(
inter.sp[v].spe for v in virtuals
)
for k in occ:
if k in holes:
continue
gap += sum(inter.v_ms(h, k, h, k) for h in holes)
gap -= sum(inter.v_ms(v, k, v, k) for v in virtuals)
gap += inter.v_ms(holes[0], holes[1], holes[0], holes[1])
gap -= inter.v_ms(virtuals[0], virtuals[1], virtuals[0], virtuals[1])
return gap if abs(gap) >= floor else (floor if gap >= 0 else -floor)

def rank_pool(inter, occ, pool):
"""Sort by descending PT2 score; return (operator, coupling, first-order amplitude)."""
ranked = []
for op in pool:
h1, h2, v1, v2 = op
coupling = inter.v_ms(v1, v2, h1, h2)
gap = en_denominator(inter, occ, (h1, h2), (v1, v2))
ranked.append((coupling**2 / abs(gap), op, coupling, coupling / gap))
ranked.sort(key=lambda row: (-row[0], row[1])) # deterministic on ties
return [
(op, coupling, amplitude) for _, op, coupling, amplitude in ranked
]

Bloky qubitových excitací​

Při mapování Jordan-Wigner se excitační operátor 2p2h2p2h zachovávající počet částic mění na součet osmi Pauliho řetězců, z nichž každý nese řetězec operátorů ZZ mezi krajními indexy. Řetězce ZZ vynucují fermionovou antisymetrii a jsou nákladné: proton-neutronová excitace přesahuje hranici mezi oběma polovinami registru a zahrnuje paritní řetězec přes tuto hranici.

Vypuštěním řetězců ZZ vznikne qubitový excitační operátor podle Yordanova a kol. [5]. Stav připravený tímto operátorem má jiné amplitudy, ale spojuje přesně stejné dvojice determinantů, takže množina determinantů, kterých obvod může dosáhnout, se nemění. Pooled SQD tyto determinanty používá pro klasickou diagonalizaci. Krok 2 porovnává nosiče obou konstrukcí a měří jejich hardwarové náklady.

Sestavení Pauliho tvaru z aj†=12(Xj−iYj)⊗Z<ja_j^\dagger = \tfrac{1}{2}(X_j - i Y_j) \otimes Z_{<j}, přičemž řetězec ZZ je volitelný, odděluje obě konstrukce jediným příznakem. Všech osm členů jednoho generátoru komutuje, takže jediný krok PauliEvolutionGate je přesná exponenciála, nikoli Trotterova aproximace.

def _ladder(num_qubits, q, dagger, parity):
"""Pauli form of a_q or a_q^dagger. `parity` toggles the Jordan-Wigner Z string."""
prefix = (
["Z"] * q + ["I"] * (num_qubits - q) if parity else ["I"] * num_qubits
)
x_part, y_part = list(prefix), list(prefix)
x_part[q], y_part[q] = "X", "Y"
return SparsePauliOp(
["".join(reversed(x_part)), "".join(reversed(y_part))],
coeffs=[0.5, 0.5 * (-1j if dagger else 1j)],
)

def excitation_generator(num_qubits, op, parity=False):
"""Hermitian H with exp(-i theta H) = exp(theta (T - T^dagger)) for T = a+ a+ a a."""
h1, h2, v1, v2 = op
T = SparsePauliOp("I" * num_qubits)
for q, dagger in ((v1, True), (v2, True), (h2, False), (h1, False)):
T = (T @ _ladder(num_qubits, q, dagger, parity)).simplify()
return (1j * (T - T.adjoint())).simplify()

def excitation_block(op, theta, parity=False):
"""(window, circuit) for one excitation.

A qubit excitation touches only its four qubits. A fermionic excitation also carries Z
operators on every qubit between the outermost indices, so its window is the whole span --
which is exactly where its extra cost comes from.
"""
window = list(range(min(op), max(op) + 1)) if parity else sorted(op)
local = tuple(window.index(i) for i in op)
generator = excitation_generator(len(window), local, parity=parity)
return window, PauliEvolutionGate(generator, time=theta).definition

def excitation_ansatz(
num_qubits, occ, operators, amplitudes, measure=True, parity=False
):
"""X gates for the reference determinant, then one evolution block per excitation."""
qc = QuantumCircuit(num_qubits)
for q in occ:
qc.x(q)
for op, theta in zip(operators, amplitudes):
if abs(theta) < 1e-12:
continue
window, block = excitation_block(op, theta, parity=parity)
qc.compose(block, qubits=window, inplace=True)
if measure:
qc.measure_all()
return qc

Rozpočet hloubky a soubor obvodů​

Jediný hluboký obvod obsahující všechny seřazené excitace může překročit koherenční dobu hardwaru. Rozprostření fondu na soubor mělkých obvodů a sloučení jejich měření do jedné množiny determinantů mění Krok 2 na problém balení: každá excitace má změřenou cenu, každý obvod má rozpočet a otázkou je, kolik ze seřazeného fondu se vejde.

Rozpočet se měří v dvouqubitové hloubce (vrstvy dvouqubitových hradel na kritické cestě) spíše než v hrubém počtu hradel, protože hloubka určuje dobu trvání obvodu a tím i to, kolik koherence zařízení spotřebuje. Celkový počet se uvádí vedle ní, protože je to lepší ukazatel nahromaděné chyby hradel; oba údaje odpovídají na různé otázky a žádný nenahrazuje ten druhý.

Obě veličiny se získávají podle arity: instrukce působící na přesně dva qubity, ať už backend své provázací hradlo nazývá jakkoli. Porovnávání podle názvů hradel by mohlo vrátit nulu pro neznámou bázovou sadu a chybně umístit celý fond do jednoho obvodu bez překročení vypočteného rozpočtu.

Plnění toho obvodu, který je právě nejprázdnější, v pořadí podle ranku udržuje každý obvod blízko rozpočtu. Náklady se měří na cíli skutečného backendu, po jedné excitaci, protože náklad odečtený z abstraktního obvodu není náklad, který vytvoří transpiler.

DIRECTIVES = ("barrier", "delay")

def is_two_qubit(instruction):
"""True for an operation on exactly two qubits, excluding directives.

Selecting by arity rather than by gate name keeps this correct on any backend, whatever its
two-qubit basis gate happens to be called -- cz on today's Heron devices, ecr on Eagle, or
something newer tomorrow. A gate-name allow-list silently returns zero on anything it has
not heard of, which would collapse the whole pool into one circuit and pass every budget
check. Barriers are excluded because a barrier spanning two qubits is not a gate.
"""
return (
len(instruction.qubits) == 2
and instruction.operation.name not in DIRECTIVES
)

def two_qubit_count(qc):
"""How many two-qubit gates the circuit contains: the accumulated-gate-error proxy."""
return sum(1 for instruction in qc.data if is_two_qubit(instruction))

def two_qubit_depth(qc):
"""Layers of two-qubit gates on the critical path: the duration and decoherence proxy.

This is what the budget is measured in. Two gates on disjoint qubit pairs run in the same
layer, so depth tracks how long the circuit takes -- and therefore how much coherence it
spends -- while the count above tracks how much gate error it accumulates. Both are
reported; only depth is budgeted.
"""
return qc.depth(filter_function=is_two_qubit)

def excitation_costs(num_qubits, ranked, pm, parity=False):
"""Transpiled two-qubit depth of each excitation on its own."""
return [
two_qubit_depth(
pm.run(
excitation_ansatz(
num_qubits, (), [op], [amp], measure=False, parity=parity
)
)
)
for op, _, amp in ranked
]

def pack_ensemble(
num_qubits, occ, ranked, costs, budget, n_circuits, parity=False
):
"""Fill n_circuits in rank order, always adding to whichever is currently emptiest."""
bins, loads = [[] for _ in range(n_circuits)], [0] * n_circuits
for (op, _, amplitude), cost in zip(ranked, costs):
emptiest = min(range(n_circuits), key=lambda b: loads[b])
if loads[emptiest] + cost > budget:
break # every circuit is full
bins[emptiest].append((op, amplitude))
loads[emptiest] += cost
circuits = [
excitation_ansatz(
num_qubits,
occ,
[o for o, _ in b],
[a for _, a in b],
parity=parity,
)
for b in bins
]
return circuits, bins

def pack_to_budget(
num_qubits, occ, ranked, costs, budget, n_circuits, pm, attempts=6
):
"""Pack, transpile, and shrink the target until the assembled circuits really fit.

Costs are measured one excitation at a time, but excitations that share qubits neither add
nor parallelize cleanly once the transpiler routes them together, so the assembled depth is
not the sum of its measured parts. This loop closes that gap against the real transpiler,
and it runs entirely before any job is submitted -- a budget failure must never cost shots.
"""
target = budget
for attempt in range(attempts):
circuits, bins = pack_ensemble(
num_qubits, occ, ranked, costs, target, n_circuits, parity=False
)
isa = pm.run(circuits)
worst = max(two_qubit_depth(c) for c in isa)
if worst <= budget:
return circuits, bins, isa
target = max(min(costs), int(target * budget / worst * 0.95))
raise RuntimeError(
f"could not fit {n_circuits} circuits inside a two-qubit depth of {budget} in "
f"{attempts} attempts; raise N_CIRCUITS or DEPTH_BUDGET and re-run this cell. "
"No QPU time was spent."
)

Následné zpracování: oprava, rekombinace, diagonalizace​

Tři pomocné funkce provádějí práci Kroku 4.

half_configurations rozdělí každý vzorkovaný řádek na protonovou a neutronovou polovinu a ponechá každou polovinu, která má správný počet nukleonů. Řádek s platnou protonovou polovinou přispívá touto polovinou, i když jeho neutronová polovina má špatný počet nukleonů. Každá polovina nese celkovou vzorkovanou váhu řádků, ve kterých se objevila, což je to, co ji řadí, když je třeba podprostor zkrátit.

grow_subspace rekombinuje poloviny do každého součinu, který padne do cílového sektoru MJM_J a parity, přičemž přidává do podprostoru, který dostal, místo aby jej budoval znovu. Tím zůstávají po sobě jdoucí podprostory vnořené, což zajišťuje, že posloupnost energií je monotónně nerostoucí, a nikoli jen kolísá kolem meze.

recovery_loop je samokonzistentní obnova konfigurací z článku o pooled SQD [1]: oprav počty nukleonů obou polovin registru podle aktuálního odhadu obsazení, rekombinuj, diagonalizuj a vezmi další odhad obsazení z vlastního vektoru.

Pozorně zkontroluj konvence pořadí bitů, abys předešel nesprávným výsledkům. qiskit-addon-sqd zapisuje sloupec 0 své matice bitových řetězců jako nejvyšší index qubitu, takže obrácení řádku dává obsazení indexované qubitem; jeho „pravá“ polovina jsou nízké indexy qubitů, což je protonový blok. Odpovídajícím způsobem recover_configurations bere num_elec_a jako počet protonů a průměrná obsazení uspořádaná (protons, neutrons) podle indexu qubitu. Doplněk předpokládá, že bit ii je spárován s bitem i+Ni + N; v tomto registru jsou protonový qubit ii a neutronový qubit i+Ni + N týmž stavem (n,ℓ,j,mj)(n, \ell, j, m_j), takže předpoklad je zde fyzikálně smysluplný, nikoli náhodný.

def half_configurations(
bitstring_matrix, probabilities, sp, n_protons, n_neutrons
):
"""Split each row into proton and neutron halves, keeping each half on its own weight.

Column 0 of the addon's matrix is the highest qubit index, so reversing a row gives
occupation indexed by qubit.
"""
protons, neutrons = {}, {}
for row, weight in zip(
bitstring_matrix, np.asarray(probabilities, dtype=float)
):
occupied = np.flatnonzero(row[::-1])
p = tuple(int(i) for i in occupied if sp[i].tz == -1)
n = tuple(int(i) for i in occupied if sp[i].tz == +1)
if len(p) == n_protons:
protons[p] = protons.get(p, 0.0) + weight
if len(n) == n_neutrons:
neutrons[n] = neutrons.get(n, 0.0) + weight
return protons, neutrons

def product_subspace(sp, protons, neutrons, n_protons, n_neutrons, **targets):
"""Every (proton half) x (neutron half) product that lands in the target sector."""
return sorted(
d
for d in (
tuple(sorted(tuple(p) + tuple(n)))
for p in protons
for n in neutrons
)
if symmetry_allowed(sp, d, n_protons, n_neutrons, **targets)
)

def grow_subspace(
sp,
kept_protons,
kept_neutrons,
offered_protons,
offered_neutrons,
n_protons,
n_neutrons,
max_dimension=None,
**targets,
):
"""Add as many offered halves as the dimension cap allows, never dropping a kept one."""
kept_p, kept_n = list(kept_protons), list(kept_neutrons)
new_p = [c for c in offered_protons if c not in set(kept_p)]
new_n = [c for c in offered_neutrons if c not in set(kept_n)]

if max_dimension is None:
kept_p, kept_n = kept_p + new_p, kept_n + new_n
return (
product_subspace(
sp, kept_p, kept_n, n_protons, n_neutrons, **targets
),
kept_p,
kept_n,
)

basis = product_subspace(
sp, kept_p, kept_n, n_protons, n_neutrons, **targets
)
step = max(1, (len(new_p) + len(new_n)) // 24)
taken_p = taken_n = 0
while taken_p < len(new_p) or taken_n < len(new_n):
try_p, try_n = (
min(taken_p + step, len(new_p)),
min(taken_n + step, len(new_n)),
)
candidate = product_subspace(
sp,
kept_p + new_p[:try_p],
kept_n + new_n[:try_n],
n_protons,
n_neutrons,
**targets,
)
if len(candidate) > max_dimension:
if step == 1:
break
step = max(1, step // 2)
continue
basis, taken_p, taken_n = candidate, try_p, try_n
return basis, kept_p + new_p[:taken_p], kept_n + new_n[:taken_n]

def occupancies(sp, dets, vector):
"""Average occupancy of each qubit in a subspace eigenvector, as (protons, neutrons)."""
half = len(sp) // 2
occ = np.zeros(len(sp))
for weight, det in zip(np.abs(vector) ** 2, dets):
for q in det:
occ[q] += weight
return occ[:half], occ[half:]

def sample_occupancies(sp, bitstring_matrix, probabilities):
"""The same quantity estimated directly from sampled bitstrings."""
half = len(sp) // 2
weights = np.asarray(probabilities, dtype=float)
occ = (weights[:, None] * bitstring_matrix[:, ::-1]).sum(
axis=0
) / weights.sum()
return occ[:half], occ[half:]
def recovery_loop(
inter,
sp,
bitstring_matrix,
probabilities,
reference,
n_protons,
n_neutrons,
max_iterations=4,
energy_tol=1e-4,
max_dimension=None,
seed=None,
**targets,
):
"""Self-consistent configuration recovery, diagonalizing in the product subspace.

`num_elec_a` is the proton number and `num_elec_b` the neutron number, matching the
addon's right/left bipartition of the bitstring matrix.
"""
half = len(sp) // 2
p_ref = tuple(i for i in reference if sp[i].tz == -1)
n_ref = tuple(i for i in reference if sp[i].tz == +1)

survivors, survivor_probs = postselect_by_hamming_right_and_left(
bitstring_matrix,
np.asarray(probabilities, dtype=float).copy(),
hamming_right=n_protons,
hamming_left=n_neutrons,
)

if len(survivors):
guess = sample_occupancies(sp, survivors, survivor_probs)
else: # nothing survived: start from the reference itself
guess = (
np.array([1.0 if q in p_ref else 0.0 for q in range(half)]),
np.array(
[1.0 if q + half in n_ref else 0.0 for q in range(half)]
),
)

weights_p, weights_n = {p_ref: np.inf}, {n_ref: np.inf}
kept_p, kept_n = [p_ref], [n_ref]
history, best = [], None

for iteration in range(max_iterations):
# keep the occupancy estimate strictly inside (0, 1): the heuristic divides by it
clipped = tuple(np.clip(a, 1e-4, 1.0 - 1e-4) for a in guess)
recovered, recovered_probs = recover_configurations(
bitstring_matrix,
probabilities,
clipped,
n_protons,
n_neutrons,
rand_seed=None if seed is None else seed + iteration,
)

new_p, new_n = half_configurations(
recovered, recovered_probs, sp, n_protons, n_neutrons
)
for config, weight in new_p.items():
weights_p[config] = weights_p.get(config, 0.0) + weight
for config, weight in new_n.items():
weights_n[config] = weights_n.get(config, 0.0) + weight

def order(w):
return sorted(w, key=lambda c: (-w[c], c))

basis, kept_p, kept_n = grow_subspace(
sp,
kept_p,
kept_n,
order(weights_p),
order(weights_n),
n_protons,
n_neutrons,
max_dimension=max_dimension,
**targets,
)
energy, vector = ground_state(inter, basis)

history.append(
dict(
iteration=iteration + 1,
energy=energy,
dimension=len(basis),
protons=len(kept_p),
neutrons=len(kept_n),
recovered=len(recovered),
survivors=len(survivors),
)
)
print(
f" iteration {iteration + 1}: {len(kept_p)} proton x {len(kept_n)} neutron "
f"halves -> dimension {len(basis)}, E = {energy:.6f} MeV"
)

if best is None or energy < best[0]:
best = (energy, basis, vector)
guess = occupancies(
sp, basis, vector
) # the self-consistent update
if (
len(history) > 1
and abs(history[-2]["energy"] - energy) < energy_tol
):
break

return dict(
energy=best[0], basis=best[1], vector=best[2], history=history
)

Backend, rozpočet a parametry běhu​

Každý následující běh používá stejný backend, stejné pass managery a stejný rozpočet hloubky, takže tyto tři jsou přímo srovnatelné. Rozpočet je spojuje: každý obvod v každém souboru se musí vejít dovnitř a rozhoduje o tom, kolik z fondu lze vůbec vzorkovat.

Zde použité hodnoty byly zvoleny měřením transpilovaných nákladů vůči cíli Heron. Při dvouqubitové hloubce 300 a 16 obvodech vycházejí 24qubitové i 40qubitové soubory výrazně pod 100 mikrosekund na obvod, při koherenčních dobách několika set mikrosekund. Zvýšení rozpočtu zahrne více z fondu, ale prodlouží dobu trvání obvodu. Tento compromis změř pro svůj backend.

# This example assumes you have saved your IBM Quantum Platform account locally.
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, min_num_qubits=40
)

pass_manager = generate_preset_pass_manager(
optimization_level=3, backend=backend, seed_transpiler=42
)
costing_manager = generate_preset_pass_manager(
optimization_level=1, backend=backend, seed_transpiler=42
)

DEPTH_BUDGET = 300 # two-qubit depth per circuit
N_CIRCUITS = 16 # circuits per ensemble
SHOTS = 10_000 # shots per circuit
MAX_DIMENSION = 4_000 # largest subspace the dense solver here will build
JOB_TAGS = ["TUT_SBQDNH"] # initials of the title's content words

# derive the two-qubit basis gate from the target by arity, not from a hard-coded name
two_qubit_basis = sorted(
name
for name in backend.target.operation_names
if backend.target.operation_from_name(name).num_qubits == 2
)
if not two_qubit_basis:
raise RuntimeError(
f"{backend.name} exposes no two-qubit gate; pick another backend"
)

print(
f"{backend.name}: {backend.num_qubits} qubits, two-qubit basis gate {two_qubit_basis[0]}"
)
print(
f"two-qubit depth budget {DEPTH_BUDGET}, {N_CIRCUITS} circuits x {SHOTS:,} shots per run"
)
print(
f"three runs: {3 * N_CIRCUITS} circuits, {3 * N_CIRCUITS * SHOTS:,} shots in total"
)
ibm_phoenix: 120 qubits, two-qubit basis gate cz
two-qubit depth budget 300, 16 circuits x 10,000 shots per run
three runs: 48 circuits, 480,000 shots in total

Příklad na hardwaru v malém rozsahu​

Tato část sleduje čtyřkrokový pracovní postup na QPU s použitím stejného backendu a stejného rozpočtu hradel jako běhy ve velkém rozsahu. Menší úloha poskytuje přesnou referenci pro ověření výsledku.

Úlohou v malém rozsahu je 20Ne^{20}\mathrm{Ne}: dva valenční protony a dva valenční neutrony ve slupce sdsd nad jádrem 16O^{16}\mathrm{O} s interakcí USDA [2]. Tři orbitaly na druh dávají 24 qubitů a úplná báze povolená symetriemi má 640 determinantů, dost malá na to, aby se odhady energie daly porovnat s přesnou odpovědí.

Krok 1: Namapuj klasické vstupy na kvantovou úlohu​

Načti interakci, sestav registr a vytvoř referenční determinant. Následující tabulka ukazuje informace o registru z části Pozadí, načtené přímo ze souboru interakce.

N_PROTONS, N_NEUTRONS = 2, 2

ms_sd = read_snt(DATA / "usda.snt", N_PROTONS, N_NEUTRONS)
sp_sd = m_scheme_states(ms_sd)
inter_sd = Interaction(ms_sd, sp_sd)
occ_sd = reference_determinant(sp_sd, inter_sd, N_PROTONS, N_NEUTRONS)

# post-selection splits the register in half, so the two species must contribute equally
n_proton_states = sum(1 for s in sp_sd if s.tz == -1)
if n_proton_states != len(sp_sd) - n_proton_states:
raise ValueError(
"this workflow needs equal proton and neutron state counts"
)

SHELL_LABEL = {0: "s", 1: "p", 2: "d", 3: "f", 4: "g"}
print(
f"core Z={ms_sd.core_z} N={ms_sd.core_n} plus {N_PROTONS}p + {N_NEUTRONS}n valence "
f"-> A={ms_sd.mass_number} on {len(sp_sd)} qubits"
)
print(
f"interaction: {len(ms_sd.tbmes)} J-coupled matrix elements, fitted at "
f"A_ref={ms_sd.a_ref}, rescaled by (A/A_ref)^{ms_sd.mass_exponent:g} = "
f"{ms_sd.mass_factor:.6f}\n"
)

print(
f"{'orbital':>9} {'SPE (MeV)':>10} {'proton qubits':>14} {'neutron qubits':>15}"
)
for o in (o for o in ms_sd.orbitals if o.tz == -1):
twin = next(
t
for t in ms_sd.orbitals
if t.tz == +1 and (t.n, t.ell, t.j2) == (o.n, o.ell, o.j2)
)
qp = [q for q, s in enumerate(sp_sd) if s.orb == o.idx]
qn = [q for q, s in enumerate(sp_sd) if s.orb == twin.idx]
print(
f"{f'{o.n}{SHELL_LABEL[o.ell]}{o.j2}/2':>9} {ms_sd.spes[o.idx]:>10.4f} "
f"{f'{qp[0]}-{qp[-1]}':>14} {f'{qn[0]}-{qn[-1]}':>15}"
)

print(f"\nreference determinant occupies qubits {occ_sd}")
print(
f" M_J = {sum(sp_sd[i].mj2 for i in occ_sd) / 2:g}, "
f"parity = {(-1) ** (sum(sp_sd[i].ell for i in occ_sd) % 2):+d}, "
f"energy = {matrix_element(inter_sd, occ_sd, occ_sd):.6f} MeV"
)
core Z=8 N=8 plus 2p + 2n valence -> A=20 on 24 qubits
interaction: 158 J-coupled matrix elements, fitted at A_ref=18, rescaled by (A/A_ref)^-0.3 = 0.968886

orbital SPE (MeV) proton qubits neutron qubits
0d3/2 2.1117 0-3 12-15
0d5/2 -3.9257 4-9 16-21
1s1/2 -3.2079 10-11 22-23

reference determinant occupies qubits (4, 9, 16, 21)
M_J = 0, parity = +1, energy = -29.765549 MeV

Před pokračováním proveď dvě kontroly hamiltoniánu. Obě jsou levné a mohou odhalit chyby přepojení, které by jediný výpočet energie nemusel zjistit.

Rotačně invariantní hamiltonián organizuje své vlastní stavy do multipletů JJ, takže každá vlastní hodnota sektoru MJ=2M_J = 2 se musí objevit i ve spektru MJ=0M_J = 0 při stejné energii. Rozdíl mezi základním stavem a nejnižším stavem s MJ=2M_J = 2 je excitační energie 2+2^+, která je změřena: 1.6341.634 MeV pro 20Ne^{20}\mathrm{Ne} [6]. Od empirické interakce pro slupku sdsd se očekává shoda v rámci několika set keV.

basis_exact_sd = full_basis(sp_sd, N_PROTONS, N_NEUTRONS)
if len(basis_exact_sd) != count_basis(sp_sd, N_PROTONS, N_NEUTRONS):
raise AssertionError("the basis counter disagrees with the enumeration")
E_REF_SD = matrix_element(inter_sd, occ_sd, occ_sd)
E_EXACT_SD, _ = ground_state(inter_sd, basis_exact_sd)

# the M_J = 2 sector: its spectrum must be contained in the M_J = 0 spectrum
basis_mj2 = full_basis(sp_sd, N_PROTONS, N_NEUTRONS, mj2_target=4)
spectrum_0 = np.linalg.eigvalsh(
subspace_hamiltonian(inter_sd, basis_exact_sd)
)
spectrum_2 = np.linalg.eigvalsh(subspace_hamiltonian(inter_sd, basis_mj2))
contained = sum(
1 for e in spectrum_2 if np.min(np.abs(spectrum_0 - e)) < 1e-7
)
if contained != len(spectrum_2):
raise AssertionError(
f"rotational invariance broken: only {contained}/{len(spectrum_2)} "
"M_J=2 eigenvalues appear in the M_J=0 spectrum"
)

print(
f"rotational invariance: all {contained} M_J=2 eigenvalues found in the M_J=0 spectrum"
)
print(
f"E(2+) - E(0+) = {spectrum_2[0] - E_EXACT_SD:.3f} MeV (experiment: 1.634 MeV)\n"
)
print(f"reference determinant {E_REF_SD:11.6f} MeV")
print(
f"exact diagonalization {E_EXACT_SD:11.6f} MeV (dimension {len(basis_exact_sd)})"
)
print(f"correlation energy to find {E_EXACT_SD - E_REF_SD:11.6f} MeV")
rotational invariance: all 497 M_J=2 eigenvalues found in the M_J=0 spectrum
E(2+) - E(0+) = 1.747 MeV (experiment: 1.634 MeV)

reference determinant -29.765549 MeV
exact diagonalization -40.472331 MeV (dimension 640)
correlation energy to find -10.706782 MeV

Dále sestav fond operátorů. Použití obou pravidel výběru dává důležitý výsledek: pro tuto referenci, v tomto modelovém prostoru, nejsou povoleny vůbec žádné jednoduché excitace.

Důvod je konkrétní a ověřitelný. Excitace 1p1h1p1h zachovává MJM_J jen tehdy, když má stav částice stejné mjm_j jako díra. Reference obsazuje dva stavy největšího ∣mj∣|m_j| v nejnižším orbitalu (mj=±5/2m_j = \pm 5/2 orbitalu 0d5/20d_{5/2}) a žádný jiný orbital ve slupce sdsd nedosahuje ∣mj∣=5/2|m_j| = 5/2, protože 0d3/20d_{3/2} končí na 3/23/2 a 1s1/21s_{1/2} na 1/21/2. Proto žádná jednoduchá excitace nepřežije a korelaci nesou výhradně excitace 2p2h2p2h. Je to vlastnost reference a slupky, nikoli obecný zákon; následující buňka to spočítá, místo aby to předpokládala.

raw_pool_sd = excitation_pool(sp_sd, occ_sd)
pool_sd = [op for op in raw_pool_sd if conserves_symmetry(sp_sd, op)]
ranked_sd = rank_pool(inter_sd, occ_sd, pool_sd)

singles_sd = [
(h, v)
for h in occ_sd
for v in range(len(sp_sd))
if v not in occ_sd and sp_sd[h].tz == sp_sd[v].tz
]
singles_mj_sd = [
(h, v) for h, v in singles_sd if sp_sd[h].mj2 == sp_sd[v].mj2
]

print(
f"1p1h: {len(singles_sd):4d} raw -> {len(singles_mj_sd):3d} conserve M_J"
)
print(
f"2p2h: {len(raw_pool_sd):4d} raw -> {len(pool_sd):3d} conserve M_J and couple to a common J\n"
)

print(
f"{'rank':>4} {'holes':>9} {'particles':>11} {'<ref|H|a> (MeV)':>16} {'amplitude':>10}"
)
for r, (op, coupling, amplitude) in enumerate(ranked_sd[:8], start=1):
print(
f"{r:>4} {f'{op[0]},{op[1]}':>9} {f'{op[2]},{op[3]}':>11} "
f"{coupling:>16.4f} {amplitude:>10.4f}"
)

# what is the best this ansatz could possibly do? Apply every excitation once and recombine.
reachable = {occ_sd}
for op, _, _ in ranked_sd:
h1, h2, v1, v2 = op
reachable |= {
tuple(sorted(set(d) - {h1, h2} | {v1, v2}))
for d in reachable
if {h1, h2} <= set(d) and not {v1, v2} & set(d)
}
ceiling = product_subspace(
sp_sd,
{tuple(i for i in d if sp_sd[i].tz == -1) for d in reachable},
{tuple(i for i in d if sp_sd[i].tz == +1) for d in reachable},
N_PROTONS,
N_NEUTRONS,
)
print(
f"\nthe pool reaches {len(reachable)} determinants, whose product subspace spans "
f"{len(ceiling)} of {len(basis_exact_sd)}"
)
1p1h: 40 raw -> 0 conserve M_J
2p2h: 490 raw -> 78 conserve M_J and couple to a common J

rank holes particles <ref|H|a> (MeV) amplitude
1 4,9 0,3 -1.8714 0.1375
2 16,21 12,15 -1.8714 0.1375
3 4,21 3,12 1.6775 -0.1029
4 9,16 0,15 1.6775 -0.1029
5 4,9 10,11 -0.8728 0.1168
6 16,21 22,23 -0.8728 0.1168
7 4,21 3,17 1.0622 -0.0882
8 4,21 8,12 -1.0622 0.0882

the pool reaches 412 determinants, whose product subspace spans 640 of 640

Krok 2: Optimalizuj úlohu pro provádění na kvantovém hardwaru​

Transpilace odhaluje hardwarové náklady řetězců ZZ z Jordanovy-Wignerovy transformace a úspory plynoucí z použití qubitových excitací. První buňka změří obě konstrukce vůči skutečnému cíli backendu a ověří tvrzení, uvedené v části Nastavení, že vypuštění řetězců ZZ mění amplitudy, ale ne množinu determinantů, kterých obvod může dosáhnout.

Porovnej dva důsledky této záměny. Qubitová excitace stojí stejně bez ohledu na vzdálenost mezi svými indexy, takže proton-neutronové excitace, které přesahují hranici mezi oběma polovinami registru a tvoří většinu fondu, už tyto dodatečné náklady nemají. Celý fond se pak vejde do rozpočtu, což znamená, že limitem výsledku je vzorkování, nikoli hloubka obvodu.

# 1. do the two constructions reach the same determinants?
# Apply one block to the reference on the window it spans and read off which basis states
# acquire amplitude. Column 0 of the unitary is the image of |0...0>, and the X gates that
# place the reference are part of the circuit, so that column is exactly what is wanted.
# A fermionic block's window is its whole span, and building a unitary on it costs 4^n, so
# probe the narrowest excitations in the pool rather than the highest-ranked ones.
PROBE_SPAN = 12
narrow = sorted(ranked_sd, key=lambda row: max(row[0]) - min(row[0]))
probes = [op for op, _, _ in narrow if max(op) - min(op) + 1 <= PROBE_SPAN][
:3
]
if len(probes) < 2:
raise RuntimeError(
f"no excitation spans {PROBE_SPAN} qubits or fewer; raise PROBE_SPAN"
)

print(
f"{'excitation':>16} {'span':>5} {'reachable determinants':>22} {'same as fermionic?':>19}"
)
for probe_op in probes:
probe_window = list(range(min(probe_op), max(probe_op) + 1))
probe_local = tuple(probe_window.index(i) for i in probe_op)
probe_occ = tuple(
probe_window.index(i) for i in occ_sd if i in probe_window
)

supports = {}
for parity in (True, False):
unitary = Operator(
excitation_ansatz(
len(probe_window),
probe_occ,
[probe_local],
[0.7],
measure=False,
parity=parity,
)
).data
supports[parity] = frozenset(
np.flatnonzero(np.abs(unitary[:, 0]) > 1e-10).tolist()
)

if len(supports[True]) < 2:
raise AssertionError(
f"{probe_op}: the block did not move any amplitude, so this "
"comparison would be vacuous"
)
if supports[True] != supports[False]:
raise AssertionError(
f"{probe_op}: the two constructions reach different determinants"
)
print(
f"{str(probe_op):>16} {len(probe_window):>5} {len(supports[True]):>22} {'yes':>19}"
)

print(
"\n-> identical support; the amplitudes differ, and pooled SQD only consumes the support\n"
)

# 2. what does each one cost on this backend?
cost_qeb = excitation_costs(
len(sp_sd), ranked_sd, costing_manager, parity=False
)
cost_jw = excitation_costs(
len(sp_sd), ranked_sd, costing_manager, parity=True
)

def species(op):
return "same" if len({sp_sd[i].tz for i in op}) == 1 else "pn"

print(
f"{'excitation':>10} {'count':>5} {'QEB 2q depth':>14} {'fermionic 2q depth':>19}"
)
for group in ("same", "pn"):
q = [
c
for (op, _, _), c in zip(ranked_sd, cost_qeb)
if species(op) == group
]
j = [
c for (op, _, _), c in zip(ranked_sd, cost_jw) if species(op) == group
]
print(
f"{group:>10} {len(q):>5} {f'{min(q)}-{max(q)}':>14} {f'{min(j)}-{max(j)}':>19}"
)
print(
f"{'pool total':>10} {len(ranked_sd):>5} {sum(cost_qeb):>14} {sum(cost_jw):>19}"
)
print(
f"\nfermionic / qubit-excitation cost ratio: {sum(cost_jw) / sum(cost_qeb):.2f}x"
)
print(
f"\nensemble capacity: {N_CIRCUITS} circuits at two-qubit depth {DEPTH_BUDGET}"
)
excitation span reachable determinants same as fermionic?
(4, 9, 5, 8) 6 2 yes
(16, 21, 17, 20) 6 2 yes
(4, 9, 6, 7) 6 2 yes

-> identical support; the amplitudes differ, and pooled SQD only consumes the support

excitation count QEB 2q depth fermionic 2q depth
same 26 40-48 48-144
pn 52 48-48 48-256
pool total 78 3728 9112

fermionic / qubit-excitation cost ratio: 2.44x

ensemble capacity: 16 circuits at two-qubit depth 300
circuits_sd, bins_sd, isa_sd = pack_to_budget(
len(sp_sd),
occ_sd,
ranked_sd,
cost_qeb,
DEPTH_BUDGET,
N_CIRCUITS,
pass_manager,
)

PACKED_SD = sum(len(b) for b in bins_sd)
worst_sd = max(two_qubit_depth(c) for c in isa_sd)
worst_count_sd = max(two_qubit_count(c) for c in isa_sd)

print(
f"packed {PACKED_SD} of {len(ranked_sd)} excitations into {N_CIRCUITS} circuits"
)
print(f" excitations per circuit {[len(b) for b in bins_sd]}")
print(f" two-qubit depth {[two_qubit_depth(c) for c in isa_sd]}")
print(f" two-qubit gates {[two_qubit_count(c) for c in isa_sd]}")
print(
f"\nworst circuit: two-qubit depth {worst_sd} of a {DEPTH_BUDGET} budget, "
f"{worst_count_sd} two-qubit gates"
)
packed 78 of 78 excitations into 16 circuits
excitations per circuit [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4]
two-qubit depth [228, 182, 177, 220, 214, 220, 226, 224, 222, 222, 181, 179, 136, 179, 171, 181]
two-qubit gates [234, 231, 227, 228, 225, 225, 233, 229, 233, 226, 230, 223, 232, 226, 176, 185]

worst circuit: two-qubit depth 228 of a 300 budget, 234 two-qubit gates

Krok 3: Spusť pomocí primitiv Qiskit​

Odešli jednu úlohu na problém, přičemž celý soubor je jediným seznamem obvodů. Twirling hradel a měření a dynamické oddělování jsou zapnuty ke snížení vlivu šumu hardwaru. Jejich přínos závisí na obvodu a backendu.

Vypíše se ID každé úlohy. Pomocí service.job("JOB_ID") získáš dokončenou úlohu a její výsledky bez spotřeby dalšího času QPU.

def sample(isa_circuits, shots, tags):
"""Submit one Sampler job; return the per-circuit bit arrays and the measured QPU seconds."""
sampler = SamplerV2(mode=backend)
sampler.options.environment.job_tags = tags
sampler.options.twirling.enable_gates = True
sampler.options.twirling.enable_measure = True
sampler.options.dynamical_decoupling.enable = True
sampler.options.dynamical_decoupling.sequence_type = "XY4"

job = sampler.run(isa_circuits, shots=shots)
print(
f"job {job.job_id()}: {len(isa_circuits)} circuits x {shots:,} shots "
f"on {backend.name}"
)
return [pub.data.meas for pub in job.result()]

def pool_samples(bit_arrays, sp):
"""Merge the ensemble's bit arrays into one bitstring matrix and probability vector."""
matrices, weights, total = [], [], 0
for bit_array in bit_arrays:
matrix, probabilities = bit_array_to_arrays(bit_array)
matrices.append(matrix)
weights.append(probabilities * bit_array.num_shots)
total += bit_array.num_shots
counts = np.concatenate(weights)
matrix = np.vstack(matrices)
# the same bitstring can appear in more than one circuit; merge duplicate rows
unique, inverse = np.unique(matrix, axis=0, return_inverse=True)
merged = np.zeros(len(unique))
np.add.at(merged, inverse.ravel(), counts)
return unique, merged / merged.sum(), total
bit_arrays_sd = sample(isa_sd, SHOTS, JOB_TAGS + ["20Ne"])
matrix_sd, probs_sd, shots_sd = pool_samples(bit_arrays_sd, sp_sd)

survivors_sd, _ = postselect_by_hamming_right_and_left(
matrix_sd,
probs_sd.copy(),
hamming_right=N_PROTONS,
hamming_left=N_NEUTRONS,
)
shot_survival_sd = float(
probs_sd[
(matrix_sd[:, len(sp_sd) // 2 :].sum(axis=1) == N_PROTONS)
& (matrix_sd[:, : len(sp_sd) // 2].sum(axis=1) == N_NEUTRONS)
].sum()
)

reference_bits = "".join(
"1" if q in occ_sd else "0" for q in range(len(sp_sd))
)[::-1]
print(f"\n{shots_sd:,} shots -> {len(matrix_sd):,} distinct bitstrings")
print(
f" {shot_survival_sd:6.1%} of shots carry the right proton and neutron numbers"
)
print(f" {len(survivors_sd):,} distinct bitstrings do")

order = np.argsort(-probs_sd)
half = len(sp_sd) // 2
print(f"\n{'neutrons':>{half}} | {'protons':<{half}} share")
for i in order[:4]:
bits = "".join("1" if b else "0" for b in matrix_sd[i])
tag = " <- reference determinant" if bits == reference_bits else ""
print(f"{bits[:half]} | {bits[half:]} {probs_sd[i]:6.2%}{tag}")

if len(survivors_sd) == 0:
raise RuntimeError(
"no shot carried the right nucleon numbers; check the backend and "
"the transpiled circuits before spending more QPU time"
)
job dap30qtr85ps73fg21p0: 16 circuits x 10,000 shots on ibm_phoenix

160,000 shots -> 17,221 distinct bitstrings
31.5% of shots carry the right proton and neutron numbers
973 distinct bitstrings do

neutrons | protons share
001000010000 | 001000010000 20.08% <- reference determinant
000000010000 | 001000010000 2.25%
001000010000 | 001000000000 2.19%
001000010000 | 000000010000 1.93%

Krok 4: Následně zpracuj a vrať výsledek v požadovaném klasickém formátu​

Převeď kvantové vzorky na odhad energie pomocí omezení jaderných symetrií popsaných v části Pozadí.

Obnova konfigurací opravuje dva počty nukleonů. recover_configurations vezme každé měření, které má špatný počet protonů nebo neutronů, a překlopí bity, které nejméně odpovídají aktuálnímu odhadu průměrných obsazení orbitalů, místo aby jej zahodila. Při prvním průchodu odhad obsazení pochází z měření, která už přežila; poté pochází z vlastního vektoru předchozího podprostoru, čímž je postup samokonzistentní.

MJM_J a parita se vynucují na rekombinovaných součinech, nikoli na celých měřeních. Každé opravené měření přispívá protonovou a neutronovou polovinou a podprostor je generován každým součinem vzorkované protonové konfigurace se vzorkovanou neutronovou konfigurací, který padne na MJ=0M_J = 0 se správnou paritou. Filtrování celých měření podle celkového MJM_J by zahodilo dvě dobré poloviny kvůli kvantovému číslu, které patří jejich kombinaci.

Čtyři kontroly kvantových čísel zamítají různé podíly vzorků. Dva počty nukleonů vysvětlují většinu filtrování. Parita je uvnitř jediné hlavní slupky automaticky splněna: každý orbital sdsd má sudé ℓ\ell a každý orbital pfpf liché ℓ\ell, takže jakmile jsou počty nukleonů správné, parita nemůže být chybná. Kontrola parity se ponechává, protože modelový prostor přes více slupek by z ní udělal nezávislé omezení. Kontrola MJM_J ponechává součiny v cílovém sektoru momentu hybnosti. Hodnota čtyř přesných kvantových čísel spočívá v tom, že jsou levná a přesná, nikoli v tom, že by každé z nich bylo velkým filtrem.

Diagonalizace dává variační horní mez. Protože podprostor každé iterace obsahuje předchozí, posloupnost energií monotónně klesá a každý její člen je přísná horní mez energie skutečného základního stavu, bez ohledu na šum ve vzorcích, které ji vytvořily.

result_sd = recovery_loop(
inter_sd,
sp_sd,
matrix_sd,
probs_sd,
occ_sd,
N_PROTONS,
N_NEUTRONS,
max_iterations=4,
max_dimension=MAX_DIMENSION,
seed=42,
)

E_SQD_SD = result_sd["energy"]
recovered_sd = 100 * (E_SQD_SD - E_REF_SD) / (E_EXACT_SD - E_REF_SD)

print(f"\nreference determinant {E_REF_SD:11.6f} MeV")
print(
f"pooled SQD upper bound {E_SQD_SD:11.6f} MeV "
f"(subspace dimension {len(result_sd['basis'])} of {len(basis_exact_sd)})"
)
print(f"exact diagonalization {E_EXACT_SD:11.6f} MeV")
print(f"\ncorrelation energy recovered: {recovered_sd:.1f}%")

energies_sd = [h["energy"] for h in result_sd["history"]]
if any(b > a + 1e-9 for a, b in zip(energies_sd, energies_sd[1:])):
raise AssertionError(
"the subspaces are not nested; the bound should never rise"
)
if E_SQD_SD < E_EXACT_SD - 1e-7:
raise AssertionError(
f"pooled SQD returned {E_SQD_SD:.6f}, below the exact {E_EXACT_SD:.6f}; "
"a subspace bound cannot beat the full diagonalization"
)
iteration 1: 66 proton x 66 neutron halves -> dimension 640, E = -40.472331 MeV
iteration 2: 66 proton x 66 neutron halves -> dimension 640, E = -40.472331 MeV

reference determinant -29.765549 MeV
pooled SQD upper bound -40.472331 MeV (subspace dimension 640 of 640)
exact diagonalization -40.472331 MeV

correlation energy recovered: 100.0%

Vyhodnoť výsledky​

Pomocí následujících kontrol vyhodnoť své výsledky na backendu třídy Heron s těmito nastaveními:

  • Přežití měření u dvou počtů nukleonů udává podíl měření se správným počtem protonů a neutronů. Může klesat s rostoucím registrem. Míra přežití blízká nule může signalizovat problém s provedením obvodu. Zkontroluj hloubku ISA v Kroku 2 a kalibraci backendu, ne následné zpracování.

  • Smyčka obnovy by měla vypisovat dimenzi podprostoru, která zůstává konstantní nebo roste, a energii, která zůstává konstantní nebo klesá s každou iterací. Pokud iterace 1 už dosáhne MAX_DIMENSION, omezujícím faktorem je klasický řešič a nikoli vzorkování.

  • Obnovený podíl pro 20Ne^{20}\mathrm{Ne} by měl být vysoký, protože strop ansatzu spočítaný v Kroku 1 je celý prostor 640 determinantů; v tomto běhu je jedinou překážkou vzorkování, nikoli expresivita.

  • Dvě aserce v předchozí buňce kontrolují variační meze. Mez, která roste, znamená, že podprostory přestaly být vnořené, a mez pod přesnou energií znamená, že je něco špatně s hamiltoniánem, ne s hardwarem.

Překvapivě může hlučnější backend dát o něco lepší mez než čistý, protože chyby vytvářejí platné poloviční konfigurace, které by ideální obvod nikdy nevzorkoval, a rozšíření variačního podprostoru nemůže zvýšit jeho nejnižší vlastní hodnotu. Stejný efekt může ukázat i simulace se šumem; tento tutoriál jej ukazuje na vzorcích z hardwaru.

# IBM Carbon palette: Blue 60 and Blue 80 for data, Gray 100/70/30 for ink and rules
SURFACE, INK, MUTED, RULE = "#ffffff", "#161616", "#6f6f6f", "#c6c6c6"
SERIES, DEEP, PURPLE = "#0f62fe", "#002d9c", "#6929c4"

def convergence_plot(
history, e_ref, e_exact, title, colour=SERIES, full_dim=None
):
"""Energy against subspace dimension, scaled to the data rather than to the full window.

A good run lands within a fraction of a percent of the exact answer, so an axis spanning
reference-to-exact would squash every point onto one line. The axis is therefore scaled to
the data (plus the exact line, when there is one), and the right-hand axis carries the
fraction of the correlation energy so the absolute and relative readings sit side by side.
"""
dimensions = [h["dimension"] for h in history]
energies = [h["energy"] for h in history]

fig, ax = plt.subplots(figsize=(7.4, 4.3), facecolor=SURFACE)
ax.set_facecolor(SURFACE)
ax.plot(
dimensions,
energies,
"-o",
color=colour,
linewidth=2,
markersize=8,
markeredgecolor=SURFACE,
markeredgewidth=1.5,
zorder=3,
)
stacked = {}
for h in history:
# a converged loop repeats the same point; stack the labels so they do not overprint
key = (round(h["dimension"]), round(h["energy"], 9))
offset = 12 + 11 * stacked.get(key, 0)
stacked[key] = stacked.get(key, 0) + 1
ax.annotate(
str(h["iteration"]),
xy=(h["dimension"], h["energy"]),
xytext=(0, offset),
textcoords="offset points",
ha="center",
fontsize=8,
color=MUTED,
)

span = (max(dimensions) - min(dimensions)) or max(1, max(dimensions) // 4)
x_left, x_right = (
min(dimensions) - 0.14 * span,
max(dimensions) + 0.40 * span,
)
ax.set_xlim(x_left, x_right)

floor = min(energies) if e_exact is None else min(min(energies), e_exact)
height = max(max(energies) - floor, 1e-3)
ax.set_ylim(floor - 0.30 * height, max(energies) + 0.42 * height)

if e_exact is not None:
ax.axhline(
e_exact, color=MUTED, linestyle="--", linewidth=1, zorder=1
)
label = "exact" + (f", {full_dim:,} determinants" if full_dim else "")
ax.annotate(
f"{label} {e_exact:.3f} MeV".replace("-", "\u2212"),
xy=(x_left, e_exact),
xytext=(3, 5),
textcoords="offset points",
ha="left",
va="bottom",
color=MUTED,
fontsize=9,
)

# the reference determinant is far off this scale; state it rather than plotting it
ax.annotate(
f"reference determinant {e_ref:.3f} MeV".replace("-", "\u2212")
+ f" ({e_ref - max(energies):+.2f} MeV off the top of this axis)".replace(
"-", "\u2212"
),
xy=(x_right, max(energies) + 0.42 * height),
xytext=(-3, -12),
textcoords="offset points",
ha="right",
va="top",
color=MUTED,
fontsize=8.5,
)

if e_exact is not None and abs(e_exact - e_ref) > 1e-9:
right = ax.twinx()
low, high = ax.get_ylim()

def to_percent(e):
return 100 * (e - e_ref) / (e_exact - e_ref)

right.set_ylim(to_percent(low), to_percent(high))
right.set_ylabel("correlation energy recovered (%)", color=MUTED)
right.tick_params(colors=MUTED)
for side in ("top", "left"):
right.spines[side].set_visible(False)
right.spines["right"].set_color(MUTED)
right.spines["bottom"].set_color(MUTED)

ax.set_xlabel("subspace dimension", color=MUTED)
ax.set_ylabel("ground-state energy (MeV)", color=MUTED)
ax.set_title(title, color=INK, fontsize=11.5, loc="left", pad=12)
ax.grid(axis="y", color=RULE, alpha=0.55)
ax.tick_params(colors=MUTED)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("bottom", "left"):
ax.spines[side].set_color(MUTED)
fig.tight_layout()
return fig

convergence_plot(
result_sd["history"],
E_REF_SD,
E_EXACT_SD,
f"$^{{20}}$Ne: the bound falls as configuration recovery widens the subspace\n"
f"({backend.name}, {N_CIRCUITS} circuits x {SHOTS:,} shots)",
full_dim=len(basis_exact_sd),
)
plt.show()

Output of the previous code cell

Příklad na hardwaru ve velkém rozsahu​

Zvětšení mění pouze vstupy, takže dalším krokem je spojit čtyři fáze do jedné funkce a spustit ji dvakrát, pokaždé na 40qubitovém registru ve slupce pfpf nad jádrem 40Ca^{40}\mathrm{Ca} s interakcí GXPF1 [3].

Tyto dva běhy ilustrují různé aspekty škálování:

  • 44Ti^{44}\mathrm{Ti}, dva valenční protony a dva valenční neutrony, má bázi o 4 000 determinantech. Registr má 40 qubitů, ale úloha je stále dost malá na přesnou diagonalizaci na notebooku, takže můžeš po zvětšení registru porovnat výsledek z hardwaru s přesnou referencí.

  • 48Cr^{48}\mathrm{Cr}, čtyři valenční protony a čtyři valenční neutrony, má ve stejných 40 qubitech 1 963 461 determinantů povolených symetriemi. Hustý řešič tutoriálu tento úplný prostor diagonalizovat nedokáže, takže běh vrátí přísnou horní mez a referenční determinant, který zlepšuje.

Sleduj ve dvou bězích dvě veličiny. Podíl fondu, který se vejde do pevného rozpočtu hradel, se s růstem fondu zmenšuje, a pack_ensemble hlásí, kolik je zahrnuto. Podprostor přestává být omezen vzorkováním a začíná být omezen MAX_DIMENSION, největší maticí, kterou zde hustý klasický řešič sestaví. V tomto měřítku by produkční výpočet použil řešič vybrané konfigurační interakce (selected-CI).

Spojení kroků 1–4​

Následující funkce volá stejné fáze jako průvodce postupem, ve stejném pořadí.

def sqd_run(snt_file, n_protons, n_neutrons, name, exact=True):
"""The whole workflow for one nucleus. Returns a record of every stage."""
# -------------------------Step 1-------------------------
ms = read_snt(DATA / snt_file, n_protons, n_neutrons)
sp = m_scheme_states(ms)
inter = Interaction(ms, sp)
if sum(1 for s in sp if s.tz == -1) * 2 != len(sp):
raise ValueError(
f"{name}: post-selection needs equal proton and neutron state counts"
)
reference = reference_determinant(sp, inter, n_protons, n_neutrons)
e_ref = matrix_element(inter, reference, reference)

raw = excitation_pool(sp, reference)
ranked = rank_pool(
inter, reference, [op for op in raw if conserves_symmetry(sp, op)]
)
print(
f"{name}: {len(sp)} qubits, {n_protons}p + {n_neutrons}n, A = {ms.mass_number}"
)
print(
f" 2p2h pool {len(raw)} raw -> {len(ranked)} symmetry-allowed; "
f"reference energy {e_ref:.6f} MeV"
)

# -------------------------Step 2-------------------------
costs = excitation_costs(len(sp), ranked, costing_manager)
circuits, bins, isa = pack_to_budget(
len(sp),
reference,
ranked,
costs,
DEPTH_BUDGET,
N_CIRCUITS,
pass_manager,
)
packed = sum(len(b) for b in bins)
worst = max(two_qubit_depth(c) for c in isa)
worst_count = max(two_qubit_count(c) for c in isa)
print(
f" packed {packed} of {len(ranked)} excitations; worst circuit two-qubit depth "
f"{worst}, {worst_count} two-qubit gates"
)

# -------------------------Step 3-------------------------
# a unique tag per run, so the jobs are findable later
bit_arrays = sample(isa, SHOTS, JOB_TAGS + [name])
matrix, probabilities, shots = pool_samples(bit_arrays, sp)
survival = float(
probabilities[
(matrix[:, len(sp) // 2 :].sum(axis=1) == n_protons)
& (matrix[:, : len(sp) // 2].sum(axis=1) == n_neutrons)
].sum()
)
print(
f" {shots:,} shots -> {len(matrix):,} distinct bitstrings, "
f"{survival:.1%} of shots with the right nucleon numbers"
)
if survival == 0.0:
raise RuntimeError(
f"{name}: no shot carried the right nucleon numbers"
)

# -------------------------Step 4-------------------------
result = recovery_loop(
inter,
sp,
matrix,
probabilities,
reference,
n_protons,
n_neutrons,
max_iterations=4,
max_dimension=MAX_DIMENSION,
seed=42,
)
energy = result["energy"]

full_dim = count_basis(sp, n_protons, n_neutrons) # cheap, even when huge
e_exact = None
if exact:
full = full_basis(sp, n_protons, n_neutrons)
if len(full) != full_dim:
raise AssertionError(
f"{name}: counted {full_dim} determinants but enumerated "
f"{len(full)}"
)
e_exact, _ = ground_state(inter, full)

print(f" reference {e_ref:11.6f} MeV pooled SQD {energy:11.6f} MeV")
if e_exact is not None:
print(
f" exact {e_exact:11.6f} MeV (dimension {full_dim}) -> "
f"{100 * (energy - e_ref) / (e_exact - e_ref):.1f}% of the correlation energy"
)
if energy < e_exact - 1e-7:
raise AssertionError(
f"{name}: pooled SQD bound is below the exact energy"
)
else:
print(
f" no exact reference: the symmetry-allowed basis is {full_dim:,} determinants"
)
print(
f" the bound captures {energy - e_ref:.6f} MeV of correlation energy"
)
print()

return dict(
name=name,
qubits=len(sp),
pool=len(ranked),
packed=packed,
two_qubit=worst,
two_qubit_gates=worst_count,
shots=shots,
distinct=len(matrix),
survival=survival,
dimension=len(result["basis"]),
full_dim=full_dim,
e_ref=e_ref,
e_sqd=energy,
e_exact=e_exact,
history=result["history"],
# the subspace and its eigenvector cannot be reconstructed from the summary --
# they depend on the sampled shots -- so keep them for the scaling analysis
interaction=inter,
states=sp,
reference=reference,
ranked=ranked,
basis=result["basis"],
vector=result["vector"],
)

pretty = {"20Ne": "$^{20}$Ne", "44Ti": "$^{44}$Ti", "48Cr": "$^{48}$Cr"}

small_scale = dict(
name="20Ne",
qubits=len(sp_sd),
pool=len(ranked_sd),
packed=PACKED_SD,
two_qubit=worst_sd,
two_qubit_gates=worst_count_sd,
shots=shots_sd,
distinct=len(matrix_sd),
survival=shot_survival_sd,
dimension=len(result_sd["basis"]),
full_dim=len(basis_exact_sd),
e_ref=E_REF_SD,
e_sqd=E_SQD_SD,
e_exact=E_EXACT_SD,
history=result_sd["history"],
interaction=inter_sd,
states=sp_sd,
reference=occ_sd,
ranked=ranked_sd,
basis=result_sd["basis"],
vector=result_sd["vector"],
)

44Ti^{44}\mathrm{Ti}: stejný pracovní postup na 40qubitovém registru​

Slupka pfpf nad 40Ca^{40}\mathrm{Ca} má čtyři orbitaly na druh a po 20 magnetických podstavech, takže registr má 40 qubitů. Dva valenční protony a dva valenční neutrony tvoří 44Ti^{44}\mathrm{Ti} s 4 000 determinanty povolenými symetriemi — asi šestkrát větší než báze 20Ne^{20}\mathrm{Ne}, a to s 40 qubity místo 24.

Toto je větší ze dvou příkladů, které notebook dokáže vyřešit přesně, takže můžeš porovnat výsledek z hardwaru s přesnou referencí.

large_scale_verified = sqd_run("gxpf1.snt", 2, 2, "44Ti", exact=True)
44Ti: 40 qubits, 2p + 2n, A = 44
2p2h pool 1602 raw -> 174 symmetry-allowed; reference energy -44.309387 MeV
packed 96 of 174 excitations; worst circuit two-qubit depth 272, 285 two-qubit gates
job dap31a02fm4c73f67dp0: 16 circuits x 10,000 shots on ibm_phoenix
160,000 shots -> 48,170 distinct bitstrings, 18.6% of shots with the right nucleon numbers
iteration 1: 187 proton x 189 neutron halves -> dimension 3891, E = -47.849086 MeV
iteration 2: 190 proton x 190 neutron halves -> dimension 4000, E = -47.876666 MeV
iteration 3: 190 proton x 190 neutron halves -> dimension 4000, E = -47.876666 MeV
reference -44.309387 MeV pooled SQD -47.876666 MeV
exact -47.876666 MeV (dimension 4000) -> 100.0% of the correlation energy

48Cr^{48}\mathrm{Cr}: za hranicí kapacity přesné diagonalizace v tutoriálu​

Přidání dvou protonů a dvou neutronů používá stejný 40qubitový registr (4, 4 pro 48Cr^{48}\mathrm{Cr}) a zvětšuje velikost báze zhruba 491krát, na 1 963 461 determinantů povolených symetriemi. Taková matice je daleko za tím, co tento tutoriál sestaví, takže exact=False: neexistuje přesná referenční energie, jen variační mez a referenční determinant, který zlepšuje.

V tomto měřítku se mění dvě věci a obě jsou vidět ve výpisu. Fond povolených excitací naroste na několik set, takže pevný rozpočet hradel pokrývá už jen jeho menšinu, ne celý fond. Navíc podprostor součinů, který vzorky pokrývají, je větší než MAX_DIMENSION, takže hustý řešič ho zkrátí podle váhy ve vzorcích. Mez zůstává rigorózní, ale může být méně přesná než mez vypočtená ze všech navzorkovaných konfigurací. Produkční výpočet by si vzorky ponechal a použil řešič, který podporuje větší podprostor.

large_scale_unverified = sqd_run("gxpf1.snt", 4, 4, "48Cr", exact=False)
48Cr: 40 qubits, 4p + 4n, A = 48
2p2h pool 5536 raw -> 582 symmetry-allowed; reference energy -93.041237 MeV
packed 96 of 582 excitations; worst circuit two-qubit depth 224, 279 two-qubit gates
job dap32a02fm4c73f67eog: 16 circuits x 10,000 shots on ibm_phoenix
160,000 shots -> 55,436 distinct bitstrings, 18.6% of shots with the right nucleon numbers
iteration 1: 223 proton x 223 neutron halves -> dimension 3977, E = -96.481598 MeV
iteration 2: 223 proton x 223 neutron halves -> dimension 3977, E = -96.481598 MeV
reference -93.041237 MeV pooled SQD -96.481598 MeV
no exact reference: the symmetry-allowed basis is 1,963,461 determinants
the bound captures -3.440361 MeV of correlation energy

Vyhodnocení výsledku bez přesné reference​

Běh pro 48Cr^{48}\mathrm{Cr} nemá v rámci tohoto tutoriálu přesnou referenci. Pomocí existujících vzorků posuď konvergenci a porovnej výsledek s klasickou výběrovou základnou, bez dalšího času na QPU či diagonalizace v celém prostoru.

Je to zkonvergované? Seřaď ponechané determinanty podle jejich váhy ve zkonvergovaném vlastním vektoru a podprostory se stanou vnořenými, takže diagonalizace úvodního bloku d×dd \times d pro řadu hodnot dd ukáže pokles meze napříč dvěma řády velikosti podprostoru. Pokud stále prudce klesá při největším dd, omezující je strop dimenze klasického řešiče a parametr, který je třeba zvýšit, je MAX_DIMENSION. Pokud se zploštila, přidání dalších ponechaných determinantů přinese jen malé zlepšení; budeš-li chtít jít dál, může být potřeba navzorkovat další konfigurace. Hamiltonián se sestaví jednou v plné velikosti a každý stupeň je jeho hlavní blok, takže celé procházení stojí jedno sestavení matice místo jednoho na každý stupeň.

Jak si stojí kvantové vzorkování oproti klasickému výběru? Porovnej s podprostorem stejné velikosti zvoleným klasickým výběrovým postupem: vezmi fond seřazený podle poruchové teorie v pořadí skóre, zvětši podprostor součinů na stejnou dimenzi a diagonalizuj ten. Obě křivky jsou rigorózní horní meze pro tentýž hamiltonián, takže ta, která leží níže při stejné dimenzi, vybrala lepší determinanty. Toto srovnání rozhoduje, zda hardwarové vzorkování zlepšuje odhad energie oproti této klasické základně.

Tento podprostor není vybrán pro excitované stavy. Obnova konfigurací řídí podprostor pomocí obsazení základního stavu, takže vyšší vlastní hodnoty jsou od konvergence mnohem dál než nejnižší a první excitační energie vychází výrazně nad naměřenou hodnotu 2+2^+. Pro řádné dosažení excitovaných stavů je potřeba podprostor vybraný pro ně.

def subspace_scaling(
inter, basis, vector, points=18, smallest=32, largest=None
):
"""Nested Rayleigh-Ritz sweep: the lowest eigenvalue of the leading d x d block, for a ladder of d.

Reordering the basis by descending weight in the converged eigenvector makes every subspace in
the ladder a subset of the next, so the energies fall monotonically and each one is a valid
variational bound. H is built once at full size; each rung is a principal block.
"""
order = np.argsort(-(np.abs(vector) ** 2))
ordered = [basis[i] for i in order]
weights = (np.abs(vector) ** 2)[order]
if (
largest is not None
): # cap the ladder so two subspaces end at a common dimension
ordered, weights = ordered[:largest], weights[:largest]
H = subspace_hamiltonian(inter, ordered)
dimensions = np.unique(
np.geomspace(smallest, len(ordered), points).astype(int)
)
rows = [
(
int(d),
float(
eigh(H[:d, :d], eigvals_only=True, subset_by_index=[0, 0])[0]
),
)
for d in dimensions
]
return rows, np.cumsum(weights)

def classical_selection(
inter, sp, reference, ranked, target, n_protons, n_neutrons
):
"""The subspace classical perturbative ranking would pick, grown to `target` dimension.

Same product construction as the sampled subspace, and the same truncation discipline -- half
configurations are offered to `grow_subspace` in order of importance and it takes as many as
fit. The only difference from the sampled path is where the ordering comes from: PT2 score
here, measured sampling weight there. So the comparison isolates *which determinants got
chosen* and nothing else.

Truncating by any other rule would not be a fair baseline. Slicing an arbitrarily ordered
list, for instance, keeps determinants by accident rather than by importance and makes the
classical subspace look worse than classical selection really is.
"""
p_ref = tuple(i for i in reference if sp[i].tz == -1)
n_ref = tuple(i for i in reference if sp[i].tz == +1)
reached = {reference}
proton_order, neutron_order = [p_ref], [n_ref]
seen_p, seen_n = {p_ref}, {n_ref}
product_budget = 4 * target

for op, _, _ in ranked: # ranked is already in descending PT2 score
h1, h2, v1, v2 = op
fresh = {
tuple(sorted(set(d) - {h1, h2} | {v1, v2}))
for d in reached
if {h1, h2} <= set(d) and not {v1, v2} & set(d)
}
reached |= fresh
for det in fresh: # first appearance fixes a half's rank
half_p = tuple(i for i in det if sp[i].tz == -1)
half_n = tuple(i for i in det if sp[i].tz == +1)
if half_p not in seen_p:
seen_p.add(half_p)
proton_order.append(half_p)
if half_n not in seen_n:
seen_n.add(half_n)
neutron_order.append(half_n)
if len(proton_order) * len(neutron_order) > product_budget:
# Half-configuration products over-count the subspace, because only the
# symmetry-allowed ones survive `product_subspace`. Stopping on the product
# count alone can therefore leave the basis far short of `target`, so check
# the dimension actually realized and widen the budget if it falls short.
trial, _, _ = grow_subspace(
sp,
[p_ref],
[n_ref],
proton_order,
neutron_order,
n_protons,
n_neutrons,
max_dimension=target,
)
if len(trial) >= target:
break
product_budget *= 2

basis, _, _ = grow_subspace(
sp,
[p_ref],
[n_ref],
proton_order,
neutron_order,
n_protons,
n_neutrons,
max_dimension=target,
)
return basis

run = large_scale_unverified
if "basis" not in run:
raise RuntimeError(
"this cell needs the subspace and eigenvector that sqd_run now returns; "
"re-run the sqd_run definition and the 48Cr cell"
)

print(
f"{run['name']}: sweeping nested subspaces of the sampled basis "
f"(dimension {run['dimension']})"
)
sampled_rows, cumulative = subspace_scaling(
run["interaction"], run["basis"], run["vector"]
)

print(
f"{run['name']}: building the classically selected subspace at the same dimension"
)
classical_basis = classical_selection(
run["interaction"],
run["states"],
run["reference"],
run["ranked"],
run["dimension"],
4,
4,
)
# Both subspaces must be scored at the same dimension. Symmetry filtering can still leave
# the classical construction short of the target when the ranked pool runs out, so take the
# dimension both actually reach, cap both ladders there, and verify they agree.
common_dim = min(sampled_rows[-1][0], len(classical_basis))
if common_dim < sampled_rows[-1][0]:
sampled_rows, _ = subspace_scaling(
run["interaction"], run["basis"], run["vector"], largest=common_dim
)
classical_rows, _ = subspace_scaling(
run["interaction"],
classical_basis,
ground_state(run["interaction"], classical_basis)[1],
largest=common_dim,
)
if sampled_rows[-1][0] != classical_rows[-1][0]:
raise RuntimeError(
f"comparison dimensions differ: sampled {sampled_rows[-1][0]}, "
f"classical {classical_rows[-1][0]}"
)

advantage = sampled_rows[-1][1] - classical_rows[-1][1]
direction = "lower" if advantage < 0 else "higher"
verdict = "beats" if advantage < 0 else "does not beat"
descent = next(
e for d, e in reversed(sampled_rows) if d <= sampled_rows[-1][0] / 2
)
for fraction in (0.90, 0.99):
count = int(np.searchsorted(cumulative, fraction) + 1)
print(
f" {fraction:.0%} of the eigenvector norm sits on {count} determinants "
f"({count / run['full_dim']:.1e} of the {run['full_dim']:,}-determinant space)"
)
print(
f" bound still falling {1000 * (sampled_rows[-1][1] - descent):+.1f} keV "
f"over the last doubling of dimension"
)
print(
f" sampled {sampled_rows[-1][1]:.6f} MeV vs classically selected "
f"{classical_rows[-1][1]:.6f} MeV at a verified common dimension of "
f"{classical_rows[-1][0]:,}"
)
print(
f" -> the sampled subspace is {abs(advantage) * 1000:.0f} keV {direction}"
)
48Cr: sweeping nested subspaces of the sampled basis (dimension 3977)
48Cr: building the classically selected subspace at the same dimension
90% of the eigenvector norm sits on 107 determinants (5.4e-05 of the 1,963,461-determinant space)
99% of the eigenvector norm sits on 593 determinants (3.0e-04 of the 1,963,461-determinant space)
bound still falling -15.6 keV over the last doubling of dimension
sampled -96.481598 MeV vs classically selected -95.314510 MeV at a verified common dimension of 3,957
-> the sampled subspace is 1167 keV lower
fig, axes = plt.subplots(1, 2, figsize=(11.2, 4.0), facecolor=SURFACE)

# left: two nested convergence curves on the same axes
ax = axes[0]
ax.set_facecolor(SURFACE)
ax.plot(
[d for d, _ in sampled_rows],
[e for _, e in sampled_rows],
"-o",
color=SERIES,
linewidth=2,
markersize=5,
markeredgecolor=SURFACE,
markeredgewidth=1,
zorder=4,
label="sampled on the QPU",
)
ax.plot(
[d for d, _ in classical_rows],
[e for _, e in classical_rows],
"--s",
color=MUTED,
linewidth=1.6,
markersize=4,
markeredgecolor=SURFACE,
markeredgewidth=1,
zorder=3,
label="classically selected, same size",
)
ax.axhline(run["e_ref"], color=RULE, linestyle=":", linewidth=1.2, zorder=1)
ax.annotate(
f"reference determinant {run['e_ref']:.2f} MeV".replace("-", "\u2212"),
xy=(sampled_rows[-1][0], run["e_ref"]),
xytext=(-2, 4),
textcoords="offset points",
ha="right",
va="bottom",
color=MUTED,
fontsize=8,
)

# mark the gap between the two curves at the largest dimension, not either curve alone
edge = sampled_rows[-1][0]
ax.plot(
[edge, edge],
[classical_rows[-1][1], sampled_rows[-1][1]],
"-",
color=SERIES,
linewidth=1.0,
alpha=0.7,
zorder=2,
)
ax.annotate(
f"{abs(advantage) * 1000:.0f} keV {direction}\nat equal dimension",
xy=(edge, 0.5 * (classical_rows[-1][1] + sampled_rows[-1][1])),
xytext=(-8, 0),
textcoords="offset points",
ha="right",
va="center",
color=SERIES,
fontsize=8.5,
)

ax.set_xscale("log")
ax.set_xlim(sampled_rows[0][0] * 0.75, edge * 1.5)
ax.set_xlabel("subspace dimension", color=MUTED)
ax.set_ylabel("variational upper bound (MeV)", color=MUTED)
ax.set_title(
f"{pretty[run['name']]}: the bound, and the subspace it {verdict}",
color=INK,
fontsize=11,
loc="left",
pad=10,
)
legend = ax.legend(frameon=False, fontsize=8.5, loc="lower left")
for text in legend.get_texts():
text.set_color(MUTED)

# right: why a few thousand determinants can bound two million
ax = axes[1]
ax.set_facecolor(SURFACE)
ranks = np.arange(1, len(cumulative) + 1)
ax.plot(ranks, 100 * cumulative, "-", color=DEEP, linewidth=2, zorder=3)
for fraction, style, label_y in ((0.90, ":", 46), (0.99, "--", 24)):
count = int(np.searchsorted(cumulative, fraction) + 1)
ax.axvline(count, color=MUTED, linestyle=style, linewidth=1, zorder=1)
ax.annotate(
f"{fraction:.0%} of the norm\non {count} determinants",
xy=(count, label_y),
xytext=(7, 0),
textcoords="offset points",
ha="left",
va="center",
color=MUTED,
fontsize=8.5,
)
ax.set_xscale("log")
ax.set_xlim(0.8, len(cumulative) * 2.6)
ax.set_ylim(0, 104)
ax.set_xlabel("determinants, ordered by weight", color=MUTED)
ax.set_ylabel("cumulative share of the eigenvector (%)", color=MUTED)
ax.set_title(
f"Sparsity: {run['full_dim']:,} determinants in the sector",
color=INK,
fontsize=11,
loc="left",
pad=10,
)

for ax in axes:
ax.grid(axis="y", color=RULE, alpha=0.55)
ax.tick_params(colors=MUTED, labelsize=8.5)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("bottom", "left"):
ax.spines[side].set_color(MUTED)
fig.tight_layout()
plt.show()

Output of the previous code cell

Porovnání tří běhů​

Absolutní energie nejsou srovnatelné napříč různými jádry a různými interakcemi, proto se zaměř na podíl obnovené korelační energie napříč běhy, kde je k dispozici přesná reference. Porovnej také hloubku obvodu a podíl zahozených shotů.

runs = [small_scale, large_scale_verified, large_scale_unverified]

print(
f"{'run':>6} {'qubits':>6} {'pool':>9} {'2q depth':>8} {'2q gates':>8} "
f"{'shots kept':>10} {'dim':>6} {'of':>9} {'% corr':>7}"
)
for r in runs:
fraction = (
"--"
if r["e_exact"] is None
else f"{100 * (r['e_sqd'] - r['e_ref']) / (r['e_exact'] - r['e_ref']):.1f}%"
)
coverage = "{}/{}".format(r["packed"], r["pool"])
print(
f"{r['name']:>6} {r['qubits']:>6} {coverage:>9} "
f"{r['two_qubit']:>8} {r['two_qubit_gates']:>8} {r['survival']:>9.1%} "
f"{r['dimension']:>6} {(r['full_dim'] or 0):>9,} {fraction:>7}"
)

print()
for r in runs:
exact = (
f"exact {r['e_exact']:11.6f}"
if r["e_exact"] is not None
else "exact unavailable"
)
print(
f"{r['name']:>6} reference {r['e_ref']:11.6f} pooled SQD {r['e_sqd']:11.6f} {exact} MeV"
)
run qubits pool 2q depth 2q gates shots kept dim of % corr
20Ne 24 78/78 228 234 31.5% 640 640 100.0%
44Ti 40 96/174 272 285 18.6% 4000 4,000 100.0%
48Cr 40 96/582 224 279 18.6% 3977 1,963,461 --

20Ne reference -29.765549 pooled SQD -40.472331 exact -40.472331 MeV
44Ti reference -44.309387 pooled SQD -47.876666 exact -47.876666 MeV
48Cr reference -93.041237 pooled SQD -96.481598 exact unavailable MeV
# Left: how much of the correlation energy was recovered, where the exact answer is known.
# Right: the bound itself for the run that has nothing to score against.
scored = [r for r in runs if r["e_exact"] is not None]

fig, ax = plt.subplots(figsize=(6.4, 3.9), facecolor=SURFACE)
ax.set_facecolor(SURFACE)
labels = [
f"{pretty[r['name']]}\n{r['qubits']} qubits\n{r['full_dim']:,} determinants"
for r in scored
]
fractions = [
100 * (r["e_sqd"] - r["e_ref"]) / (r["e_exact"] - r["e_ref"])
for r in scored
]
shades = [SERIES, DEEP, PURPLE]
bars = ax.bar(
labels, fractions, width=0.46, color=shades[: len(scored)], zorder=3
)
for bar, fraction, r in zip(bars, fractions, scored):
ax.annotate(
f"{fraction:.1f}%",
xy=(bar.get_x() + bar.get_width() / 2, fraction),
xytext=(0, 5),
textcoords="offset points",
ha="center",
va="bottom",
color=INK,
fontsize=10,
)
ax.annotate(
f"dim {r['dimension']:,}",
xy=(bar.get_x() + bar.get_width() / 2, 3),
ha="center",
va="bottom",
color=SURFACE,
fontsize=8.5,
)
ax.axhline(100, color=MUTED, linestyle="--", linewidth=1, zorder=1)
ax.annotate(
"exact diagonalization",
xy=(-0.45, 100),
xytext=(0, 4),
textcoords="offset points",
ha="left",
va="bottom",
color=MUTED,
fontsize=8.5,
)
ax.set_ylim(0, 118)
ax.set_ylabel("correlation energy recovered (%)", color=MUTED)
ax.set_title(
f"Where the exact answer is known ({backend.name})",
color=INK,
fontsize=11,
loc="left",
pad=10,
)
ax.grid(axis="y", color=RULE, alpha=0.55)
ax.tick_params(colors=MUTED, labelsize=8.5)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("bottom", "left"):
ax.spines[side].set_color(MUTED)
fig.tight_layout()
plt.show()

# the same convergence view as the walkthrough, for the run with no exact reference
convergence_plot(
large_scale_unverified["history"],
large_scale_unverified["e_ref"],
None,
f"{pretty[large_scale_unverified['name']]}: "
f"{large_scale_unverified['full_dim']:,} determinants, no exact answer to score against\n"
f"({backend.name}, {N_CIRCUITS} circuits x {SHOTS:,} shots)",
colour=DEEP,
)
plt.show()

Output of the previous code cell

Output of the previous code cell

Shrnutí​

Jeden pracovní postup, beze změny kromě vstupů, běžel na QPU ve třech velikostech problému: 24qubitový problém, který lze přesně ověřit, 40qubitový problém, který lze stále přesně ověřit, a 40qubitový problém s téměř dvěma miliony bázových stavů, který přesahuje možnosti přesné diagonalizace v tomto tutoriálu.

Tyto tři běhy ilustrují následující body:

  • Kvantový krok má pouze navrhovat determinanty. Obvod je pevný, inicializovaný z poruchové teorie druhého řádu a nikdy neoptimalizovaný. Nic v pracovním postupu nevyžaduje přesné amplitudy, jen užitečný nosič. Klasická diagonalizace ve vybraném podprostoru dává variační horní mez, i když mez kolísá podle navzorkovaných konfigurací.

  • Qubitové excitace snižují hloubku obvodu. Protože záleží jen na nosiči, fermionové excitační bloky lze nahradit qubitovými excitacemi, jejichž cena neroste se vzdáleností mezi orbitaly, které spojují. Krok 2 změřil úsporu na skutečném backendu, což je rozdíl mezi obvodem, který se pohodlně vejde do koherence, a tím, který ne.

  • Obnova konfigurací znovu využívá zašuměné vzorky. Každý shot s nesprávným počtem protonů nebo neutronů se opraví podle aktuálního odhadu obsazení místo zahození a každá opravená poloviční konfigurace může do podprostoru přidat konfigurace. Rozšíření variačního podprostoru nemůže zvýšit jeho nejnižší vlastní hodnotu. Tento tutoriál předvádí obnovu konfigurací pomocí hardwarových vzorků.

  • Omezující podmínka se s růstem měřítka posouvá. U 24 qubitů mohl ansatz dosáhnout přesné odpovědi a v cestě stálo jen vzorkování. U 40 qubitů se čtyřmi valenčními nukleony na druh pokrývá rozpočet hradel menšinu fondu a hustý klasický řešič omezuje podprostor. Vědět, který ze tří faktorů tě omezuje, je praktická dovednost, kterou tento pracovní postup učí.

Další kroky​

Doporučení

Prozkoumej tyto související zdroje:

Rozšíření k zvážení​

  • Nahraď hustý řešič. MAX_DIMENSION je strop všeho v měřítku 48Cr^{48}\mathrm{Cr} a důvodem je np.linalg.eigh na husté matici. Sestavení téhož projektovaného hamiltoniánu jako řídké matice a použití iterativního řešiče vlastních hodnot, například scipy.sparse.linalg.eigsh, nebo Davidsonova či selected-CI řešiče navrženého pro jaderné dvoutělesové interakce, by mohlo podporovat větší podprostory. Praktický limit závisí na řídkosti matice, dostupné paměti a konvergenci řešiče a tento tutoriál toto rozšíření nebenchmarkuje. Funkce qiskit_addon_sqd.fermion.solve_sci z doplňku SQD není plnohodnotná náhrada: obaluje řešič elektronové struktury a očekává jedno- a dvoutělesové integrály v této podobě, takže sdílená součinová struktura proton ×\times neutron sama o sobě nestačí. Její použití by znamenalo převést interakci slupkového modelu z rovnice (1) na tyto integrály a ověřit výsledek vůči přesným energiím, které tento notebook už počítá.

  • Přidej dávkování a podvzorkování. Publikovaný pooled SQD pracovní postup diagonalizuje několik nezávislých podvzorků na iteraci a ponechá nejlepší. Tento tutoriál používá jednu dávku na iteraci, což je bezpečné pro variační mez, ale neposkytuje informaci o rozptylu, která naznačuje, zda by více shotů pomohlo.

  • Excitované stavy a další sektory. Vyšší vlastní hodnoty hamiltoniánu každého podprostoru jsou horní meze pro excitované stavy ve stejném symetrickém sektoru a běh při MJ≠0M_J \neq 0 dosáhne na další sektory. Kontrola 2+2^+ v kroku 1 je už polovinou tohoto výpočtu.

  • Mezislupkový modelový prostor. Parita je uvnitř jedné hlavní slupky splněna automaticky, což je důvod, proč zde nehraje roli. Prostor sdsd-pfpf míchá parity ℓ\ell, takže parita se stává skutečnou čtvrtou podmínkou, kterou by samotná Hammingova oprava SQD ani součinová konstrukce nezachytily.

  • Jádra s lichou hmotností. reference_determinant vyžaduje sudý počet valenčních částic v každém druhu, protože časově obrácené párové zaplnění vynucuje MJ=0M_J = 0. Liché jádro potřebuje polovičně celočíselný cíl MJM_J a nespárovanou referenci.

Dodatek​

Tato sekce vysvětluje úvahy za pomocnými funkcemi zavedenými v sekci Nastavení.

Proč přeškálování závislé na hmotnosti není volitelné​

Empirické slupkové interakce jsou fitovány při jedné hmotnosti a aplikovány na řetězec izotopů, přičemž dvoutělesové maticové elementy se škálují jako (A/Aref)p(A/A_{\mathrm{ref}})^{p}. Oba soubory interakcí nesou p=−0.3p = -0.3, s Aref=18A_{\mathrm{ref}} = 18 pro rodinu USD a 4242 pro GXPF1. Na dvoutělesovém hlavičkovém řádku souboru .snt stojí tato dvě čísla tam, kam by se věrohodně hodila oscilátorová frekvence a energie jádra, což je činí snadno zaměnitelnými; čtení exponentu jako konstantní energie jádra přidá falešný posun ke každému diagonálnímu prvku a vynechá přeškálování, což změní korelační energii o několik procent. Kontrola symetrie v kroku 1 sama o sobě energetickou škálu neověřuje. Porovnání excitační energie 2+2^+, měřené v MeV, s experimentem poskytuje další kontrolu přeškálování závislého na hmotnosti. Excitační energie je rozdíl mezi hladinami, takže konstantní posun aplikovaný na všechny energie neodhalí.

Proč se reference hledá prohledáváním, a ne zaplňováním​

Zjevnou referencí je determinant, který zaplňuje nejnižší jednočásticové energie. Není to determinant s nejnižší energií, protože diagonála rovnice (1) obsahuje dvoutělesový člen ∑i<j⟨ij∥ij⟩\sum_{i<j} \langle ij \| ij \rangle a párovací interakce silně preferuje obsazení časově obrácených partnerů (+mj,−mj)(+m_j, -m_j) při největším dostupném ∣mj∣|m_j|. Ve slupce sdsd je to rozdíl mezi dvojicí mj=±1/2m_j = \pm 1/2 a dvojicí mj=±5/2m_j = \pm 5/2 orbitalu 0d5/20d_{5/2} a činí zhruba 1 MeV; ve slupce pfpf je to blíže 2. Protože referenční energie definuje nulu metriky „obnovená korelační energie“, špatná volba tuto metriku nafoukne a poskytne méně přesný výchozí bod.

Omezení na párová zaplnění činí vyčerpávající hledání levným, s (npairsk)\binom{n_{\mathrm{pairs}}}{k} kandidáty na druh (nejvýše pár tisíc) a zajišťuje MJ=0M_J = 0. Ve všech případech v tomto tutoriálu, které lze ověřit proti úplnému výčtu, hledání vrátí globální determinant s nejnižší diagonálou, který je zároveň jedinou největší složkou přesného základního stavu.

Proč amplituda prvního řádu, a ne přesný dvouúrovňový úhel​

Diagonalizace hamiltoniánu 2×22 \times 2 v prostoru {∣Φref⟩,∣α⟩}\{|\Phi_{\mathrm{ref}}\rangle, |\alpha\rangle\} dává směšovací úhel θexact=12arctan⁡(2V/Δ)\theta_{\mathrm{exact}} = \tfrac{1}{2}\arctan(2V/\Delta); mohlo by být lákavé označit jej za správnou volbu pro izolovaný pár hladin. V tomto ansatzu působí několik desítek excitačních bloků postupně na stejnou referenci, takže optimalizace každého bloku zvlášť nemusí nutně optimalizovat složený obvod.

Úlohu obvodu určuje volba úhlu. Protože ∣12arctan⁡(2x)∣≤∣x∣|\tfrac{1}{2}\arctan(2x)| \le |x| pro každé reálné xx, je přesný úhel vždy co do velikosti menší než amplituda prvního řádu t=V/Δt = V/\Delta, a proto vždy ponechává více amplitudy na referenčním determinantu. Obvod, který ponechává více amplitudy na referenci, vrací referenci častěji a odlišné excitované determinanty méně často. Pro pooled SQD je užitečným výstupem shotu determinant, který klasický krok ještě neviděl, což motivuje použití většího úhlu v tomto tutoriálu. Žádný z úhlů nemusí být přesný, protože klasická diagonalizace amplitudy obvodu úplně zahodí a odvodí si vlastní.

Proč pooled SQD může používat qubitové excitace​

Fermionová excitace T=av1†av2†ah2ah1T = a_{v_1}^\dagger a_{v_2}^\dagger a_{h_2} a_{h_1} se při Jordan-Wignerově transformaci zobrazí na osm Pauliho řetězců, z nichž každý nese operátory ZZ na každém qubitu mezi nejkrajnějšími indexy. Tyto řetězce kódují fermionové znaménko a jejich cena roste s rozpětím, které je pro proton-neutronovou excitaci celý registr.

Jejich odstranění dává operátor qubitové excitace podle Yordanova a kol. [5]. Je to jiný operátor: stav, který připravuje, se od fermionového liší v znaménkách jeho amplitud a obě vzorkovací rozdělení se mohou podstatně lišit. Co se nemění, je, které determinanty mají nenulovou amplitudu, protože každý blok stále rotuje uvnitř téhož dvourozměrného prostoru {∣d⟩,∣d′⟩}\{|d\rangle, |d'\rangle\} pro každý determinant dd, na který působí, a stále přesně zachovává oba počty nukleonů, MJM_J i paritu. Dosažitelná množina determinantů je proto totožná a dosažitelná množina je jediné, co pooled SQD používá; klasická diagonalizace si přiřadí vlastní amplitudy tak jako tak. Krok 2 ověřuje tvrzení o totožném nosiči na reálném operátoru z fondu a měří, co náhrada ušetří.

Omezením je, že vzorkovací váhy se liší, takže obě konstrukce neobjeví determinanty ve stejném pořadí při konečném počtu shotů. Protože řazení, které rozhoduje, které excitace vstoupí do obvodů, je klasické a beze změny a klasický krok vše stejně převáží, je rozdíl ve vzorkovacích vahách kompromisem za sníženou hloubku obvodu.

Proč MJM_J patří do součinové fáze​

Postselekce i obnova konfigurací působí na Hammingovy váhy: počet protonů v jedné polovině registru a počet neutronů v druhé. MJ=Mp+MnM_J = M_p + M_n takového tvaru není. Je to vlastnost protonové konfigurace spárované s neutronovou konfigurací. Shot, jehož protonová polovina i neutronová polovina nesou správný počet nukleonů, obsahuje dvě použitelné poloviční konfigurace, i když se jejich hodnoty MJM_J nevyruší, protože protonová polovina s Mp=+1M_p = +1 je naprosto dobrá, jakmile je spárována s neutronovou polovinou s Mn=−1M_n = -1. Filtrování celých shotů podle celkového MJM_J zahodí obě poloviny a uvalení MJM_J na znovu složené součiny je zachová. Stejný argument vysvětluje, proč recover_configurations nepotřebuje v tomto případě pojem MJM_J, aby byla užitečná.

Reference​

  1. J. Robledo-Moreno, M. Motta, H. Haas, et al., "Chemistry beyond the scale of exact diagonalization on a quantum-centric supercomputer", Science Advances 11, eadu9991 (2025). arXiv:2405.05068

  2. B. A. Brown and W. A. Richter, "New USD Hamiltonians for the sd shell", Physical Review C 74, 034315 (2006). The embedded usda.snt file carries the USDA parameters as tabulated by W. A. Richter, S. Mkhize and B. A. Brown, "sd-shell observables for the USDA and USDB Hamiltonians", Physical Review C 78, 064302 (2008).

  3. M. Honma, T. Otsuka, B. A. Brown and T. Mizusaki, "Effective interaction for pf-shell nuclei", Physical Review C 65, 061301(R) (2002).

  4. B. Huron, J. P. Malrieu and P. Rancurel, "Iterative perturbation calculations of ground and excited state energies from multiconfigurational zeroth-order wavefunctions", The Journal of Chemical Physics 58, 5745 (1973).

  5. Y. S. Yordanov, D. R. M. Arvidsson-Shukur and C. H. W. Barnes, "Efficient quantum circuits for quantum computational chemistry", Physical Review A 102, 062612 (2020).

  6. National Nuclear Data Center, Evaluated Nuclear Structure Data File, Brookhaven National Laboratory. Zdroj naměřených excitačních energií 2+2^+ uvedených v kroku 1.