Transpile של מעגלים בוזוניים
המדריך הזה מסביר איך לבצע Transpile ל-BosonicCircuit ל-QuantumCircuit רגיל. למעגל בוזוני אין קטיעה, אין קידוד ואין מספר Qubit (ראו עבודה עם מעגלים בוזוניים), ולכן ה-transpiler הוא המקום שבו הבחירות האלה מתבצעות. הקטיעה והקידוד מתוארים על ידי LevelSpec (ראו תיאור הקטיעה באמצעות LevelSpec), וה-mapper שהופך אופרטורים לאופרטורים של Qubit מתואר ב-מיפוי ייצוגי אופרטורים. התוצאה היא QuantumCircuit רגיל, ולכן כל סימולטור תואם Qiskit יכול להריץ אותו.
המדריך משתמש בדוגמה קטנה אחת לכל אורכו: שני מודים בוזוניים, מצב עם בוזון אחד בכל אחד מהם, ואבולוציה אחת בזמן תחת המילטוניאן Bose-Hubbard שאיברי הקשר והאיברים באתר שלו בקבוצות נפרדות (ראו קיבוץ איברי אופרטור: שימוש במבנה האופרטור):
>>> from qiskit_bosons.circuit import BosonicCircuit
>>> from qiskit_bosons.circuit.library import Evolution, InitializeModes
>>> from qiskit_bosons.operators import BosonOperator, ann, cre
>>>
>>> terms = {(cre(0), ann(1)): -1.0, (cre(1), ann(0)): -1.0}
>>> terms.update({(cre(mode), cre(mode), ann(mode), ann(mode)): 0.5 for mode in range(2)})
>>> hamiltonian = BosonOperator.from_dict(terms)
>>> # the bond is group 0 and the on-site interaction is group 1; derive the index from the term,
>>> # because the stored order of a dictionary is not specified
>>> hamiltonian.groups = [
... 0 if len({mode for _, mode in term}) == 2 else 1 for term, _ in hamiltonian.iter_terms()
... ]
>>>
>>> circuit = BosonicCircuit(2)
>>> circuit.append(InitializeModes([1, 1]), circuit.modes)
>>> circuit.append(Evolution(2, hamiltonian, time=0.1), circuit.modes)
>>> dict(circuit.count_ops())
{'InitializeModes': 1, 'Evolution': 1}
שלבי ה-Transpilation
כפי שמוסבר ב-qiskit_bosons.transpiler, ה-pass managers המוגדרים מראש בחבילה הזאת מפצלים את ה-Transpilation לשלבים הבאים:
קלט
ממיר את המעגל למבנה נתונים של גרף מכוון אציקלי (DAG), הייצוג שכל pass מאוחר יותר עובד עליו.
אופטימיזציה
משאיר את המעגל במרחב בוזוני. ה-passes האלה משתמשים במבנה של האופרטורים הבוזוניים, בפרט בקבוצות שלהם, בעוד שעדיין אין מידע על Qubit. B2BSynthesis הוא ה-pass שמפרק כאן שערים, עם plugin לכל סוג שער: נוסחת מכפלה או qDRIFT עבור Evolution, ורשת Clements עבור ModeRotation (ראו סינתזה של שער Evolution במרחב בוזוני לאופן שבו נבחרת שיטה). RelabelModes מסדר מחדש את המודים. ההגדרה המוגדרת מראש מפרקת סיבובי מודים בשלב הזה ומשאירה כל דבר אחר כמו שהוא.
פריסה
ממפה כל רגיסטר מודים בוזוניים לרגיסטר קוונטי. מוד בוזוני תופס בדרך כלל כמה Qubit ( מהם תחת הקידודים האונריים, תחת הקומפקטיים), ולכן הרגיסטר הקוונטי רחב יותר מרגיסטר המודים. OccupationB2QLayout גוזר את רוחב הרגיסטר מתוך LevelSpec.
סינתזה
ממיר שערים בוזוניים לפעולות על Qubit, plugin אחד לכל סוג שער. הקידוד מתרחש בשלב הזה: ה-plugin של שער Evolution ממפה את האופרטור שלו לאופרטור של Qubit, וה-plugin של שער InitializeModes מגדיר את ה-Qubit שמקודדים כל אכלוס. barrier מועבר לכל Qubit של כל רגיסטר שהוא נגע בו.
Qubit
מבצע Transpile למעגל ה-Qubit שהתקבל בשיטות של Qiskit עצמו, למשל לקבוצת שערי בסיס או להתקן. ההגדרה המוגדרת מראש תמיד כוללת אותו; כברירת מחדל זה ה-pass manager המוגדר מראש של Qiskit, שמסנתז כל אבולוציה לשערים (ראו המשך ברמת ה-Qubit).
פלט
ממיר את מבנה הנתונים הפנימי DAG בחזרה ל-QuantumCircuit.
הקטיעה והקידוד לא נשמרים במעגל. הם מועברים ל-transpiler, והפריסה, הכנת המצב וה-mapper חייבים כולם להסכים עליהם. ההגדרה המוגדרת מראש מבטיחה זאת בכך שהיא מעבירה את אותו LevelSpec לשלושתם.
דוגמה מעשית
generate_preset_occupation_pass_manager() בונה את ה-pipeline מתוך spec. הורדת הדוגמה לרמה נמוכה תחת שני קידודים משנה רק את ה-spec. ההגדרה המוגדרת מראש מסתיימת ב-Transpilation של Qiskit עצמו ברמת ה-Qubit, שהיה מסנתז את האבולוציה לשערים. כדי לראות את המעגל בדיוק כפי שהשלבים הבוזוניים פולטים אותו, רוקנו קודם את השלב הזה:
>>> from qiskit.transpiler import PassManager
>>> from qiskit_bosons.mappers import LevelSpec
>>> from qiskit_bosons.transpiler.presets import generate_preset_occupation_pass_manager
>>>
>>> for encoding in ("unary_unprojected", "gray"):
... pass_manager = generate_preset_occupation_pass_manager(LevelSpec(2, encoding))
... pass_manager.qubit = PassManager()
... lowered = pass_manager.run(circuit)
... print(f"{encoding:18s} qubits={lowered.num_qubits} ops={dict(lowered.count_ops())}")
unary_unprojected qubits=6 ops={'x': 2, 'PauliEvolution': 1}
gray qubits=4 ops={'x': 2, 'PauliEvolution': 1}
שני הקידודים משתמשים בשישה ובארבעה Qubit בהתאמה (שלוש רמות לכל מוד תחת הקידוד האונרי, שני Qubit לכל מוד תחת קוד Gray), אבל מבנה המעגל זהה: שתי פעולות XGate מכינות את האכלוסים, ו-PauliEvolutionGate אחד נושא את האבולוציה.
השוואה לזרימת העבודה המסורתית
המדריך על מעגלים בוזוניים מתאר את זרימת העבודה המסורתית, שממפה את ההמילטוניאן לאופרטור של Qubit לפני שהיא בונה את QuantumCircuit. שני המסלולים מפיקים מעגלים שקולים. ההבדל ביניהם הוא במה שצריך לעשות מחדש כשהקטיעה משתנה. המעגל הבוזוני נבנה פעם אחת ומורד לרמה נמוכה בכל קטיעה, ואילו מסלול המיפוי-קודם דורש למפות את האופרטור שוב ולבנות מחדש את המעגל:
>>> from qiskit import QuantumCircuit
>>> from qiskit.circuit.library import PauliEvolutionGate
>>> from qiskit_bosons.mappers.library import occupation_encoding
>>>
>>> basis = ["rz", "sx", "x", "cx"]
>>> cx_counts = []
>>> for max_occupation in (1, 2, 3):
... spec = LevelSpec(max_occupation, "gray")
... # the bosonic circuit is reused as it is
... lowered = generate_preset_occupation_pass_manager(
... spec, optimization_level=0, basis_gates=basis
... ).run(circuit)
... # the map-first route maps the operator and builds a new circuit for every cutoff
... qubit_hamiltonian = occupation_encoding(hamiltonian, spec)
... by_hand = QuantumCircuit(qubit_hamiltonian.num_qubits)
... _ = by_hand.append(PauliEvolutionGate(qubit_hamiltonian, time=0.1), by_hand.qubits)
... cx_counts.append(lowered.count_ops().get("cx", 0))
... print(
... f"d={max_occupation + 1}: {lowered.num_qubits} qubits; map-first: "
... f"{len(qubit_hamiltonian)} Pauli terms to place by hand"
... )
d=2: 2 qubits; map-first: 2 Pauli terms to place by hand
d=3: 4 qubits; map-first: 10 Pauli terms to place by hand
d=4: 4 qubits; map-first: 22 Pauli terms to place by hand
מספר השערים המסבכים תלוי באופן שבו Qiskit מסנתז את שער האבולוציה, וזה משתנה בין גרסאות, ולכן שרטטו אותו:
הספירה גדלה מהר עם הקטיעה, וזו הסיבה להשאיר את הקטיעה פרמטר של ה-Transpilation ולא של המעגל. שני המסלולים שקולים כאן, אבל רק הבוזוני יכול גם לשלב את קיבוץ האופרטור עם נוסחת המכפלה (ראו סינתזה של שער Evolution במרחב בוזוני) או להעביר את הקידוד ל-spec אחר בלי לשנות שורה במעגל.
spec עם קטיעה בודדת משאיר את מספר המודים פתוח, ולכן pass manager אחד משרת מעגלים בכל רוחב. spec עם קטיעה אחת לכל מוד קובע את הרוחב:
>>> wide = BosonicCircuit(3)
>>> wide.append(InitializeModes([1, 0, 1]), wide.modes)
>>> shared = generate_preset_occupation_pass_manager(LevelSpec(2, "gray"))
>>> shared.run(wide).num_qubits
6
>>> per_mode = generate_preset_occupation_pass_manager(LevelSpec([2, 1], "gray"))
>>> per_mode.run(circuit).num_qubits
3
>>> try:
... per_mode.run(wide)
... except ValueError as error:
... print(error)
the spec covers 2 modes, but 3 were expected
בניית ה-pipeline ידנית
ההגדרה המוכנה היא MultiStagePassManager עם רשומה אחת לכל שלב. אפשר להרכיב את אותו צינור מהחלקים שלו, וזו נקודת המוצא לכל ההתאמות האישיות שלהלן:
>>> from qiskit.passmanager import MultiStagePassManager
>>> from qiskit_bosons.mappers.library import occupation_encoding
>>> from qiskit_bosons.transpiler import BosonicCircuitToDAG, QuantumDAGToCircuit
>>> from qiskit_bosons.transpiler.passes import (
... B2QSynthesis,
... MapperFnEvolutionSynthesis,
... OccupationB2QLayout,
... OccupationInitializeModesSynthesis,
... )
>>>
>>> spec = LevelSpec(2, "gray")
>>>
>>> def mapper_fn(operator, num_qubits):
... return occupation_encoding(operator, spec, num_qubits)
>>>
>>> synthesis = B2QSynthesis()
>>> synthesis.methods["Evolution"] = MapperFnEvolutionSynthesis(mapper_fn)
>>> synthesis.methods["InitializeModes"] = OccupationInitializeModesSynthesis(spec)
>>>
>>> by_hand = MultiStagePassManager(
... input=BosonicCircuitToDAG(),
... layout=OccupationB2QLayout(spec),
... synthesis=synthesis,
... output=QuantumDAGToCircuit(),
... )
>>> preset = generate_preset_occupation_pass_manager(spec)
>>> preset.qubit = PassManager()
>>> by_hand.run(circuit) == preset.run(circuit)
True
פונקציית ה-mapper מקבלת אופרטור ואת רוחב האוגר שה-layout גזר, ומחזירה SparseObservable. מכיוון שהרוחב נמסר לה, העברת num_qubits הלאה אל occupation_encoding() גורמת ל-mapper לבדוק שהמפרט מתאים לאוגר, כך שאי-התאמה בין ה-layout לקידוד תיכשל עם הודעה ברורה.
תהליכי עבודה מתקדמים
מתן layout מפורש
CustomB2QLayout מחליף את שלב ה-layout במיפוי שאתם מספקים, מכל אוגר בוזוני של המעגל לאוגר קוונטי. זה שימושי כשהאוגר צריך להיות רחב יותר ממה שהמפרט דורש, למשל כדי להשאיר Qubit בטלים למטרות אחרות:
>>> from qiskit.circuit import QuantumRegister
>>> from qiskit_bosons.transpiler.passes import CustomB2QLayout
>>>
>>> (mode_register,) = BosonicCircuitToDAG().run(circuit).qregs.values()
>>> custom = MultiStagePassManager(
... input=BosonicCircuitToDAG(),
... layout=CustomB2QLayout({mode_register: QuantumRegister(6, "q")}),
... synthesis=synthesis,
... output=QuantumDAGToCircuit(),
... )
>>> custom.run(circuit).num_qubits
6
layout מותאם אישית הוא באחריותכם. תוספי הסינתזה עדיין ניגשים ל-Qubit דרך המפרט, ולכן אוגר שאינו תואם לרוחבי המפרט יגרום למעגל שגוי או לשגיאה עמוק בתוך תוסף. layout מותאם גם מדלג על הבדיקה ש-OccupationB2QLayout מבצע אחרי RelabelModes (ראו בסעיף הבא).
סידור מחדש של המודים
RelabelModes הוא מעבר (pass) בשלב האופטימיזציה שמסדר מחדש את המודים. התמורה נקראת כמיפוי מאינדקס המוד המקורי לאינדקס החדש, והיא נשמרת במטא-דאטה של המעגל. הסידור מחדש משנה אילו Qubit כל מוד תופס, ולכן הוא יכול להקטין את העומק של המעגל המונמך.
מכיוון שהתוצאה מבוטאת בסדר המודים החדש, צריך למפות את מחרוזות הביטים שנמדדו בחזרה לפני שמפרשים אותן. כשיש כמה Qubit לכל מוד, המיפוי פועל על בלוקים של Qubit ולא על ביטים בודדים. הדוגמה הבאה מבצעת relabel לשלושה מודים, שכל אחד מהם תופס שני Qubit תחת קוד גריי, ומשחזרת את התפוסה המקורית מתוך הספירות שנמדדו:
>>> # BasicSimulator is a convenient pure-Python choice; any simulator works
>>> from qiskit.providers.basic_provider import BasicSimulator
>>> from qiskit_bosons.transpiler.passes import RelabelModes
>>>
>>> state = BosonicCircuit(3)
>>> state.append(InitializeModes([2, 0, 1]), state.modes)
>>> spec = LevelSpec(2, "gray") # three levels, two qubits per mode
>>> width = spec.resolve(3).widths[0]
>>> permutation = [2, 0, 1]
>>>
>>> synthesis = B2QSynthesis()
>>> synthesis.methods["InitializeModes"] = OccupationInitializeModesSynthesis(spec)
>>> relabeled = MultiStagePassManager(
... input=BosonicCircuitToDAG(),
... optimization=RelabelModes(permutation),
... layout=OccupationB2QLayout(spec),
... synthesis=synthesis,
... output=QuantumDAGToCircuit(),
... ).run(state)
>>> relabeled.metadata
{'permutation': [2, 0, 1]}
>>>
>>> def restore(bitstring, permutation, width):
... """Map a bitstring in the new mode order back to the original mode order."""
... qubits = bitstring[::-1] # character q is now the value of qubit q
... blocks = [qubits[mode * width : (mode + 1) * width] for mode in range(len(permutation))]
... return "".join(blocks[new] for new in permutation)[::-1]
>>>
>>> relabeled.measure_all()
>>> (measured,) = BasicSimulator().run(relabeled, shots=1).result().get_counts()
>>> measured
'110100'
>>> restore(measured, permutation, width)
'010011'
מחרוזת הביטים ששוחזרה היא מה שהמעגל מפיק ללא ה-relabel, בקריאה בסדר little-endian של Qiskit: מוד 0 מחזיק את קוד גריי של רמה שתיים (11), מוד 1 מחזיק רמה אפס (00), ומוד 2 מחזיק רמה אחת (01).
>>> plain = MultiStagePassManager(
... input=BosonicCircuitToDAG(),
... layout=OccupationB2QLayout(spec),
... synthesis=synthesis,
... output=QuantumDAGToCircuit(),
... ).run(state)
>>> plain.measure_all()
>>> (reference,) = BasicSimulator().run(plain, shots=1).result().get_counts()
>>> restore(measured, permutation, width) == reference
True
מפרט שנותן למודים cutoff שונים אינו יכול להיות משולב עם RelabelModes. ה-cutoff משויכים למודים לפי מיקומם, ולכן סידור מחדש של המודים יציב cutoff על המוד הלא נכון. OccupationB2QLayout מסרב לקבל מעגל שעבר relabel במקרה כזה:
>>> mixed = LevelSpec([2, 1, 2], "gray")
>>> synthesis = B2QSynthesis()
>>> synthesis.methods["InitializeModes"] = OccupationInitializeModesSynthesis(mixed)
>>> refused = MultiStagePassManager(
... input=BosonicCircuitToDAG(),
... optimization=RelabelModes(permutation),
... layout=OccupationB2QLayout(mixed),
... synthesis=synthesis,
... output=QuantumDAGToCircuit(),
... )
>>> try:
... refused.run(state)
... except ValueError as error:
... print(str(error).split(",")[0])
the circuit has been relabeled by RelabelModes
בחירת תוספי הסינתזה
ל-B2QSynthesis אין תוספי ברירת מחדל; ההגדרה המוכנה רושמת אחד לכל סוג שער. התוספים הזמינים מנוהלים על ידי B2QSynthesisPluginManager, שמפרט אותם לפי שם השער שעליו הם פועלים:
>>> from qiskit_bosons.transpiler.passes import B2QSynthesisPluginManager
>>> manager = B2QSynthesisPluginManager()
>>> for gate in manager.op_names():
... print(gate, manager.method_names(gate))
Evolution ['MapperFn']
InitializeModes ['Occupation']
שתי השיטות המובנות הן Evolution.MapperFn ו-InitializeModes.Occupation. הראשונה מסנתזת אבולוציה מכל פונקציית mapper, וכך מחברים קידוד מותאם אישית (ראו מיפוי ייצוגי אופרטורים). הספרייה אינה כוללת תוסף לשער ModeRotation. ההגדרה המוכנה מפרקת אותו במרחב הבוזוני לשערי Evolution תחילה, ואותם השיטה הראשונה מטפלת בהם, אבל תוסף שנרשם עבור ModeRotation יכול להנמיך את השער ישירות במקום זאת; ראו סיבוב מודים בוזוניים והנמכת סיבוב בלי לפרק אותו. אפשר לרשום שיטה לפי שם דרך הארגומנט config של B2QSynthesis, או לכתוב אובייקט תוסף ישירות אל B2QSynthesis.methods, כפי שעושות הדוגמאות הקודמות.
המשך ברמת ה-Qubit
ההגדרה המוכנה תמיד מסתיימת בשלב qubit, שהוא ה-pass manager המובנה של Qiskit עצמו. ארגומנטים של מילות מפתח של generate_preset_occupation_pass_manager() מועברים אל qiskit.transpiler.generate_preset_pass_manager(), כך שאפשר לתרגם לקבוצת שערי בסיס או לכוון ישירות להתקן. מומלץ להשתמש ב-optimization_level=0 כדי להשאיר את המעגל קרוב למה שהשלבים הבוזוניים הפיקו:
>>> from qiskit.transpiler import generate_preset_pass_manager
>>>
>>> spec = LevelSpec(2, "gray")
>>> translated = generate_preset_occupation_pass_manager(
... spec, optimization_level=0, basis_gates=["rz", "sx", "x", "cx"]
... ).run(circuit)
>>> sorted(translated.count_ops())
['cx', 'rz', 'sx', 'x']
שתי בחירות נוספות נפוצות. אפשר להקצות PassManager ריק לשלב qubit כשרוצים להציג את המעגל בדיוק כפי שהצינור סינתז אותו, כמו שעושות הדוגמאות לעיל. או להרכיב את הצינור בעצמכם ולהוסיף כל pass manager של Qiskit כשלב qubit של MultiStagePassManager. השלבים רצים בסדר שבו הם נמסרים, ולכן שלב qubit חייב לבוא לפני output, כי ה-passes של Qiskit מצפים ל-DAG ולא למעגל סופי:
>>> synthesis = B2QSynthesis()
>>> synthesis.methods["Evolution"] = MapperFnEvolutionSynthesis(
... lambda operator, num_qubits: occupation_encoding(operator, spec, num_qubits)
... )
>>> synthesis.methods["InitializeModes"] = OccupationInitializeModesSynthesis(spec)
>>> staged = MultiStagePassManager(
... input=BosonicCircuitToDAG(),
... layout=OccupationB2QLayout(spec),
... synthesis=synthesis,
... qubit=generate_preset_pass_manager(
... optimization_level=0, basis_gates=["rz", "sx", "x", "cx"]
... ),
... output=QuantumDAGToCircuit(),
... )
>>> sorted(staged.run(circuit).count_ops())
['cx', 'rz', 'sx', 'x']
השלבים הבאים
-
מיפוי ייצוגי אופרטורים לקידודים ולכתיבת mapper משלכם.
-
סינתזה של שער Evolution במרחב בוזוני לבחירת נוסחת מכפלה עבור שערי האבולוציה.
-
סיבוב מודים בוזוניים להנמכת סיבובי מודים.
-
הדמיית דינמיקת Bose-Hubbard עם בחירת קידוד לסימולציה שלמה שבודקת את המעגל המונמך מול דינמיקה מדויקת.
-
qiskit_bosons.transpilerלתיאור המלא של השלבים וה-passes הזמינים.