Saltar al contenido principal
Este proyecto está en fase beta. ¿Encontraste un error? Abre un issue en GitHub. ¿Tienes una pregunta o una idea? Pregunta en Discussions — ¡nos encantaría tu opinión!
¿Cómo es la calidad de la traducción en esta página?

Sintetiza una puerta Evolution en el espacio fermiónico

Importante

Los conceptos de esta guía solo están disponibles actualmente en la API de Python. Una funcionalidad equivalente estará disponible en la API de C en una versión futura.

Una puerta Evolution porta la evolución temporal e−itHe^{-itH} de un operador completo. Una síntesis de fermión a fermión reescribe esa puerta como un FermionicCircuit de puertas fermiónicas más pequeñas. Ambos lados de la reescritura permanecen en el espacio fermiónico, lo que la distingue de la síntesis de fermión a qubit que viene después: los groups del operador (consulta Agrupa términos del operador: usa la estructura del operador) sobreviven a ella, mientras que un mapeo de fermión a qubit devuelve un SparseObservable, que no tiene concepto de agrupación.

Dos mandos seleccionan el método. Evolution.synthesis lo establece en una sola puerta, y el pase de transpilador F2FSynthesis lo establece en todo un circuito. Ambos aceptan un F2FSynthesisPlugin, la interfaz que implementa cualquier reescritura de este tipo. Los métodos que este paquete ofrece actualmente para una evolución son fórmulas de producto, que es lo que usa el resto de esta guía: FermionicLieTrotter para una fórmula de primer orden (la predeterminada) y FermionicSuzukiTrotter para un orden par superior. Cualquier otra cosa que implemente la interfaz se selecciona del mismo modo, y las secciones sobre los dos mandos se le aplican sin cambios.

Nota

Este paso de fermión a fermión es opcional. Una puerta Evolution puede pasar directamente a la etapa de fermión a qubit, que la mapea y sintetiza completa sin importar cuántos términos contenga. Dividirla primero es una decisión que se toma porque las puertas resultantes son individualmente más baratas o porque la división expone estructura que una etapa posterior puede aprovechar.

Los dos pasos se componen, así que una evolución puede acabar aproximada en cualquiera de los dos niveles, en ambos o en ninguno. Una fórmula de producto aproxima a menos que sus factores conmuten; un método de síntesis no tiene por qué aproximar en absoluto.

Nota

Omitir este paso no tiene por qué significar perder la agrupación por completo. Envolver el mapeador en group_wise() mapea el operador grupo por grupo y suma los resultados, de modo que los términos de Pauli mapeados llegan agrupados en lugar de entremezclados. Una fórmula de producto del lado de los qubits los sintetiza en el orden en que los recibe, así que los términos conmutantes que acaban adyacentes pueden programarse juntos.

En el hamiltoniano siguiente, eso recupera todo el beneficio en profundidad de dos qubits que aporta la división: 18 en lugar de 30, con el mismo número de puertas. La diferencia está en dónde se expresa la estructura. Dividir en el espacio fermiónico convierte cada grupo en una puerta separada, que cada etapa posterior puede ver y sobre la que puede actuar; group_wise deja una sola puerta y solo reordena los términos dentro de ella, lo que basta para la programación pero no deja nada con lo que trabaje un pase fermiónico. Úsalo cuando la evolución tenga que seguir siendo una sola puerta, y recurre a una fórmula de producto fermiónica en los demás casos.

Construye un hamiltoniano agrupado​

Ambas fórmulas de producto dividen un operador en factores, y para un operador agrupado esos factores son sus grupos. Por tanto, la agrupación determina tanto el coste como la precisión del resultado. Toma una cadena de Fermi-Hubbard de seis sitios, agrupada según conmutan sus términos: los términos de salto en los enlaces pares, los términos de salto en los enlaces impares y la interacción diagonal.

>>> 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

Quince términos en tres grupos. Cada grupo de salto empareja cada término con su pareja conjugada, lo cual importa: un factor tiene que ser hermítico para que su exponencial sea unitaria, y eso no se deduce de que la suma sea hermítica. groups_are_hermitian() comprueba esa convención en lugar de dejarla a la inspección:

>>> from qiskit_fermions.operators.terms.grouping import groups_are_hermitian
>>>
>>> groups_are_hermitian(hamiltonian)
[True, True, True]

Separar a0†a1a^\dagger_0 a_1 de su pareja a1†a0a^\dagger_1 a_0 la rompería, y dejar el operador sin agrupar tiene el mismo efecto, ya que ambas fórmulas lo dividen entonces término a término y un ai†aja^\dagger_i a_j aislado no es hermítico:

>>> 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

Un factor así es un operador fermiónico válido, y este paquete construirá la puerta para él, así que nada se queja hasta que el operador mapeado llega a PauliEvolutionGate, que requiere coeficientes reales y lanza ValueError: Operator contains complex coefficients, which are not supported. Comprobar la agrupación de antemano convierte ese fallo tardío en una respuesta disponible antes de construir ningún circuito.

Establece el método en una sola puerta​

Pasa un método de síntesis a la puerta y luego descomponla. Cada puerta que emite vuelve como su propia puerta 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

El primer orden emite un factor por grupo. El orden dos emite el palíndromo simétrico, de modo que los dos grupos exteriores aparecen dos veces con la mitad del tiempo cada uno, dando cinco. Cuatro repeticiones de eso darían veinte, pero cada repetición termina en el mismo grupo con el que empieza la siguiente. Esas puertas vecinas evolucionan bajo el mismo operador, así que se fusionan en una sola puerta con el tiempo sumado, lo que ahorra una puerta por unión. Pasa merge_adjacent=False para conservar la secuencia de los libros de texto. Todos los órdenes dividen el operador de la misma manera; lo que cambia es el orden de los factores y sus tiempos de evolución, no la partición.

Nota

Los factores se marcan como atomic, así que descomponer de nuevo los deja en su sitio en lugar de dividirlos más. La descomposición tiene un punto fijo, que es lo que hace seguro llamar a decompose() repetidamente.

Establece el método para todo un pipeline​

Establecer synthesis por puerta significa trasladar la elección a todo lo que construye una, incluidos UCC y UCJ, que construyen sus puertas internamente. En cambio, el pase F2FSynthesis aplica un método a cada Evolution de un circuito, de modo que la elección se hace una sola vez para el pipeline. Se configura por nombre de puerta, igual que F2QSynthesis, y una puerta que no se nombra se deja como está:

>>> 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

Este es el mismo recuento que da decompose() para ese método más arriba. El pase MergeEvolutions aplica la misma fusión a puertas vecinas que no provienen de una misma síntesis, como las uniones entre circuitos consecutivos. El pase ejecuta la fórmula por sí mismo, así que no hace falta un paso de expansión aparte, y ejecutarlo dos veces no cambia nada, ya que los factores que emite son atomic y por tanto no tienen definición propia.

Nota

Pasa apply=False para solo seleccionar el método y dejar la expansión a otra cosa, como Decompose de Qiskit (nombrar la puerta como Decompose("Evolution") lo restringe a las evoluciones, dejando las demás puertas fermiónicas a sus propios plugins de síntesis).

Esto es útil para elegir de antemano el método de todo un circuito y diferir la expansión hasta el punto en que se quiera, por ejemplo para inspeccionar primero el circuito:

>>> 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

Ten en cuenta, sin embargo, lo que hace una puerta sin expandir: llega entera a la etapa de fermión a qubit, donde se mapea sin consultar synthesis. Eso facilita un error al comparar métodos, porque todos producen entonces una salida idéntica y la comparación no mide nada en silencio. En el hamiltoniano anterior, dejar sin expandir la puerta de orden dos reduce el circuito mucho mayor de abajo al coste de una sola evolución mapeada, sin importar qué método se haya seleccionado.

Mide la recompensa​

Un orden superior cuesta profundidad, así que la cuestión no es si es más preciso, sino si es más preciso por puerta. Se necesitan dos números por método: el error del estado que produce y el coste de dos qubits del circuito que lo produjo.

