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?

Mapear representaciones de operadores

Un mapeador convierte un BosonOperator en un operador de qubits, aquí un SparseObservable. El operador en sí no sabe nada de qubits: almacena operadores de escalera sobre modos, y un modo bosónico admite un número arbitrario de partículas. Todo lo que hace finito al operador (cuántos niveles de ocupación conservas y cómo se disponen esos niveles en los qubits) se decide al mapearlo.

El mapeador de la biblioteca occupation_encoding() trabaja en la base de ocupación (Fock): el nivel nn de un modo es el estado con nn bosones. Recibe el operador y un LevelSpec, que contiene el truncamiento y la codificación. Esta guía explica qué significan esas dos decisiones y cuánto cuesta cada una. Para un recorrido completo que las utiliza, consulta Simula la dinámica de Bose-Hubbard con una codificación a elegir; para ver cómo el transpilador aplica las mismas decisiones a un circuito completo, consulta Transpilar circuitos bosónicos.

Trunca el espacio de Fock​

Un modo que se conserva con d=nmax+1d = n_\text{max} + 1 niveles, donde nmaxn_\text{max} es su max_occupation, ya no cumple la relación canónica de conmutación. Escribiendo bˉ\bar{b} para el operador de aniquilación truncado a esos niveles,

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

la relación se cumple en todos los niveles salvo el más alto [1]. Puedes verlo mapeando el conmutador de los dos operadores de escalera y leyendo su diagonal en los niveles de un modo. La siguiente función auxiliar construye la matriz densa de un operador mapeado para un solo modo, lo cual solo es razonable para registros muy pequeños:

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

La última entrada es 1−d1 - d. Como esto significa que el espacio generado por {bˉ†,bˉ}\{\bar{b}^\dagger, \bar{b}\} no es cerrado bajo el conmutador, ninguna codificación es un isomorfismo de álgebras. Una codificación mapea estados, junto con los operadores que actúan dentro del subespacio truncado. Las dos consecuencias siguientes importan en la práctica.

El corte debe acotar la ocupación alcanzable. No basta con acotar la ocupación con la que empiezas. Un divisor de haz que actúa sobre ∣1,1⟩\lvert 1, 1 \rangle puebla ∣2,0⟩\lvert 2, 0 \rangle y ∣0,2⟩\lvert 0, 2 \rangle, de modo que un corte de uno produce en silencio un estado erróneo:

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

Con un corte de uno, la probabilidad de permanecer en ∣1,1⟩\lvert 1, 1 \rangle es uno, porque los dos estados que la dinámica poblaría no existen. Con un corte de dos, se anula, que es el efecto Hong-Ou-Mandel. Por eso max_occupation es un argumento obligatorio que nunca tiene valor por defecto: es una decisión de modelado y un parámetro de convergencia que se espera que barras.

El mapeo no conmuta con el ordenamiento normal. Los operadores bb†b b^\dagger y b†b+1b^\dagger b + 1 son iguales antes del truncamiento, pero se mapean a operadores de qubits distintos:

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

Ambos coinciden en todos los niveles salvo el más alto. Aplicado al nivel superior, b†b^\dagger no tiene adónde ir y la amplitud se recorta, de modo que bb†b b^\dagger es cero ahí, mientras que b†b+1b^\dagger b + 1 da dd. Decide una sola vez si tu hamiltoniano está definido en forma de orden normal, y mapea esa forma de manera coherente.

Tres métodos te ayudan a elegir un corte, y todos son cotas inferiores. El operador informa del mayor número de operadores de creación que aplica a cada modo dentro de un único término, y una especificación lo compara con sus cortes:

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

Una entrada distinta de cero significa que el corte está por debajo de la cota, y el valor indica en cuánto. Estas funciones auxiliares no conocen la dinámica, así que superar la comprobación no hace suficiente a un corte; el divisor de haz anterior tiene una cota de uno y aun así necesita un corte de dos. También pueden señalar un déficit para un término que es cero en los niveles retenidos, como lo es b†b†bbb^\dagger b^\dagger b b para un corte de uno.

Describe el truncamiento con un LevelSpec​

Un LevelSpec agrupa el corte de cada modo con la codificación, y de ellos deduce cuántos qubits ocupa cada modo y dónde empiezan sus qubits. Todo lo que debe coincidir en esto (el mapeador, la disposición del transpilador, la preparación del estado) recibe la misma especificación. La codificación es un argumento obligatorio, y el corte también: ninguno tiene valor por defecto.

El corte es un único entero, compartido por todos los modos, o un entero por modo:

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

Una especificación construida a partir de un solo entero deja abierto el número de modos, así que debes resolverlo antes de pedir los anchos. Una secuencia nunca se difunde, sea cual sea su longitud, y fija el número de modos:

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

El modo mm ocupa los qubits de offsets[m] a offsets[m] + widths[m] - 1, de modo que los modos con cortes distintos pueden usar distinto número de qubits, y los desplazamientos son la suma acumulada de los anchos. No necesitas pasar el número de modos al mapeador. Lo toma de la especificación o, para una especificación con un único corte, del operador mediante min_num_modes(). El registro puede ser más ancho de lo que necesitan los modos; los qubits sobrantes quedan inactivos y se sitúan al final:

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

