דלג לתוכן הראשי
פרויקט זה בשלב בטא. מצאת באג? פתח issue ב-GitHub. יש שאלה או רעיון? שאל ב-Discussions — נשמח לשמוע ממך!
מה איכות התרגום בדף זה?

מיפוי ייצוגי אופרטורים

מאפר (mapper) הופך BosonOperator לאופרטור של קיוביטים, כאן SparseObservable. האופרטור עצמו לא יודע דבר על קיוביטים: הוא שומר אופרטורי סולם על מצבים, ומצב בוזוני מכיל מספר שרירותי של חלקיקים. כל מה שהופך את האופרטור לסופי (כמה רמות אכלוס שומרים, ואיך הרמות האלה מסודרות על קיוביטים) נקבע ברגע המיפוי.

המאפר שבספרייה occupation_encoding() עובד בבסיס האכלוס (פוק): רמה nn של מצב היא המצב עם nn בוזונים. הוא מקבל את האופרטור ו-LevelSpec, שמכיל את הקטימה ואת הקידוד. המדריך הזה מסביר מה משמעות שתי הבחירות האלה ומה העלות של כל אחת. למסלול מקצה לקצה שמשתמש בהן, ראו סימולציה של דינמיקת Bose-Hubbard עם בחירת קידוד; ולאופן שבו ה-Transpiler מיישם את אותן בחירות על מעגל שלם, ראו Transpile של מעגלים בוזוניים.

קטימת מרחב פוק​

מצב שנשמר ל-d=nmax+1d = n_\text{max} + 1 רמות, כאשר nmaxn_\text{max} הוא ה-max_occupation שלו, כבר לא מציית ליחס החילוף הקנוני. אם נסמן ב-bˉ\bar{b} את אופרטור ההשמדה שנקטם לרמות האלה,

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

היחס מתקיים בכל רמה מלבד הגבוהה ביותר [1]. אפשר לראות זאת אם ממפים את החילופין של שני אופרטורי הסולם וקוראים את האלכסון שלו על הרמות של מצב אחד. פונקציית העזר הבאה בונה את המטריצה הצפופה של אופרטור ממופה למצב יחיד, וזה סביר רק לאוגרים קטנים מאוד:

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

האיבר האחרון הוא 1−d1 - d. מכיוון שזה אומר שהמרחב הנפרש על ידי {bˉ†,bˉ}\{\bar{b}^\dagger, \bar{b}\} אינו סגור תחת החילופין, שום קידוד אינו איזומורפיזם של אלגברות. קידוד ממפה מצבים, יחד עם האופרטורים שפועלים בתוך תת-המרחב הקטום. שתי ההשלכות הבאות חשובות בפועל.

הקטימה חייבת לחסום את האכלוס שאפשר להגיע אליו. לא מספיק לחסום את האכלוס שמתחילים ממנו. מפצל קרן הפועל על ∣1,1⟩\lvert 1, 1 \rangle מאכלס את ∣2,0⟩\lvert 2, 0 \rangle ואת ∣0,2⟩\lvert 0, 2 \rangle, ולכן קטימה של אחד מפיקה בשקט מצב שגוי:

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

עם קטימה של אחד, ההסתברות להישאר ב-∣1,1⟩\lvert 1, 1 \rangle היא אחד, כי שני המצבים שהדינמיקה הייתה מאכלסת לא קיימים. עם קטימה של שניים היא נעלמת, וזו אפקט Hong-Ou-Mandel. לכן max_occupation הוא ארגומנט חובה שאין לו ברירת מחדל: זו החלטת מידול ופרמטר התכנסות שמצפים ממכם לסרוק.

מיפוי לא מתחלף עם סידור נורמלי. האופרטורים bb†b b^\dagger ו-b†b+1b^\dagger b + 1 שווים לפני הקטימה, אבל הם ממופים לאופרטורי קיוביטים שונים:

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

השניים מתאימים בכל רמה מלבד הגבוהה ביותר. ברמה העליונה, ל-b†b^\dagger אין לאן ללכת והמשרעת נחתכת, ולכן bb†b b^\dagger הוא אפס שם, בעוד ש-b†b+1b^\dagger b + 1 נותן dd. החליטו פעם אחת אם ההמילטוניאן שלכם מוגדר בצורה מסודרת נורמלית, ומפו את הצורה הזו באופן עקבי.

שלוש שיטות עוזרות לבחור קטימה, וכולן חסמים תחתונים. האופרטור מדווח על המספר הגדול ביותר של אופרטורי יצירה שהוא מפעיל על כל מצב באיבר בודד, ו-spec משווה זאת לקטימות שלו:

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

