סינתזה של שער Evolution במרחב הפרמיוני
המושגים במדריך הזה זמינים כרגע רק ב-Python API. פונקציונליות מקבילה תהיה זמינה ב-C API בגרסה עתידית.
שער Evolution נושא את האבולוציה בזמן של אופרטור שלם. סינתזה מפרמיון לפרמיון כותבת מחדש את השער הזה כ-FermionicCircuit של שערים פרמיוניים קטנים יותר. שני צדדי הכתיבה מחדש נשארים במרחב הפרמיוני, וזה מה שמבדיל אותה מהסינתזה מפרמיון לקיוביט שבאה אחריה: ה-groups של האופרטור (ראו קיבוץ איברי אופרטור: שימוש במבנה האופרטור) שורדות אותה, ואילו מיפוי מפרמיון לקיוביט מחזיר SparseObservable, שאין בו מושג של קיבוץ.
שני כפתורים בוחרים את השיטה. Evolution.synthesis קובע אותה על שער אחד, ומעבר ה-Transpiler F2FSynthesis קובע אותה על מעגל שלם. שניהם מקבלים F2FSynthesisPlugin, הממשק שכל כתיבה מחדש כזו מממשת. השיטות שהחבילה הזו מספקת כרגע עבור אבולוציה הן נוסחאות מכפלה, ובהן משתמש שאר המדריך: FermionicLieTrotter לנוסחה מסדר ראשון (ברירת המחדל) ו-FermionicSuzukiTrotter לסדר זוגי גבוה יותר. כל דבר אחר שמממש את הממשק נבחר באותה דרך, והסעיפים על שני הכפתורים חלים עליו ללא שינוי.
שלב מפרמיון לפרמיון הזה הוא אופציונלי. שער Evolution יכול ללכת ישר לשלב מפרמיון לקיוביט, שממפה ומסנתז אותו בשלמותו בלי קשר לכמה איברים הוא מכיל. פיצול מראש הוא בחירה, שנעשית משום שהשערים שמתקבלים זולים יותר כל אחד לעצמו, או משום שהפיצול חושף מבנה ששלב מאוחר יותר יכול לנצל.
שני השלבים מתחברים, ולכן אבולוציה יכולה להסתיים מקורבת ברמה אחת, בשתיהן, או באף אחת. נוסחת מכפלה מקרבת אלא אם הגורמים שלה מתחלפים; שיטת סינתזה לא חייבת לקרב בכלל.
דילוג על השלב הזה לא חייב להיות אובדן מוחלט של הקיבוץ. עטיפת ה-mapper ב-group_wise() ממפה את האופרטור קבוצה אחר קבוצה ומסכמת את התוצאות, כך שאיברי Pauli הממופים מגיעים מקובצים ולא משולבים זה בזה. נוסחת מכפלה בצד הקיוביטים מסנתזת אותם בסדר שבו היא מקבלת אותם, ולכן איברים מתחלפים שנקלעים להיות סמוכים אפשר לתזמן יחד.
על ההמילטוניאן שלהלן זה משחזר את כל יתרון העומק הדו-קיוביטי של הפיצול: 18 במקום 30, עם מספר שערים ללא שינוי. ההבדל הוא איפה המבנה בא לידי ביטוי. פיצול במרחב הפרמיוני הופך כל קבוצה לשער נפרד, שכל שלב מאוחר יכול לראות ולפעול עליו; group_wise משאיר שער אחד ורק מסדר מחדש את האיברים שבתוכו, וזה מספיק לתזמון אבל לא משאיר דבר שמעבר פרמיוני יוכל לעבוד איתו. השתמשו בו כשהאבולוציה חייבת להישאר שער יחיד, ובמקרים אחרים פנו לנוסחת מכפלה פרמיונית.
בניית המילטוניאן מקובץ
שתי נוסחאות המכפלה מפצלות אופרטור לגורמים, ועבור אופרטור מקובץ הגורמים האלה הם הקבוצות שלו. לכן הקיבוץ הוא שקובע גם את העלות וגם את הדיוק של התוצאה. ניקח שרשרת Fermi-Hubbard בת שישה אתרים, מקובצת לפי האופן שבו האיברים שלה מתחלפים: איברי הקפיצה בקשרים הזוגיים, איברי הקפיצה בקשרים האי-זוגיים, והאינטראקציה האלכסונית.
>>> from qiskit_fermions.operators import FermionOperator, cre, ann
>>>
>>> def fermi_hubbard_chain(num_sites, tunneling, interaction):
... """A 1D Fermi-Hubbard chain grouped into even bonds, odd bonds, and the interaction."""
... terms, groups = [], []
... for offset in (0, 1):
... for j in range(offset, num_sites - 1, 2):
... terms += [
... ([cre(j), ann(j + 1)], -tunneling),
... ([cre(j + 1), ann(j)], -tunneling),
... ]
... groups += [offset, offset]
... for j in range(num_sites - 1):
... terms.append(([cre(j), ann(j), cre(j + 1), ann(j + 1)], interaction))
... groups.append(2)
... operator = FermionOperator.from_terms(terms)
... operator.groups = groups
... return operator
>>>
>>> num_sites = 6
>>> hamiltonian = fermi_hubbard_chain(num_sites, tunneling=1.0, interaction=2.0)
>>> hamiltonian.num_groups()
3
חמישה עשר איברים בשלוש קבוצות. כל קבוצת קפיצה מצמידה כל איבר לשותף הצמוד שלו, וזה חשוב: גורם חייב להיות הרמיטי כדי שהאקספוננט שלו יהיה אוניטרי, וזה לא נובע מכך שהסכום הרמיטי. groups_are_hermitian() בודק את המוסכמה הזו ולא משאיר אותה לבדיקה בעין:
>>> from qiskit_fermions.operators.terms.grouping import groups_are_hermitian
>>>
>>> groups_are_hermitian(hamiltonian)
[True, True, True]
הפרדת מהשותף שלו הייתה שוברת אותה, ולהשאיר את האופרטור לא מקובץ משיג את אותו אפקט, כי אז שתי הנוסחאות מפצלות אותו איבר איבר ו- בודד אינו הרמיטי:
>>> ungrouped = fermi_hubbard_chain(num_sites, tunneling=1.0, interaction=2.0)
>>> ungrouped.groups = range(len(list(ungrouped.iter_terms()))) # one group per term
>>> all(groups_are_hermitian(ungrouped))
False
גורם כזה הוא אופרטור פרמיוני תקף, והחבילה הזו תבנה עבורו את השער, ולכן שום דבר לא מתלונן עד שהאופרטור הממופה מגיע ל-PauliEvolutionGate, שדורש מקדמים ממשיים וזורק ValueError: Operator contains complex coefficients, which are not supported. בדיקת הקיבוץ מראש הופכת את הכישלון המאוחר הזה לתשובה שזמינה לפני שנבנה מעגל כלשהו.
קביעת השיטה על שער אחד
העבירו לשער שיטת סינתזה ואז פרקו אותו. כל שער שהוא פולט חוזר כשער Evolution משלו:
>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.circuit.library.synthesis import (
... FermionicLieTrotter,
... FermionicSuzukiTrotter,
... )
>>>
>>> total_time = 1.0
>>>
>>> def factors(synthesis):
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(num_sites, hamiltonian, time=total_time, synthesis=synthesis),
... circuit.modes,
... )
... return circuit.decompose().count_ops()["Evolution"]
>>>
>>> factors(FermionicLieTrotter())
3
>>> factors(FermionicSuzukiTrotter(order=2))
5
>>> factors(FermionicSuzukiTrotter(order=2, reps=4))
17
>>> factors(FermionicSuzukiTrotter(order=2, reps=4, merge_adjacent=False))
20
סדר ראשון פולט גורם אחד לכל קבוצה. סדר שני פולט את הפלינדרום הסימטרי, כך ששתי הקבוצות החיצוניות מופיעות פעמיים בחצי מהזמן כל אחת, וזה נותן חמישה. ארבע חזרות על זה היו נותנות עשרים, אבל כל חזרה מסתיימת באותה קבוצה שהחזרה הבאה מתחילה בה. השערים השכנים האלה מתפתחים תחת אותו אופרטור, ולכן הם מתמזגים לשער אחד עם סכום הזמנים, וזה חוסך שער אחד בכל תפר. העבירו merge_adjacent=False כדי לשמור את הרצף כפי שהוא בספרי הלימוד. כל סדר מפצל את האופרטור באותו אופן; מה שמשתנה הוא סידור הגורמים וזמני האבולוציה שלהם, לא החלוקה.
הגורמים מסומנים atomic, ולכן פירוק נוסף משאיר אותם במקומם ולא מפצל אותם עוד. לפירוק יש נקודת שבת, וזה מה שהופך את הקריאה ל-decompose() שוב ושוב לבטוחה.
קביעת השיטה לכל הצינור
קביעת synthesis לכל שער בנפרד פירושה להעביר את הבחירה דרך כל מה שבונה שער כזה, כולל UCC ו-UCJ, שבונים את השערים שלהם באופן פנימי. המעבר F2FSynthesis מפעיל במקום זה שיטה אחת על כל Evolution במעגל, כך שהבחירה נעשית פעם אחת עבור הצינור. הוא מוגדר לפי שם השער, באותו אופן ש-F2QSynthesis מוגדר, ושער שלא נקרא בשמו נשאר כמו שהוא:
>>> from qiskit_fermions.transpiler import FermionicPassManager
>>> from qiskit_fermions.transpiler.passes import F2FSynthesis
>>>
>>> circuit = FermionicCircuit(num_sites)
>>> circuit.append(Evolution(num_sites, hamiltonian, time=total_time), circuit.modes)
>>>
>>> expanded = FermionicPassManager(
... F2FSynthesis({"Evolution": ("SuzukiTrotter", (), {"order": 2, "reps": 4})})
... ).run(circuit)
>>> expanded.count_ops()["Evolution"]
17
זה אותו מספר ש-decompose() נותן עבור אותה שיטה למעלה. המעבר MergeEvolutions מפעיל את אותו מיזוג על שערים שכנים שלא הגיעו מסינתזה אחת, כמו התפרים בין מעגלים עוקבים. המעבר מבצע את הנוסחה בעצמו, ולכן אין צורך בשלב הרחבה נפרד, והרצתו פעמיים לא משנה דבר, כי הגורמים שהוא פולט הם atomic ולכן אין להם הגדרה משלהם.
העבירו apply=False כדי רק לבחור את השיטה ולהשאיר את ההרחבה למשהו אחר, כמו Decompose של Qiskit (מתן שם השער כ-Decompose("Evolution") מגביל אותו לאבולוציות ומשאיר את שאר השערים הפרמיוניים לפלאגינים של הסינתזה שלהם).
זה שימושי כדי לבחור את השיטה לכל המעגל מראש ולדחות את ההרחבה לנקודה שבה רוצים בה, למשל כדי לבדוק קודם את המעגל:
>>> selected = FermionicPassManager(
... F2FSynthesis({"Evolution": ("SuzukiTrotter", (), {"order": 2})}, apply=False)
... ).run(circuit)
>>> selected.count_ops()["Evolution"] # selected, not yet expanded
1
>>> selected.decompose().count_ops()["Evolution"] # expanded on demand
5
אבל שימו לב מה שער לא מורחב עושה: הוא מגיע לשלב מפרמיון לקיוביט בשלמותו, שם הוא ממופה בלי שמתייעצים ב-synthesis. זה יוצר טעות קלה בהשוואת שיטות, כי כל אחת מהן מפיקה אז פלט זהה וההשוואה מודדת בשקט כלום. על ההמילטוניאן שלמעלה, השארת שער סדר שני לא מורחב מכווצת את המעגל הגדול בהרבה שלהלן לעלות של אבולוציה ממופה אחת, בלי קשר לאיזו שיטה נבחרה.
מדידת התמורה
סדר גבוה יותר עולה בעומק, ולכן השאלה היא לא אם הוא מדויק יותר אלא אם הוא מדויק יותר לכל שער. נדרשים שני מספרים לכל שיטה: השגיאה של המצב שהיא מפיקה, והעלות הדו-קיוביטית של המעגל שהפיק אותו.
השגיאה נמדדת באמצעות ffsim, שמסמלץ FermionicCircuit ישירות (ראו כיצד החבילה הזו קשורה ל-ffsim). אין מעורב מיפוי מפרמיון לקיוביט, ולכן ההשוואה מבודדת את נוסחת המכפלה הפרמיונית מכל מה שהמיפוי או הסינתזה בצד הקיוביטים עשויים לתרום. היא עובדת גם בסקטור של מספר חלקיקים קבוע, שהוא גם קטן יותר מהמרחב המלא וגם המרחב שהאבולוציה באמת נשארת בו.
>>> import ffsim
>>> import numpy as np
>>> from scipy.sparse.linalg import expm_multiply
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> norb, nelec = num_sites, (3, 0) # three spinless fermions on six modes
>>> initial = ffsim.hartree_fock_state(norb, nelec)
>>>
>>> hamiltonian_operator = ffsim.linear_operator(hamiltonian, norb=norb, nelec=nelec)
>>> exact = expm_multiply(
... -1j * total_time * hamiltonian_operator,
... initial,
... traceA=-1j * total_time * ffsim.trace(hamiltonian, norb=norb, nelec=nelec),
... )
>>>
>>> def cost_and_error(synthesis):
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(num_sites, hamiltonian, time=total_time, synthesis=synthesis),
... circuit.modes,
... )
... # the method is set on the gate, so decompose() is what expands it here; a pipeline
... # would reach for F2FSynthesis instead (see the note above)
... circuit = circuit.decompose()
... evolved = ffsim.apply_unitary(initial, circuit, norb=norb, nelec=nelec)
... error = 1 - abs(np.vdot(evolved, exact)) ** 2
... qubit_circuit = generate_preset_jw_pass_manager().run(circuit).decompose(reps=6)
... two_qubit = lambda instruction: len(instruction.qubits) == 2
... return error, qubit_circuit.count_ops()["cx"], qubit_circuit.depth(two_qubit)
>>>
>>> methods = [
... ("order 1, reps 1 ", FermionicLieTrotter()),
... ("order 2, reps 1 ", FermionicSuzukiTrotter(order=2)),
... ("order 1, reps 10", FermionicLieTrotter(reps=10)),
... ("order 2, reps 4 ", FermionicSuzukiTrotter(order=2, reps=4)),
... ]
>>> costs = {label: cost_and_error(synthesis) for label, synthesis in methods}
>>> labels, counts, depths = [], [], []
>>> for label, (error, gates, depth) in costs.items():
... labels.append(label.strip().replace(", ", "\n"))
... counts.append(gates)
... depths.append(depth)
... print(f"{label} error {error:.6f}")
order 1, reps 1 error 0.515997
order 2, reps 1 error 0.180958
order 1, reps 10 error 0.004026
order 2, reps 4 error 0.000421
העלות הדו-קיוביטית הושמטה מהטבלה הזו בכוונה. היא תלויה בסינתזה בצד הקיוביטים ש-Qiskit בוחר, שמשתפרת בין גרסאות, ולכן המספרים שיודפסו כאן היו תצלום מצב ולא תכונה של נוסחת המכפלה. האיור שלהלן מציג אותם במקום זה, ומה שההשוואה נשענת עליו נטען כיחס:
קראו את שתי השיטות האחרונות יחד, כי הן ההשוואה שחשובה. סדר שני עם ארבע חזרות מדויק פי עשרה בערך מסדר ראשון עם עשר, והוא מגיע לזה עם פחות שערי שזירה ולא יותר, כפי שהאיור מראה. השקעת תקציב שערים בסדר גבוה יותר עדיפה כאן על השקעתו ביותר צעדים מסדר ראשון.
התוצאה הזו אינה אוניברסלית. הגדלת reps והגדלת order מקטינות את השגיאה בקצבים שונים עבור אותו סוג עלות, ומי מנצח תלוי באופרטור ובזמן שמפתחים. שתי השורות שלמעלה הן הדרך לברר זאת עבור בעיה נתונה.
הנוסחה הפרמיונית מתחברת עם זו שנבחרה לשלב מפרמיון לקיוביט (ראו MapperFnEvolutionSynthesis.product_formula). כששתיהן מקרבות, דיוק התוצאה נקבע לפי החלשה מבין השתיים, ולכן העלאת הסדר כאן בזמן שהנוסחה בצד הקיוביטים נשארת מסדר ראשון קונה מעט.
הגבלת המעבר לשערים שנהנים ממנו
סדר גבוה יותר עוזר רק כשהגורמים לא מתחלפים. אם כולם מתחלפים, הנוסחה מסדר ראשון כבר מדויקת והגורמים הנוספים הם תקורה טהורה. אופרטור אלכסוני לחלוטין הופך את זה לקונקרטי, כי אופרטורי מספר מתחלפים זה עם זה:
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> diagonal = FermionOperator.from_terms(
... [([cre(j), ann(j), cre(j + 1), ann(j + 1)], 1.0 + 0.1 * j) for j in range(num_sites - 1)]
... )
>>> diagonal.groups = list(range(num_sites - 1))
>>>
>>> def diagonal_cost(synthesis):
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(num_sites, diagonal, time=total_time, synthesis=synthesis), circuit.modes
... )
... transpiled = generate_preset_jw_pass_manager().run(circuit.decompose())
... return transpiled.decompose(reps=6).count_ops()["cx"]
>>>
>>> diagonal_cost(FermionicLieTrotter())
10
>>> diagonal_cost(FermionicSuzukiTrotter(order=2))
18
שני המעגלים מדויקים, ולכן השני מבזבז 80% יותר שערי שזירה בלי סיבה. לאופרטורי Coulomb האלכסוניים של UCJ יש צורה כזו, ולכן קיים F2FSynthesis.filter: הוא מקבל פרדיקט על ה-DAGOpNode ומשאיר שערים שנדחו ללא נגיעה, כך שמעבר אחד יכול להעלות את הסדר בשערים שנהנים ממנו ולהשאיר את השאר בסדר ראשון.
מעגל שמכיל את שני סוגי האבולוציה מראה כמה זה חוסך. הפרדיקט שלהלן מקבל שער רק כשהאופרטור שלו מזיז חלקיקים בין מודים, וזה בדיוק כשהקבוצות לא מתחלפות ולסדר גבוה יותר יש מה לקנות:
>>> def is_diagonal(operator):
... """Whether every term only counts particles, rather than moving them between modes."""
... return all(
... sorted(mode for created, mode in actions if created)
... == sorted(mode for created, mode in actions if not created)
... for actions, _ in operator.iter_terms()
... )
>>>
>>> def mixed_circuit():
... circuit = FermionicCircuit(num_sites)
... circuit.append(Evolution(num_sites, hamiltonian, time=total_time), circuit.modes)
... circuit.append(Evolution(num_sites, diagonal, time=total_time), circuit.modes)
... return circuit
>>>
>>> def two_qubit_cost(circuit):
... transpiled = generate_preset_jw_pass_manager().run(circuit).decompose(reps=6)
... two_qubit = lambda instruction: len(instruction.qubits) == 2
... return transpiled.count_ops()["cx"], transpiled.depth(two_qubit)
>>>
>>> order_2 = FermionicSuzukiTrotter(order=2)
>>>
>>> everywhere = two_qubit_cost(
... FermionicPassManager(F2FSynthesis({"Evolution": order_2})).run(mixed_circuit())
... )
>>> restricted = two_qubit_cost(
... FermionicPassManager(
... F2FSynthesis(
... {"Evolution": order_2}, filter=lambda node: not is_diagonal(node.op.operator)
... )
... ).run(mixed_circuit())
... )
>>> everywhere[0] - restricted[0] # the gates the filter saved
8
>>> restricted[0] < everywhere[0] and restricted[1] < everywhere[1]
True
השערים שהמסנן חוסך הם בדיוק אלה שסדר שני בזבז על האבולוציה האלכסונית, שההשוואה האלכסונית למעלה מתמחרת בשמונה (18 לעומת 10). הם לא קנו שם דבר, כי האבולוציה הזו כבר מדויקת בסדר ראשון, ולכן ויתור עליהם לא עולה בדיוק.
השוו את שני מעגלי סדר שני זה לזה, ולא למעגל הלא מורחב. השארת שני השערים לא מורחבים שולחת כל אחד לשלב מפרמיון לקיוביט בשלמותו, וזו הטעות שההערה על apply=False למעלה מתארת: אפשר להגיע לאותו מספר שערי שזירה כמו המעגל המסונן ולהיות מעגל שונה בעומק שונה, ולכן קריאת התועלת של המסנן מהמספר הזה לא מודדת דבר.
אף אחד מהכפתורים לא מוודא שהגורמים הרמיטיים, ואופרטור מקובץ באופן שגוי יכול להיראות טוב יותר בסדר זוגי מאשר בסדר ראשון ועדיין להיות שגוי, כי הסימטריזציה מבטלת חלקית את השגיאה. קבצו שותפים צמודים יחד, כפי ש-fermi_hubbard_chain למעלה עושה.
השלבים הבאים
-
קראו את קיבוץ איברי אופרטור: שימוש במבנה האופרטור כדי לראות איך מוקצות הקבוצות שנוסחאות המכפלה האלה מפצלות לפיהן.
-
קראו את טרנספילציה של מעגלים פרמיוניים כדי להכיר את השלבים ש-
FermionicCircuitעובר דרכם. -
ראו את סימולציה של דינמיקת Fermi-Hubbard חד-ממדית עם flow sets למקרה מעשי שבו הנוסחה הפרמיונית מספקת את מספר הצעדים שסינתזה מותאמת אישית בצד הקיוביטים לא יכולה לספק.
-
עיינו ב-
synthesisעבור שיטות הסינתזה עצמן, וב-FermionicQDriftעבור חלופה אקראית.