Se rechaza un operador que actúa sobre un modo posterior al último de una especificación por modo:

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

Algunos límites se derivan de cómo cuentan los qubits las codificaciones. Bajo una codificación compacta, un modo con un solo nivel (max_occupation = 0) no ocupa ningún qubit, porque su único nivel no necesita nada para distinguirse de otro. Un modo así está permitido y no desplaza los qubits de los modos que le siguen, pero se rechaza una especificación en la que todos los modos no ocuparían ningún qubit. Las codificaciones unarias admiten como máximo 128 niveles por modo, y un corte mayor lanza un error que sugiere una codificación compacta:

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

Elige una codificación​

Un LevelEncoding decide qué estado base de un bloque de qubits representa cada nivel. Hay cuatro, y ninguna es la mejor en general [2]. El número de qubits por modo y el patrón de bits de cada nivel se derivan de la codificación:

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

Los patrones de bits son enteros en los que el bit qq es el valor del qubit qq. Las codificaciones unarias gastan un qubit por nivel y activan uno de ellos. Las codificaciones compactas gastan ⌈log⁡2d⌉\lceil \log_2 d \rceil qubits y almacenan el número de nivel, en binario o como código Gray en el que los niveles vecinos difieren en un bit. El nombre en cadena de una codificación se acepta allí donde se acepta la codificación.

El compromiso es entre qubits y peso del operador. Las codificaciones compactas necesitan exponencialmente menos qubits, pero un operador de escalera conecta niveles cuyos patrones pueden diferir en varios bits, por lo que sus términos actúan sobre más qubits. Las secciones siguientes examinan lo que esto cuesta, empezando por las dos codificaciones unarias, que tienen disposiciones idénticas y solo difieren en el operador.

Compara las dos codificaciones unarias​

En la codificación unaria, un operador de escalera mueve una excitación entre dos qubits vecinos. Para escribirlo como operador sobre todo el modo, debes elegir qué hacer con los demás qubits. Las dos variantes eligen de forma distinta:

  • unary_projected coloca un proyector ∣0⟩⟨0∣\lvert 0 \rangle\langle 0 \rvert en cada uno de los demás qubits del modo. Eso impone la restricción one-hot en el propio operador: el término actúa solo si el resto del modo está vacío.

  • unary_unprojected deja en paz esos qubits, de modo que el término actúa como la identidad sobre ellos.

Observa un único b†b^\dagger con d=4d = 4 bajo ambas. El mapeador devuelve un SparseObservable, cuyos términos son cadenas de términos de bit de un solo qubit. Estos son los cuatro primeros términos de cada uno, con los qubits sobre los que actúan:

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

Ambos tienen doce términos con los mismos coeficientes. Los términos proyectados llevan un término de bit 0 en los dos qubits que los términos no proyectados no mencionan, de modo que cada término proyectado toca los cuatro qubits y cada término no proyectado toca dos. En general, un término proyectado toca los dd qubits del modo, porque lleva dos factores, cada uno XX o YY, y un proyector en cada uno de los demás qubits.

Los dos operadores son iguales en los estados para los que está pensada la codificación, los estados base one-hot (las palabras de código), y difieren fuera de ellos. Para comprobarlo, toma un operador pequeño sobre dos modos con d=3d = 3 y compara las matrices en las nueve palabras de código:

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

Ambos dejan además invariante el subespacio de palabras de código, de modo que un estado preparado en una palabra de código permanece en palabras de código. La elección solo importa para estados que salen de ese subespacio, por ejemplo por ruido. Ahí el operador proyectado suprime la evolución, porque sus proyectores fallan, mientras que el no proyectado sigue actuando. Ninguno de los dos comportamientos es un requisito físico. El operador no proyectado solo está definido en el subespacio one-hot, así que elegirlo es una decisión de modelado y no una optimización gratuita.

Lo que cuestan los proyectores son puertas. Evoluciona un b†+bb^\dagger + b hermítico en un modo durante un tiempo corto y cuenta las puertas de dos qubits tras la transpilación de 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
... }

Los recuentos exactos dependen de cómo sintetiza Qiskit la puerta de evolución, lo cual cambia entre versiones, así que represéntalos gráficamente:

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.

El recuento sin proyectar crece linealmente con dd, porque cada una de las d−1d - 1 transiciones es una rotación de dos qubits. El recuento proyectado crece mucho más rápido, porque cada término actúa sobre los dd qubits. Las codificaciones compactas necesitan menos qubits, pero el peso de sus términos depende de cómo difieren los patrones de bits de los niveles vecinos: el código Gray, en el que difieren en un bit, es la más barata de las dos codificaciones compactas, y el binario estándar se dispara cuando un acarreo invierte varios bits a la vez (d=5d = 5). Que una codificación compacta supere también a la unaria sin proyectar depende de dd y de cómo sintetice Qiskit la evolución, así que lee la tendencia y no la clasificación en un solo tamaño.