ערך שאינו אפס אומר שהקטימה נמוכה מהחסם, והערך הוא בכמה. העוזרים האלה לא מכירים את הדינמיקה, ולכן בדיקה שעברה לא הופכת קטימה למספקת; למפצל הקרן שלמעלה יש חסם של אחד ובכל זאת הוא דורש קטימה של שניים. הם גם יכולים לדווח על חסר באיבר שהוא אפס על הרמות שנשמרו, כמו b†b†bbb^\dagger b^\dagger b b עבור קטימה של אחד.

תיאור הקטימה עם LevelSpec​

LevelSpec אורז יחד את הקטימה של כל מצב עם הקידוד, ומהם הוא גוזר כמה קיוביטים כל מצב תופס ואיפה הקיוביטים שלו מתחילים. כל מה שצריך להסכים על כך (המאפר, פריסת ה-Transpiler, הכנת המצב) מקבל את אותו spec. הקידוד הוא ארגומנט חובה, וכך גם הקטימה: לאף אחד מהם אין ברירת מחדל.

הקטימה היא או מספר שלם אחד, משותף לכל המצבים, או מספר שלם אחד לכל מצב:

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

spec שנבנה ממספר שלם יחיד משאיר את מספר המצבים פתוח, ולכן צריך לפתור אותו לפני שמבקשים רוחבים. רצף לעולם לא משודר, לא משנה מה אורכו, והוא קובע את מספר המצבים:

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

מצב mm תופס את הקיוביטים offsets[m] עד offsets[m] + widths[m] - 1, ולכן מצבים עם קטימות שונות יכולים לתפוס מספר שונה של קיוביטים, וה-offsets הם הסכום המצטבר של הרוחבים. אין צורך להעביר למאפר את מספר המצבים. הוא לוקח אותו מה-spec, או, עבור spec עם קטימה יחידה, מהאופרטור דרך min_num_modes(). האוגר יכול להיות רחב יותר ממה שהמצבים צריכים; הקיוביטים העודפים סרק ונמצאים בסוף:

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

אופרטור שפועל על מצב מעבר לאחרון ב-spec לפי מצב נדחה:

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

כמה מגבלות נובעות מהאופן שבו הקידודים סופרים קיוביטים. תחת קידוד קומפקטי, מצב עם רמה אחת (max_occupation = 0) לא תופס קיוביט בכלל, כי הרמה היחידה שלו לא צריכה דבר כדי להבדיל אותה מרמה אחרת. מצב כזה מותר ולא מזיז את הקיוביטים של המצבים שאחריו, אבל spec שבו כל מצב לא היה תופס קיוביט נדחה. הקידודים האונריים מחזיקים לכל היותר 128 רמות למצב, וקטימה גדולה יותר מעלה שגיאה שמציעה קידוד קומפקטי:

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

בחירת קידוד​

LevelEncoding קובע איזה מצב בסיס של בלוק קיוביטים מייצג כל רמה. יש ארבעה, ואף אחד מהם אינו הטוב ביותר באופן כללי [2]. מספר הקיוביטים למצב ותבנית הביטים של כל רמה נובעים מהקידוד:

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

תבניות הביטים הן מספרים שלמים שבהם ביט qq הוא הערך של קיוביט qq. הקידודים האונריים משקיעים קיוביט אחד לכל רמה ומדליקים אחד מהם. הקידודים הקומפקטיים משקיעים ⌈log⁡2d⌉\lceil \log_2 d \rceil קיוביטים ושומרים את מספר הרמה, בבינארי או כקוד גריי שבו רמות שכנות נבדלות בביט אחד. שם המחרוזת של קידוד מתקבל בכל מקום שבו מתקבל הקידוד.

הפשרה היא קיוביטים מול משקל האופרטור. הקידודים הקומפקטיים צריכים הרבה פחות קיוביטים (באופן מעריכי), אבל אופרטור סולם מחבר רמות שהתבניות שלהן יכולות להיות שונות בכמה ביטים, ולכן האיברים שלו פועלים על יותר קיוביטים. הסעיפים הבאים בוחנים מה זה עולה, החל בשני הקידודים האונריים, שיש להם פריסות זהות ונבדלים רק באופרטור.

השוואה בין שני הקידודים האונריים​

בקידוד האונרי אופרטור סולם מעביר עירור אחד בין שני קיוביטים שכנים. כדי לכתוב זאת כאופרטור על המצב כולו, צריך לבחור מה לעשות עם שאר הקיוביטים. שני הווריאנטים בוחרים אחרת:

  • unary_projected שם מטיל ∣0⟩⟨0∣\lvert 0 \rangle\langle 0 \rvert על כל קיוביט אחר של המצב. זה אוכף את אילוץ ה-one-hot באופרטור עצמו: האיבר פועל רק אם שאר המצב ריק.

  • unary_unprojected משאיר את הקיוביטים האלה בנחת, ולכן האיבר פועל עליהם כזהות.