El error se mide con ffsim, que simula un FermionicCircuit directamente (consulta Cómo se relaciona este paquete con ffsim). No interviene ningún mapeo de fermión a qubit, así que la comparación aísla la fórmula de producto fermiónica de cualquier cosa que pudieran aportar el mapeo o la síntesis del lado de los qubits. También funciona en el sector de número de partículas fijo, que es más pequeño que el espacio completo de 2N2^N y es el espacio en el que la evolución realmente permanece.

>>> 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

El coste de dos qubits se deja fuera de esa tabla a propósito. Depende de la síntesis del lado de los qubits que seleccione Qiskit, que mejora entre versiones, así que los números impresos aquí serían una instantánea y no una propiedad de la fórmula de producto. La figura siguiente los representa en su lugar, y aquello en lo que se apoya la comparación se afirma como una relación:

Grouped bars of CX count and depth per method, with order two at four repetitions clearly shorter than order one at ten.

Lee juntos los dos últimos métodos, porque son la comparación que importa. El orden dos con cuatro repeticiones es aproximadamente diez veces más preciso que el orden uno con diez, y lo consigue con menos puertas entrelazantes en lugar de más, como muestra la figura. Aquí, invertir un presupuesto de puertas en un orden superior supera a invertirlo en más pasos de primer orden.

Ese resultado no es universal. Aumentar reps y aumentar order reducen el error a ritmos distintos para el mismo tipo de coste, y cuál gana depende del operador y del tiempo que se evoluciona. Las dos filas anteriores son la manera de averiguarlo para un problema dado.

Nota

La fórmula fermiónica se compone con la elegida para la etapa de fermión a qubit (consulta MapperFnEvolutionSynthesis.product_formula). Cuando ambas aproximan, la precisión del resultado la gobierna la más débil de las dos, así que subir aquí el orden mientras la fórmula del lado de los qubits sigue siendo de primer orden aporta poco.

Restringe el pase a las puertas que se benefician​

Un orden superior solo ayuda cuando los factores no conmutan. Si todos conmutan, la fórmula de primer orden ya es exacta y los factores adicionales son puro sobrecoste. Un operador puramente diagonal lo hace concreto, ya que los operadores de número conmutan entre sí:

>>> 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

Ambos circuitos son exactos, así que el segundo gasta un 80 % más de puertas entrelazantes para nada. Los operadores de Coulomb diagonales de un UCJ tienen esta forma, que es la razón de que exista F2FSynthesis.filter: toma un predicado sobre el DAGOpNode y deja intactas las puertas rechazadas, de modo que un solo pase puede subir el orden en las puertas que se benefician y dejar el resto en primer orden.

Un circuito que contiene ambos tipos de evolución muestra lo que eso ahorra. El predicado siguiente acepta una puerta solo cuando su operador mueve partículas entre modos, que es exactamente cuando los grupos no conmutan y un orden superior tiene algo que aportar:

>>> 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

Las puertas que ahorra el filtro son exactamente las que el orden dos gastó en la evolución diagonal, que la comparación diagonal anterior valora en ocho (18 frente a 10). Allí no aportaron nada, porque esa evolución ya es exacta en primer orden, así que eliminarlas no cuesta precisión.

Nota

Compara los dos circuitos de orden dos entre sí, no con el no expandido. Dejar ambas puertas sin expandir envía cada una entera a la etapa de fermión a qubit, que es el error que describe la nota sobre apply=False anterior: puede dar el mismo número de puertas entrelazantes que el circuito filtrado siendo un circuito distinto con otra profundidad, así que leer el beneficio del filtro a partir de ese número no mide nada.

Precaución

Ninguno de los dos mandos verifica que los factores sean hermíticos, y un operador mal agrupado puede parecer mejor en un orden par que en primer orden y seguir estando mal, porque la simetrización cancela parcialmente el error. Agrupa juntas las parejas conjugadas, como hace fermi_hubbard_chain más arriba.

Próximos pasos​