Generar circuitos SqDRIFT
Con la diagonalización cuántica basada en muestras (SQD), debes elegir un ansatz del cual muestrear cadenas de bits. La variante SqDRIFT usa en su lugar un conjunto de circuitos de evolución temporal construidos directamente a partir del hamiltoniano objetivo. Esto se logra submuestreando operadores de evolución temporal más pequeños del hamiltoniano según sus coeficientes, lo que se conoce como el método de trotterización qDRIFT.
Esta guía de introducción muestra cómo generar un conjunto de estos circuitos aleatorizados.
1. Configuración del hamiltoniano
Para los fines de esta guía, carga el hamiltoniano de estructura electrónica del N2 desde un archivo FCIDUMP. Existen otras formas de construir el FermionOperator. Asegúrate de consultar su documentación, así como qiskit_fermions.operators.library.
- PYTHON
- C
>>> from qiskit_fermions.operators.library import FCIDump
>>> from qiskit_fermions.operators import FermionOperator
>>>
>>> fcidump = FCIDump.from_file("docs/guides/n2.fcidump")
>>> num_modes = 2 * fcidump.norb
>>> hamil = FermionOperator.from_fcidump(fcidump)
#include <qiskit_fermions.h>
QfFCIDump* fcidump = NULL;
qf_fcidump_from_file("docs/guides/n2.fcidump", &fcidump);
QfFermionOperator* hamil = qf_ferm_op_from_fcidump(fcidump);
uint32_t num_modes = 2 * qf_fcidump_norb(fcidump);
2. Agrupar los términos del hamiltoniano
Aprovecha las numerosas simetrías presentes en el hamiltoniano de estructura electrónica agrupando los términos relacionados que tienen coeficientes idénticos. Esta acción cambia la distribución de coeficientes del operador de la que muestrea el protocolo qDRIFT, pero no afecta a sus garantías de convergencia. Lo fundamental es que agrupar términos relacionados por simetría produce una cancelación favorable de términos de Pauli, lo que da como resultado una profundidad de circuito global menor al evolucionar un estado en el tiempo bajo su acción.
El módulo qiskit_fermions.operators.terms.grouping ofrece funciones de conveniencia para agrupar los términos de un operador. Esto se explica con más detalle en esta guía.
¡La implementación de group_terms_by_electronic_structure() supone que los términos del hamiltoniano están en orden normal!
- PYTHON
- C
>>> from qiskit_fermions.operators.terms.grouping import group_terms_by_electronic_structure
>>> from qiskit_fermions.operators.terms.ordering import canonical_order
>>>
>>> canon = canonical_order(hamil.normal_ordered().simplify(atol=1e-16))
>>> exit_code = group_terms_by_electronic_structure(canon, num_modes, two_body_physicist_order=False)
>>> assert exit_code is None
>>> print(canon.groups) # the groups attribute now contains some list of group indices
[0, ...]
QfFermionOperator* normal = qf_ferm_op_normal_ordered(hamil, NULL);
QfFermionOperator* simplified = qf_ferm_op_simplify(normal, 1e-16);
QfFermionOperator* canon = qf_ferm_op_canonical_order(simplified);
QfExitCode exit = qf_ferm_op_group_terms_by_electronic_structure(canon, num_modes, false);
assert(exit == QfExitCode_Success);
qf_ferm_op_free(normal);
qf_ferm_op_free(simplified);
El hamiltoniano de estructura electrónica completo contiene ciertos términos cuya inclusión en un circuito de evolución temporal no tiene ningún impacto en las cadenas de bits percibidas y, por tanto, solo aumenta la sobrecarga de muestreo. Por ello, se recomienda filtrar dichos términos del hamiltoniano en este punto, antes de construir la compuerta Evolution en el siguiente paso.
Los términos que encajan en esta descripción son los diagonales en la base de números de ocupación, es decir, los productos de operadores de número (). Esto incluye el desplazamiento constante de energía, cuya evolución temporal solo introduce una fase global en el circuito, los operadores de número individuales, cuya evolución temporal equivale a rotaciones Z de un solo qubit, así como productos de orden superior como . Ninguno de ellos puede cambiar la ocupación de un modo, por lo que descartarlos no supone ninguna pérdida de contenido de excitación. A diferencia del rechazo por muestra del paso 5, este filtrado ocurre una sola vez, sobre el propio hamiltoniano, de modo que la distribución de coeficientes de la que muestrea el protocolo se reconstruye de forma coherente a partir del operador filtrado en lugar de renormalizarse a mitad del muestreo.
La función filter_diagonal_terms() elimina dichos términos de un operador in situ:
- PYTHON
- C
>>> from qiskit_fermions.operators.terms.filtering import filter_diagonal_terms
>>>
>>> filter_diagonal_terms(canon)
qf_ferm_op_filter_diagonal_terms(canon);
Filtrar aquí, una sola vez, es considerablemente más barato que filtrar repetidamente. FermionicQDrift se ejecuta una vez por circuito transpilado, por lo que filtrar el hamiltoniano al principio, en lugar de en cada llamada, evita repetir trabajo por cada circuito generado a partir de él.
Por la misma razón, conviene ordenar aquí los términos del hamiltoniano por índice de grupo. Los índices de grupo son una etiqueta por término que no dice nada sobre dónde se ubican esos términos, por lo que los términos de un grupo suelen estar dispersos por todo el operador: la agrupación aplicada arriba asigna los índices en orden de primera aparición, lo que deja el segundo término de un grupo lejos del primero.
FermionicQDrift muestrea grupos completos y busca cada grupo extraído con split_out_groups(). Encontrar un grupo entre términos dispersos implica recorrerlos todos, por lo que esa búsqueda cuesta lo mismo si se extrae un grupo o cuarenta. group_order() reúne cada grupo en una única serie contigua, tras lo cual la búsqueda es una búsqueda binaria sobre los límites de los grupos y cuesta lo que cuestan los grupos extraídos en lugar de lo que cuestan los términos almacenados. La búsqueda en sí resulta drásticamente más barata; cuánto ahorra esto en una transpilación concreta depende de qué más haga el flujo, ya que el mapeo y la síntesis dominan una vez eliminado el recorrido:
- PYTHON
- C
>>> from qiskit_fermions.operators.terms.ordering import group_order
>>>
>>> canon = group_order(canon)
>>> print(canon.groups[:4])
[0, 0, ...]
QfFermionOperator* grouped = qf_ferm_op_group_order(canon);
Esto reordena los términos sin cambiar el valor del operador, y solo debe hacerse una vez, sin importar cuántas aleatorizaciones de circuito se extraigan de él.
3. Preparar el circuito de evolución temporal
Prepara el circuito de evolución temporal del hamiltoniano y el circuito base del que extraer muestras. qiskit_fermions.circuit.library contiene todos los componentes necesarios para ello, conforme a las convenciones de Qiskit.
El protocolo qDRIFT es el método de síntesis FermionicQDrift, y la compuerta Evolution lo lleva como su synthesis. El método introduce unos pocos parámetros que puedes usar para personalizar los circuitos que se generan:
-
La longitud de cada circuito en términos de grupos de excitación:
num_groups -
Opcionalmente, una semilla para el generador de números aleatorios:
rng -
Opcionalmente, pesos de muestreo precalculados, si la propia transpilación se convierte en un cuello de botella:
weights(consulta (Opcional) Precalcular los pesos de muestreo más abajo)
Como el método lo guarda la compuerta, todo lo que describe el operador de la compuerta, como esos pesos, queda justo junto a ese operador.
- PYTHON
- C
>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.circuit.library.synthesis import FermionicQDrift
>>>
>>> time = 1.0 # you can choose a desired scaling factor here
>>> num_groups = 10
>>> qdrift = FermionicQDrift(num_groups, rng=19)
>>> evo_gate = Evolution(num_modes, canon, time, synthesis=qdrift)
>>>
>>> circ = FermionicCircuit(num_modes)
>>> circ.append(evo_gate, circ.modes)
// WARNING: Qiskit's C API does not yet allow us to implement circuits
// with custom gate definitions.
Este ejemplo no inicializa los modos fermiónicos con partículas ni mide su estado final.
4. Transpilar el circuito
El módulo qiskit_fermions.transpiler se integra directamente con el flujo de transpilación de Qiskit, lo que permite transpilar directamente a un QuantumCircuit el FermionicCircuit construido arriba.
Usa el mapeo fermión a qubit jordan_wigner() para convertir el hamiltoniano expresado en términos de fermiones en uno expresado en cadenas de Pauli. Esto puede hacerse directamente como parte del proceso de transpilación mediante el paso de transpilación F2QSynthesis. Usa generate_preset_jw_pass_manager() para construir un FermionicStagedPassManager, que garantiza que la codificación de Jordan-Wigner se use de forma coherente en todas las instrucciones del circuito.
Lo fundamental es añadir el paso Decompose de Qiskit a la etapa optimization del flujo de transpilación. Este descompone la compuerta Evolution con el método de síntesis que la compuerta lleva, que es el método qDRIFT preparado arriba. Así se garantiza que el circuito no use la evolución temporal del hamiltoniano completo, cuya profundidad superaría las capacidades del hardware de computación cuántica disponible actualmente.
En su lugar, submuestrea un número fijo de groups de términos del hamiltoniano para cada circuito, cada vez que el circuito se transpila. Así, puedes generar múltiples aleatorizaciones de circuito, como exige el algoritmo SqDRIFT, ejecutando repetidamente el flujo de transpilación. El número de circuitos a generar es num_sqdrift_randomizations.
- PYTHON
- C
>>> from qiskit.transpiler.passes import Decompose
>>> from qiskit_fermions.transpiler import FermionicPassManager
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> pm = generate_preset_jw_pass_manager()
>>> pm.optimization = FermionicPassManager([Decompose(["Evolution"])])
>>>
>>> num_sqdrift_randomizations = 10
>>> sqdrift_circuits = [
... pm.run(circ) for _ in range(num_sqdrift_randomizations)
... ]
// WARNING: Qiskit's C API does not yet allow us to implement circuits
// with custom gate definitions, which we therefore also cannot transpile
// via this API.
Los ejemplos anteriores fijan la semilla rng del método de síntesis FermionicQDrift, de modo que la secuencia de aleatorizaciones pueda reproducirse.
El mismo flujo acepta el método qDRIFT en otros dos lugares. El paso F2FSynthesis toma un método para cada compuerta Evolution por nombre, F2FSynthesis({"Evolution": FermionicQDrift(num_groups)}), que es todo lo necesario cuando ninguna compuerta guarda nada específico de su operador. El siguiente paso usa el tercero, el paso QDriftTrotterization.
5. Filtrar las excitaciones extraídas por ocupación
Además de los términos diagonales filtrados en el paso anterior, una excitación extraída puede actuar por completo dentro de un conjunto de modos cuya ocupación ya está fija (todos ocupados o todos desocupados), donde no puede mover una partícula de un lado al otro. Tal excitación no aporta nada sobre las cadenas de bits muestreadas, por lo que una posición gastada en ella es una posición desperdiciada. El paso QDriftTrotterization, que envuelve el mismo método FermionicQDrift, rechaza esas excitaciones a medida que se extraen y extrae una de reemplazo, de modo que cada una de las num_groups posiciones del circuito resultante lleva una excitación que acopla los dos lados.
Este filtrado no es gratuito, y por eso es un paso aparte. Lo que conserva es el presupuesto de num_groups posiciones muestreadas, no la profundidad del circuito: como ese presupuesto es fijo, una excitación rechazada se reemplaza en lugar de descartarse, y una barata cede su lugar a una excitación de acoplamiento que cuesta más sintetizar. Espera que un circuito filtrado sea más profundo que uno sin filtrar extraído con la misma semilla.
Renormalizar la extracción sobre las excitaciones aceptadas cambia la distribución de la que muestrea el protocolo qDRIFT y, a diferencia de la agrupación del paso 2, también rompe la garantía de convergencia del protocolo: el producto muestreado ya no promedia a la evolución bajo el hamiltoniano que pasaste. Cada excitación retenida acaba ponderada por el recíproco de la probabilidad de aceptación, las rechazadas no aportan nada, y no se ajusta ni la magnitud total de los coeficientes ni el tiempo de evolución por compuerta para compensarlo.
La regla de aceptación es además una sobreaproximación. Compara solo los soportes de los modos, por lo que no lleva información de amplitud, y los conjuntos registrados solo crecen: una excitación confinada al conjunto ocupado es realmente inerte sobre un estado producto, pero el mismo razonamiento se debilita en extracciones posteriores, una vez que las excitaciones aceptadas antes han dejado el estado entrelazado y han marcado la mayoría de los modos como “inciertos”.
Usa el filtrado para el flujo de muestreo de cadenas de bits que describe esta guía, donde los circuitos alimentan el posprocesamiento de SQD y solo importan las cadenas de bits muestreadas. No lo uses si pretendes estimar un valor esperado a partir de estos circuitos, o apoyarte de cualquier otro modo en la cota de error de qDRIFT: esos resultados llevan un sesgo que el paso no corrige.
Este filtrado necesita saber qué modos empiezan ocupados. Por ello requiere una compuerta InitializeModes antes de las compuertas Evolution del circuito; InitializeModes.from_hartree_fock() es una forma cómoda de construir una. Añade una aquí para la referencia de Hartree-Fock del N2 (siete electrones alfa y siete beta en 14 orbitales espaciales) y compara las excitaciones muestreadas con y sin el filtrado:
- PYTHON
- C
>>> from qiskit_fermions.circuit.library import InitializeModes
>>> from qiskit_fermions.transpiler.passes import F2FSynthesis, QDriftTrotterization
>>>
>>> init = InitializeModes.from_hartree_fock(fcidump.norb, (7, 7))
>>>
>>> hf_circ = FermionicCircuit(num_modes)
>>> hf_circ.append(init, hf_circ.modes)
>>> hf_circ.append(Evolution(num_modes, canon, time), hf_circ.modes)
>>>
>>> num_groups = 5
>>> qdrift_unfiltered = F2FSynthesis(
... {"Evolution": FermionicQDrift(num_groups, rng=3480)}
... )
>>> qdrift_trivial = QDriftTrotterization(
... FermionicQDrift(num_groups, rng=3480)
... )
>>>
>>> for instruction in FermionicPassManager(qdrift_unfiltered).run(hf_circ)._inner.data:
... if instruction.operation.name == "Evolution":
... print(sorted(instruction.operation.operator.get_support()))
[2, 4]
[41, 45, 52]
[15, 45, 55]
[41, 52, 53]
[10, 16, 37, 38]
>>>
>>> for instruction in FermionicPassManager(qdrift_trivial).run(hf_circ)._inner.data:
... if instruction.operation.name == "Evolution":
... print(sorted(instruction.operation.operator.get_support()))
[0, 1, 6, 7]
[0, 1, 28, 29]
[4, 13, 55]
[13, 20, 40, 41]
[0, 1, 28, 29]
// WARNING: Qiskit's C API does not yet allow us to implement circuits
// with custom gate definitions, which we therefore also cannot transpile
// via this API.
Ninguna de las excitaciones extraídas sin filtrado toca en absoluto el conjunto ocupado (0-6 y 28-34), por lo que ninguna puede mover una partícula entre un modo ocupado y uno desocupado. Con el filtrado, las cinco se rechazan y se reemplazan por excitaciones que sí acoplan un modo ocupado con uno desocupado. Por ejemplo, la primera excitación aceptada [0, 1, 6, 7] mueve una partícula entre los modos ocupados 0, 1 y 6 y el modo desocupado 7.
Una vez aceptada una excitación, todos los modos de su soporte pasan a ser “inciertos” y, por tanto, aptos para desempeñar cualquiera de los dos papeles en muestras posteriores, de modo que los conjuntos de modos ocupados y desocupados siguen creciendo a medida que se aceptan más excitaciones. Esto es lo que hace aceptable la segunda excitación, [0, 1, 28, 29]. Sus cuatro modos están entre los ocupados originalmente, por lo que no se acopla a ningún modo originalmente desocupado. Solo se acepta porque los modos 0 y 1 pasaron a ser inciertos (y, por tanto, aptos como lado “desocupado” del acoplamiento) cuando la primera excitación los tocó.
Este crecimiento es lo que convierte la regla en una sobreaproximación y no en una prueba: a medida que más modos se vuelven inciertos, se rechazan menos extracciones, y el filtrado actúa más sobre la distribución de muestreo en las primeras extracciones.
Para juzgar cuánto movió el filtrado la distribución, lee los conteos de rechazo que el paso registra en los metadatos del circuito. Se almacena una entrada por cada compuerta Evolution filtrada, y la razón entre aceptadas y totales estima la probabilidad de aceptación, que es el factor por el que se sobreponderaron las excitaciones retenidas:
- PYTHON
- C
>>> # a freshly seeded pass, so the counts do not depend on earlier draws
>>> qdrift_counted = QDriftTrotterization(
... FermionicQDrift(num_groups, rng=3480)
... )
>>> filtered = FermionicPassManager(qdrift_counted).run(hf_circ)
>>> discarded = filtered.metadata.get("filter_trivial.discarded")
>>> emitted = filtered.metadata.get("filter_trivial.emitted")
>>> print(f"{emitted[0]} kept, {discarded[0]} discarded")
5 kept, 7 discarded
>>> print(f"acceptance probability: {emitted[0] / (emitted[0] + discarded[0]):.2f}")
acceptance probability: 0.42
// WARNING: Qiskit's C API does not yet allow us to implement circuits
// with custom gate definitions, which we therefore also cannot transpile
// via this API.
El filtrado necesita información de ocupación contra la cual filtrar. Una compuerta PrepareSlaterDeterminant la proporciona igual que InitializeModes, y cualquier OrbitalRotation marca como “incierto” todo modo sobre el que actúa. Sin ninguna de ellas, o cuando los modos sembrados caen todos en un mismo lado (todos ocupados o todos desocupados), el paso emite una UserWarning y deja el muestreo sin filtrar para esa compuerta Evolution. En un hamiltoniano cuyos términos restantes ya no pueden acoplar los dos conjuntos registrados, el paso lanza en cambio una RuntimeError tras QDriftTrotterization.MAX_SAMPLE_RETRIES rechazos consecutivos.
(Opcional) Precalcular los pesos de muestreo
Recurre a esto solo si la propia transpilación es un cuello de botella. No cambia nada de los circuitos que obtienes.
FermionicQDrift extrae cada grupo con una probabilidad proporcional a la magnitud de su coeficiente, y deriva esas magnitudes del hamiltoniano en cada llamada: un valor por término del hamiltoniano, reducido a uno por grupo. Como el método no guarda estado, repite esa reducción en cada aleatorización, aunque el resultado sea idéntico cada vez. El argumento weights te permite calcularla una sola vez con group_coeff_means() y pasar el resultado, sacando el costo del bucle y dejando el método libre de cualquier estado en caché que dependa del hamiltoniano.
Esta es la contraparte del ordenamiento de términos del paso 2. Aquel saca del bucle la búsqueda de grupos; este, la reducción de pesos, y cada uno solo se nota tanto como falta el otro, por lo que conviene aplicar ambos a la vez. Como canon ya se reordenó arriba, los dos se combinan directamente.
Los pesos describen los grupos de un operador concreto, así que pertenecen a la compuerta Evolution de ese operador: construye el método para esa compuerta y pásaselo como su synthesis, exactamente como en el paso 3.
- PYTHON
- C
>>> from qiskit_fermions.operators.terms.grouping import group_coeff_means
>>>
>>> weights = group_coeff_means(canon) # `canon` is group-ordered, from step 2
>>> qdrift_weighted = FermionicQDrift(10, rng=19, weights=weights)
>>> weighted_gate = Evolution(num_modes, canon, time, synthesis=qdrift_weighted)
>>>
>>> weighted_circ = FermionicCircuit(num_modes)
>>> weighted_circ.append(weighted_gate, weighted_circ.modes)
>>>
>>> weighted_circuits = [
... pm.run(weighted_circ) for _ in range(num_sqdrift_randomizations)
... ]
>>> len(weighted_circuits)
10
// WARNING: Qiskit's C API does not yet allow us to implement circuits
// with custom gate definitions, which we therefore also cannot transpile
// via this API.
Como esos son exactamente los pesos que habría calculado el paso, los circuitos son los mismos que antes; solo desaparece la reducción repetida.
Se espera una entrada por grupo, no por término, siempre que el operador tenga grupos, ya que un operador agrupado se muestrea por grupos. Las entradas deben ser no negativas: un peso es una magnitud, porque el protocolo qDRIFT descompone con positivos, y el signo de un coeficiente pertenece a , donde el paso lo lee directamente del operador.
Los pesos son magnitudes absolutas, no preferencias relativas. Su suma también fija el tiempo de evolución, de modo que multiplicar cada entrada por muestrea de forma idéntica pero evoluciona durante en lugar de . Pasar cualquier cosa distinta de las magnitudes de los propios coeficientes del hamiltoniano cambia, por tanto, qué evolución aproxima el conjunto, razón por la cual esto es una herramienta de rendimiento y no una forma de ajustar el muestreo.
Como los pesos pertenecen a la compuerta cuyo operador describen, un circuito puede contener varias compuertas Evolution, cada una con los pesos de su propio operador. El paso QDriftTrotterization del paso 5 los respeta del mismo modo: extrae de una compuerta con el FermionicQDrift que la compuerta lleva, y recurre al método con el que se construyó para las compuertas que no llevan ninguno. Otro tipo de método de síntesis en una compuerta no es una declaración sobre qDRIFT, así que el paso lo ignora. Dar el método a un paso por nombre de compuerta, como en F2FSynthesis({"Evolution": ...}), aplica un único método a cada compuerta Evolution, por lo que los pesos pasados ahí solo tienen sentido para un circuito con una sola. La longitud de los pesos se comprueba frente a cada compuerta a la que se aplican.
(Opcional) Optimizar la indexación de los modos fermiónicos
Puedes añadir un paso de optimización adicional al flujo de transpilación que minimiza la distancia de los soportes de las excitaciones fermiónicas reetiquetando los índices de los modos fermiónicos. Esta optimización se introdujo en el artículo de SqDRIFT y la implementa build_excitation_span_minimization_model(). Se puede insertar fácilmente en el flujo del transpilador mediante el paso RelabelModes:
- PYTHON
- C
>>> from pyomo.environ import SolverFactory
>>> from qiskit_fermions.transpiler.passes import RelabelModes
>>>
>>> solver = SolverFactory("appsi_highs")
>>> solver.options["time_limit"] = 10
>>>
>>> qdrift = F2FSynthesis({"Evolution": FermionicQDrift(5, rng=19)})
>>> relabel = RelabelModes(solver=solver)
>>>
>>> pm.optimization = FermionicPassManager([qdrift, relabel])
>>>
>>> relabeled_circ = pm.run(circ)
>>> # if the automatic mode relabeling was successful, the circuit's
>>> # metadata will contain the mode `permutation` information
// WARNING: This feature is not available via the C API.
Usar la optimización automática dentro de RelabelModes (que aprovecha build_excitation_span_minimization_model()) requiere la dependencia opcional gestionada por HAS_PYOMO.
El reetiquetado cambia qué modo lleva un qubit dado, por lo que la diagonalización de subespacios solo obtiene las cadenas de bits correctas si se tiene en cuenta. Dónde se tiene en cuenta depende de dónde midas.
Añade las mediciones al FermionicCircuit con FermionicCircuit.measure_all() antes de transpilar, y no hay nada más que hacer: cada medición nombra el modo que lee, así que el reetiquetado la arrastra consigo y las cadenas de bits muestreadas quedan indexadas por el modo original.
Si en cambio mides el QuantumCircuit transpilado, las cadenas de bits deben posprocesarse usando la permutation registrada en los metadatos del circuito. Consulta la documentación de RelabelModes para ver la receta.
Próximos pasos
Ahora que has generado con éxito un conjunto de circuitos, puedes muestrear cadenas de bits a partir de ellos. Para ello, los circuitos deben ejecutarse en hardware. Consulta la documentación de Qiskit para obtener instrucciones detalladas.
Una vez obtenidas las muestras de cadenas de bits, se pueden usar junto con los coeficientes del hamiltoniano para realizar el posprocesamiento de SQD, como se explica en los tutoriales del addon SQD.