הסתכלו על b†b^\dagger בודד ב-d=4d = 4 תחת שניהם. המאפר מחזיר SparseObservable, שהאיברים שלו הם מחרוזות של איברי ביט חד-קיוביטיים. הנה ארבעת האיברים הראשונים של כל אחד, עם הקיוביטים שעליהם הם פועלים:

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

לשניהם שנים עשר איברים עם אותם מקדמים. האיברים המוטלים נושאים איבר ביט 0 על שני הקיוביטים שהאיברים הבלתי מוטלים לא מזכירים, ולכן כל איבר מוטל נוגע בכל ארבעת הקיוביטים וכל איבר בלתי מוטל נוגע בשניים. באופן כללי איבר מוטל נוגע בכל dd הקיוביטים של המצב, כי הוא נושא שני גורמים, כל אחד XX או YY, ומטיל על כל קיוביט אחר.

שני האופרטורים שווים על המצבים שלשמם נועד הקידוד, מצבי הבסיס one-hot (מילות הקוד), ונבדלים מחוץ להם. כדי לבדוק, קחו אופרטור קטן על שני מצבים ב-d=3d = 3 והשוו את המטריצות על תשע מילות הקוד:

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

שניהם גם משאירים את תת-מרחב מילות הקוד אינווריאנטי, ולכן מצב שהוכן על מילת קוד נשאר על מילות קוד. הבחירה חשובה רק למצבים שיוצאים מתת-המרחב הזה, למשל בגלל רעש. שם האופרטור המוטל מדכא את ההתפתחות, כי ההטלות שלו נכשלות, בעוד שהבלתי מוטל ממשיך לפעול. אף אחת מההתנהגויות אינה דרישה פיזיקלית. האופרטור הבלתי מוטל מוגדר רק על תת-המרחב one-hot, ולכן בחירה בו היא החלטת מידול ולא אופטימיזציה חינמית.

מה שההטלות עולות הוא שערים. הפעילו התפתחות של b†+bb^\dagger + b הרמיטי על מצב אחד לזמן קצר, וספרו את השערים הדו-קיוביטיים אחרי ה-transpilation של Qiskit:

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

הספירות המדויקות תלויות באופן שבו Qiskit מסנתזת את שער ההתפתחות, שמשתנה בין גרסאות, ולכן צרו להן גרף:

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

הספירה הבלתי מוטלת גדלה ליניארית עם dd, כי כל אחד מ-d−1d - 1 המעברים הוא סיבוב דו-קיוביטי. הספירה המוטלת גדלה הרבה יותר מהר, כי כל איבר פועל על כל dd הקיוביטים. הקידודים הקומפקטיים צריכים פחות קיוביטים, אבל משקל האיברים שלהם תלוי באופן שבו תבניות הביטים של רמות שכנות נבדלות: קוד גריי, שבו הן נבדלות בביט אחד, הוא הזול מבין שני הקידודים הקומפקטיים, והבינארי הסטנדרטי קופץ כשנשיאה הופכת כמה ביטים בבת אחת (d=5d = 5). השאלה אם קידוד קומפקטי גם מנצח את האונרי הבלתי מוטל תלויה ב-dd ובאופן שבו Qiskit מסנתזת את ההתפתחות, ולכן קראו את המגמה ולא את הדירוג בגודל אחד.

ההטלות חשובות גם כשמרחיבים אופרטור ממופה לפאולים, מה ש-from_sparse_observable() עושה בכך שהיא מחליפה כל הטלה בסכום של שני אופרטורי פאולי. איבר עם kk הטלות הופך ל-2k2^k איברי פאולי. הנה b†b^\dagger בודד ב-dd קטן, שהוא הגודל היחיד שבו כדאי לנסות זאת:

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

שני הווריאנטים שומרים את אותו מספר איברים, אבל ההרחבה של המוטל גדלה אקספוננציאלית ב-dd. לכל דבר גדול מדוגמת צעצוע, קראו את האיברים של SparseObservable ישירות, כמו שהרשימה שלמעלה עושה, במקום להמיר אותו.

הקידודים הקומפקטיים לא יכולים לוותר על ההטלות שלהם. שם ביטי האפס הם חלק ממספר הרמה, ולכן הסרת ∣0⟩⟨0∣\lvert 0 \rangle\langle 0 \rvert משנה אילו רמות איבר מחבר, והאופרטור כבר לא זהה על מילות הקוד. לכן רק הקידוד האונרי מגיע בשני וריאנטים.