Los proyectores también importan cuando expandes un operador mapeado en Paulis, lo que hace from_sparse_observable() sustituyendo cada proyector por una suma de dos operadores de Pauli. Un término con kk proyectores se convierte en 2k2^k términos de Pauli. Aquí tienes un único b†b^\dagger con dd pequeño, que es el único tamaño en el que deberías intentarlo:

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

Ambas variantes almacenan el mismo número de términos, pero la expansión de la proyectada crece exponencialmente en dd. Para cualquier cosa mayor que un ejemplo de juguete, lee directamente los términos del SparseObservable, como hace el listado anterior, en lugar de convertirlo.

Las codificaciones compactas no pueden prescindir de sus proyectores. En ellas los bits cero forman parte del número de nivel, así que quitar un ∣0⟩⟨0∣\lvert 0 \rangle\langle 0 \rvert cambia los niveles que conecta un término, y el operador deja de ser el mismo en las palabras de código. Por eso solo la codificación unaria tiene dos variantes.

Un producto de escaleras se trunca primero​

Otra decisión de diseño aparece en el coste de los términos diagonales. El mapeador no mapea cada operador de escalera y multiplica los resultados. Reúne los operadores de escalera que un término aplica a un modo, los multiplica como una matriz d×dd \times d en la base de Fock, trunca ese producto una sola vez y codifica el resultado [2]. Un producto como el operador número b†bb^\dagger b permanece, por tanto, diagonal:

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

Sus términos son los proyectores ∣1⟩⟨1∣\lvert 1 \rangle\langle 1 \rvert sobre el qubit de cada nivel por encima de cero, ponderados por el número de nivel (el nivel cero tiene peso cero y por tanto no tiene término). Su evolución temporal es un conjunto de rotaciones de un solo qubit y no necesita ninguna puerta de dos qubits. Multiplicar en cambio los dos operadores de escalera mapeados da el mismo operador en las palabras de código, pero uno distinto fuera de ellas, con cadenas no diagonales que cuestan puertas:

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

Los términos diagonales, como la interacción local del modelo de Bose-Hubbard, son estos productos, y ahí es donde importa esta decisión.

Escribe un mapeador personalizado​

Si quieres una codificación que la biblioteca no ofrece, constrúyela a partir de la imagen de un único operador de escalera. map_boson_action_generators() recibe una función que mapea una BosonAction, una función que devuelve la identidad, y construye con ellas el mapeador de operadores completos. Multiplica en orden las imágenes de las acciones de cada término, escala por el coeficiente y suma los términos. La salida puede ser de cualquier tipo que admita la multiplicación por escalares y la composición.

Como ejemplo, toma la codificación para d=2d = 2 que almacena un modo en un qubit. Un operador de creación es ∣1⟩⟨0∣\lvert 1 \rangle\langle 0 \rvert, que es (X−iY)/2(X - iY)/2 en la convención de Qiskit, y un operador de aniquilación es su adjunto:

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

Este mapeador escrito a mano multiplica los operadores de escalera mapeados, y coincide con el mapeador de la biblioteca aquí porque cada modo tiene dos niveles y un qubit contiene todo el modo. Con dd mayor, como mostró la sección anterior, componer las escaleras mapeadas y truncar primero el producto no son el mismo operador fuera de las palabras de código.

Para usar un mapeador así en un circuito, envuélvelo en una función con la firma (operator, num_qubits) -> SparseObservable y pásalo a MapperFnEvolutionSynthesis, que con él mapea el operador de cada puerta Evolution. El flujo del transpilador se describe en Transpilar circuitos bosónicos.

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

Conviene conocer dos detalles. Primero, el operador de qubits debe tener coeficientes reales, porque PauliEvolutionGate rechaza una parte imaginaria. Un operador hermítico se mapea a uno real, pero solo una vez fusionados los términos que se cancelan. Este mapeador escrito a mano no los fusiona, así que un término de salto y su adjunto dejan coeficientes imaginarios hasta que simplificas el resultado. El mapeador de la biblioteca fusiona su propio resultado; uno personalizado tiene que hacerlo, por ejemplo envolviéndolo en 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']

Segundo, una fórmula de producto sintetiza los términos en el orden en que los recibe, y el orden que produce un mapeador no está garantizado como estable. Las funciones auxiliares simplify() y group_wise() envuelven una función mapeadora para controlarlo: simplify fusiona las cadenas duplicadas y ordena los términos en un orden canónico, mientras que group_wise mapea un grupo del operador cada vez para que los términos de un grupo permanezcan adyacentes. No combines ambas, porque la ordenación descarta la agrupación. La guía de agrupación muestra cómo asignar grupos, y la guía de síntesis muestra cómo afecta el orden al circuito.

Próximos pasos​

[1]

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

[2] (1,2)

N. P. D. Sawaya, T. Menke, T. H. Kyaw, S. Johri, A. Aspuru-Guzik y 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.