מכפלה של סולמות נקטמת קודם​

בחירת תכנון נוספת מופיעה בעלות של איברים אלכסוניים. המאפר לא ממפה כל אופרטור סולם ומכפיל את התוצאות. הוא אוסף את אופרטורי הסולם שאיבר מפעיל על מצב אחד, מכפיל אותם כמטריצה d×dd \times d בבסיס פוק, קוטם את המכפלה פעם אחת, ומקודד את התוצאה [2]. מכפלה כמו אופרטור המספר b†bb^\dagger b נשארת לכן אלכסונית:

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

האיברים שלו הם ההטלות ∣1⟩⟨1∣\lvert 1 \rangle\langle 1 \rvert על הקיוביט של כל רמה מעל אפס, משוקללות במספר הרמה (לרמה אפס משקל אפס ולכן אין לה איבר). התפתחות בזמן שלו היא קבוצה של סיבובים חד-קיוביטיים ולא צריכה שער דו-קיוביטי. הכפלה של שני אופרטורי הסולם הממופים במקום זאת נותנת את אותו אופרטור על מילות הקוד, אבל אחר מחוץ להן, עם מחרוזות לא-אלכסוניות שעולות שערים:

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

איברים אלכסוניים כמו האינטראקציה באתר של מודל Bose-Hubbard הם המכפלות האלה, ושם הבחירה הזו חשובה.

כתיבת מאפר מותאם אישית​

אם אתם רוצים קידוד שהספרייה לא מספקת, בנו אותו מהתמונה של אופרטור סולם בודד. map_boson_action_generators() מקבלת פונקציה שממפה BosonAction אחד, פונקציה שמחזירה את הזהות, ובונה מהן את המאפר של אופרטורים שלמים. היא מכפילה את התמונות של הפעולות של כל איבר לפי הסדר, מכפילה במקדם, ומסכמת את האיברים. הפלט יכול להיות כל טיפוס שתומך בכפל בסקלר ובהרכבה.

כדוגמה, קחו את הקידוד ל-d=2d = 2 ששומר מצב בקיוביט אחד. אופרטור יצירה הוא ∣1⟩⟨0∣\lvert 1 \rangle\langle 0 \rvert, שהוא (X−iY)/2(X - iY)/2 בקונבנציה של Qiskit, ואופרטור השמדה הוא הצמוד שלו:

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

המאפר הכתוב ביד הזה מכפיל את אופרטורי הסולם הממופים, והוא מתאים למאפר שבספרייה כאן כי לכל מצב יש שתי רמות וקיוביט מחזיק את המצב כולו. ב-dd גדול יותר, כפי שהסעיף הקודם הראה, הרכבת הסולמות הממופים וקטימת המכפלה קודם אינן אותו אופרטור מחוץ למילות הקוד.

כדי להשתמש במאפר כזה במעגל, עטפו אותו בפונקציה עם החתימה (operator, num_qubits) -> SparseObservable והעבירו אותה ל-MapperFnEvolutionSynthesis, שממפה איתה את האופרטור של כל שער Evolution. צינור ה-Transpiler מתואר ב-Transpile של מעגלים בוזוניים.

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

שני פרטים כדאי להכיר. ראשית, לאופרטור הקיוביטים חייבים להיות מקדמים ממשיים, כי PauliEvolutionGate דוחה חלק מדומה. אופרטור הרמיטי ממופה לממשי, אבל רק אחרי שהאיברים שמתבטלים מוזגו. המאפר הכתוב ביד הזה לא ממזג אותם, ולכן איבר קפיצה והצמוד שלו משאירים מקדמים מדומים עד שמפשטים את התוצאה. המאפר שבספרייה ממזג את התוצאה שלו; מאפר מותאם צריך לעשות זאת, למשל על ידי עטיפתו ב-simplify():

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

שנית, נוסחת מכפלה מסנתזת את האיברים בסדר שבו היא מקבלת אותם, והסדר שמאפר מייצר אינו מובטח להיות יציב. העוזרים simplify() ו-group_wise() עוטפים פונקציית מאפר כדי לשלוט בו: simplify ממזג מחרוזות כפולות וממיין את האיברים לסדר קנוני, בעוד ש-group_wise ממפה קבוצה אחת של האופרטור בכל פעם כך שאיברי הקבוצה נשארים סמוכים. אל תשלבו את השניים, כי המיון מוחק את הקיבוץ. מדריך הקיבוץ מראה איך להקצות קבוצות, ומדריך הסינתזה מראה איך הסדר משפיע על המעגל.

השלבים הבאים​

[1]

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

[2] (1,2)

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