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

Los mapeadores son rutinas que transforman operadores de una representación a otra preservando la equivalencia matemática. Úsalos para trabajar con flexibilidad entre distintas álgebras de operadores y para preparar operadores para su ejecución en circuitos cuánticos.

Implementar mapeadores personalizados​

El módulo de mapeadores facilita la implementación de mapeos personalizados. Los mapeadores funcionan transformando las acciones fundamentales (creación, aniquilación, Majorana, etc.) que componen un operador, y luego combinando esas acciones transformadas según las reglas de la representación de destino.

Como ejemplo concreto, considera una transformación de Jordan-Wigner que asigna un MajoranaOperator a un operador de qubits mediante map_majorana_action_generators():

>>> from qiskit.quantum_info import SparseObservable
>>> from qiskit_fermions.mappers import map_majorana_action_generators
>>> from qiskit_fermions.operators import MajoranaOperator
>>>
>>> # Create a Majorana operator
>>> maj_op = MajoranaOperator.from_dict({(0, 1): 1.0, (2,): 0.5})
>>>
>>> # Define how each Majorana action maps to Pauli strings
>>> num_qubits = 2
>>> def map_action(mode: int) -> SparseObservable:
... idx = mode // 2
... qubits = list(range(idx + 1))
... pauli = "Y" if mode % 2 else "X"
... return SparseObservable.from_sparse_list(
... [("Z" * idx + pauli, qubits, 1.0)],
... num_qubits=num_qubits,
... )
>>>
>>> # Apply the mapping to transform the operator
>>> custom_observable = map_majorana_action_generators(
... maj_op,
... map_action,
... identity=lambda: SparseObservable.identity(num_qubits),
... compose=SparseObservable.compose,
... )
>>> print(custom_observable.simplify())
<SparseObservable with 2 terms on 2 qubits: (0+1j)(Z_0) + (0.5+0j)(X_1 Z_0)>

Los pasos clave para implementar cualquier mapeador personalizado son:

  1. Define la transformación de acciones: especifica cómo cada acción (indexada por modo) se asigna a la representación de destino. En el ejemplo anterior, las acciones de Majorana se asignan a cadenas de Pauli según las reglas de Jordan-Wigner.

  2. Usa una de las funciones de mapeo proporcionadas: el módulo mappers ofrece funciones de utilidad que se encargan de iterar sobre los términos del operador y de aplicar después el mapeo de acciones personalizado a las representaciones de operadores que proporciona el módulo operators. Por ejemplo, map_majorana_action_generators() funciona con operadores de Majorana.

  3. Obtén el resultado: la función de mapeo devuelve un operador en la representación de destino con la transformación aplicada de forma coherente a todos los términos.

Este diseño facilita crear prototipos de mapeos personalizados sin conocer a fondo las estructuras internas de los operadores. La transformación se aplica automáticamente a cada término del operador y se conserva la equivalencia matemática.

Sugerencia

También puedes iterar manualmente sobre los términos de un operador en lugar de usar una de las funciones iteradoras proporcionadas. Consulta la sección sobre iteración de términos en la guía de operadores para más detalles.

Implementaciones de la biblioteca​

El módulo qiskit_fermions.mappers.library ofrece implementaciones eficientes y exhaustivamente probadas de los mapeos más comunes. Siguen el mismo patrón subyacente que los mapeadores personalizados, pero están optimizadas para uso en producción y están disponibles tanto en la API de Python como en la de C.

El siguiente ejemplo aplica un mapeador de la biblioteca al mismo MajoranaOperator de la sección de mapeadores personalizados y muestra resultados equivalentes:

>>> from qiskit_fermions.mappers.library import majorana_to_fermion, jordan_wigner
>>>
>>> # Convert MajoranaOperator to FermionOperator using library mapper
>>> ferm_op = majorana_to_fermion(maj_op)
>>>
>>> # Apply Jordan-Wigner mapper to get qubit operator
>>> sparse_observable = jordan_wigner(ferm_op, num_qubits=2)
>>> difference = sparse_observable - custom_observable
>>> print(difference.simplify() == SparseObservable.zero(num_qubits))
True
Nota

Los dos pasos anteriores se escriben explícitamente para mostrar cómo se componen dos mapeadores de la biblioteca. En la práctica, mapearías un MajoranaOperator en un solo paso, con majorana_jordan_wigner() (qf_maj_op_jordan_wigner() en la API de C), y lo mismo para los demás tipos de operadores: edge_vertex_jordan_wigner() y transfer_vertex_jordan_wigner() (qf_edge_op_jordan_wigner(), qf_transfer_op_jordan_wigner()). Pasar por un FermionOperator también infla el resultado intermedio: cada generador de estas álgebras se asigna a una única cadena de Pauli, mientras que cada acción fermiónica se asigna a una suma de dos términos, de modo que un término construido a partir de varios generadores se expande solo para volver a reducirse después. Ese ahorro crece con la longitud de los términos; para los términos de un solo generador de un hamiltoniano de salto, las dos rutas cuestan aproximadamente lo mismo.

En la API de Python, algunos de estos mapeadores de la biblioteca también son accesibles mediante mapeadores de conveniencia independientes del tipo. fermion_operator() y majorana_operator() actúan como fachada de majorana_to_fermion() y fermion_to_majorana() (entre otros), y delegan internamente en la implementación adecuada de la biblioteca según el tipo de objeto que reciben, en lugar de exigirte que elijas tú la función correcta. Lo mismo se aplica a jordan_wigner() y a ternary_tree() (que se presenta más abajo), cada una de las cuales despacha a la implementación directa que corresponda al operador recibido. Consulta qiskit_fermions.protocols para ver cómo se implementa internamente este despacho.

Elige una codificación con árboles ternarios​

Jordan-Wigner no es la única codificación de fermiones a qubits, y tampoco es la más económica. Sus cadenas de ZZ crecen con el índice del modo, así que un operador de Majorana sobre el último de nn modos se asigna a una cadena de Pauli de peso nn. Las codificaciones que reparten la información de paridad a lo largo de un árbol en lugar de una cadena lo hacen asintóticamente mejor.

Un árbol ternario de nn nodos recoge toda esta familia. Cada nodo es un qubit con tres enlaces descendentes etiquetados X, Y y Z; un enlace o bien desciende a un nodo hijo (una arista) o bien termina (una hoja). El camino de cada hoja hasta la raíz forma una cadena de Pauli: cruzar el enlace etiquetado PP que desciende desde el nodo uu aporta PP sobre el qubit uu. Todo árbol de este tipo produce una codificación válida, y las más conocidas son simplemente formas concretas: una cadena a lo largo de Z es Jordan-Wigner, mientras que un árbol en el que cada nodo tiene tres hijos, hasta donde lo permita el número de nodos, es equilibrado.

>>> from qiskit_fermions.mappers import TernaryTree, TernaryTreeEncoding
>>>
>>> # Jordan-Wigner is a chain descending along Z, with X and Y as legs at every node.
>>> TernaryTree.chain(13, "Z").max_weight
13
>>> # The balanced tree spreads the same information over depth log_3(2n+1).
>>> TernaryTree.breadth_first(13, 3).max_weight
3

Ese peso máximo es óptimo. Jiang et al. demuestran que los operadores de Majorana de cualquier mapeo de fermiones a qubits de nn modos tienen un peso de Pauli medio de al menos log⁡3(2n)\log_3(2n), de modo que ningún mapeo puede reducir el mayor de ellos por debajo de ⌈log⁡3(2n+1)⌉\lceil \log_3(2n+1) \rceil. Ambos árboles usan exactamente nn qubits, así que el ahorro está en el peso de Pauli, no en el número de qubits.

Para mapear un operador, compila un árbol en una codificación y pásala a un mapeador junto con el número de qubits del resultado. Al compilar, se emparejan las hojas del árbol en operadores de Majorana y se construyen sus cadenas de Pauli una sola vez, por lo que conviene reutilizar la misma codificación para muchos operadores. El número de qubits se indica en la llamada de mapeo y no en la codificación, de modo que una misma codificación puede servir para registros de distinto ancho; un número superior al de nodos del árbol se rellena con la identidad.

>>> from qiskit_fermions.mappers.library import fermion_ternary_tree
>>> from qiskit_fermions.operators import FermionOperator
>>>
>>> hamiltonian = FermionOperator.from_dict(
... {((True, 0), (False, 3)): 1.0, ((True, 3), (False, 0)): 1.0}
... )
>>> encoding = TernaryTreeEncoding(TernaryTree.breadth_first(4, 3))
>>> fermion_ternary_tree(hamiltonian, encoding, 4).simplify()
<SparseObservable with 2 terms on 4 qubits: (0.5+0j)(Y_3 Z_1 Y_0) + (0.5+0j)(X_3 Z_2 X_0)>

Mapear a través de una cadena a lo largo de Z reproduce exactamente fermion_jordan_wigner() (qf_ferm_op_jordan_wigner() en la API de C), que es el sentido en que esto la generaliza:

>>> from qiskit_fermions.mappers.library import fermion_jordan_wigner
>>> chain = TernaryTreeEncoding(TernaryTree.chain(4, "Z"))
>>> direct = fermion_jordan_wigner(hamiltonian, 4)
>>> (fermion_ternary_tree(hamiltonian, chain, 4) - direct).simplify().num_terms
0

Cada representación de operador tiene un mapeador de árbol ternario, que refleja el conjunto de Jordan-Wigner; consulta qiskit_fermions.mappers.library para ver la lista completa, y Mapper Library en la API de C. En la API de Python también son accesibles mediante ternary_tree(), el envoltorio independiente del tipo que elige el adecuado para el operador recibido, igual que hace jordan_wigner():

>>> from qiskit_fermions.mappers.library import ternary_tree
>>> ternary_tree(hamiltonian, encoding, 4).simplify()
<SparseObservable with 2 terms on 4 qubits: (0.5+0j)(Y_3 Z_1 Y_0) + (0.5+0j)(X_3 Z_2 X_0)>
Nota

Algunas identidades que se cumplen bajo Jordan-Wigner son propiedades de la cadena y no de los árboles ternarios. El operador número es nj=(1−Zj)/2n_j = (1 - Z_j)/2 solo en la cadena; en otro árbol, ZjZ_j pasa a ser una cadena de ZZ sobre un conjunto de qubits. Del mismo modo, un operador de vértice VjV_j es el Pauli de peso 1 ZjZ_j solo en la cadena, donde las cadenas de ZZ de las dos cadenas de Majorana se cancelan.

Como cada operador número sigue asignándose a una única cadena de ZZ, un estado de Fock sigue siendo un estado de la base computacional bajo cualquier árbol ternario, pero el bit qq ya no indica la ocupación qq. La ocupación jj es, en cambio, la paridad de los bits medidos en el soporte de la imagen de njn_j. Lee esos soportes del mapeador en lugar de deducirlos del árbol, de modo que la decodificación siga siendo correcta para cualquier forma o permutación de modos:

>>> for mode in range(4):
... number = FermionOperator.from_dict({((True, mode), (False, mode)): 1.0})
... print(mode, ternary_tree(number, encoding, 4).simplify())
0 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_2 Z_1 Z_0)>
1 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_1)>
2 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_2)>
3 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_3)>

En este árbol equilibrado, el modo 0 está ocupado cuando los qubits 0, 1 y 2 contienen un número impar de unos, así que prepararlo significa invertir solo el qubit 0, mientras que decodificar una cadena de bits medida requiere la paridad de los tres. Un único error de inversión de bit en el qubit 1 corrompe entonces las ocupaciones tanto del modo 0 como del modo 1, lo cual importa en cualquier flujo de trabajo que lea ocupaciones a partir de muestras ruidosas.

Cuando una Evolution se reduce a través de un mapeador de este tipo, envuelve el mapeador en group_wise() (consulta Sintetizar una puerta Evolution en el espacio fermiónico). Un término de salto se asigna a dos cadenas de Pauli que conmutan, ninguna de las cuales conserva por sí sola el número de partículas; solo lo hace su producto en la evolución. Bajo Jordan-Wigner ambas cadenas actúan sobre los mismos qubits, así que un orden de términos simplificado las mantiene adyacentes. Bajo otros árboles sus soportes difieren (como en Y_3 Z_1 Y_0 y X_3 Z_2 X_0 anteriormente), tanto el orden original como el simplificado las intercalan con las imágenes de otros términos, y una fórmula de producto del lado de los qubits provoca entonces una fuga del número de partículas.

Construye un árbol arbitrario​

Los constructores de cadena y de anchura primero cubren las familias uniformes, pero la interfaz general toma un par (parent, label) por nodo, con None para la raíz (la API de C toma en su lugar el índice de la raíz). Esto importa porque los árboles que merece la pena buscar no suelen ser ni una cadena ni uniformes: el algoritmo Bonsai, por ejemplo, hace crecer un árbol de expansión del grafo de acoplamiento de un dispositivo, de modo que sus ramificaciones siguen esa conectividad.

>>> # Node 0 branches three ways, node 1 twice, and node 4 carries a single child.
>>> tree = TernaryTree([None, (0, "X"), (0, "Y"), (0, "Z"), (1, "X"), (1, "Z"), (4, "Y")])
>>> tree.num_nodes, tree.num_legs, tree.max_weight
(7, 15, 4)
>>> tree.children(0)
{'X': 1, 'Y': 2, 'Z': 3}

Fíjate en el número de hojas: siempre es 2n+12n+1, sea cual sea la forma. Un nodo con menos hijos no genera menos cadenas de Pauli: sus enlaces sin usar son hojas, y una hoja forma una cadena igual que lo hacen los descendientes de una arista. De esas 2n+12n+1 cadenas, 2n2n se emparejan en los operadores de Majorana de los nn modos, y queda exactamente una sobrante.

Paridad total​

Esa hoja sobrante es la paridad fermiónica total ∏j(1−2nj)\prod_j (1 - 2 n_j). Es una propiedad de la codificación y no un mapeo de ningún operador, por lo que es un método de la propia codificación:

>>> TernaryTreeEncoding(TernaryTree.chain(4, "Z")).total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3 Z_2 Z_1 Z_0)>
>>> TernaryTreeEncoding(TernaryTree.breadth_first(4, 3)).total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3 Z_0)>

Como muestra el caso equilibrado, esta no es en general la cadena de todo ZZ: es la hoja a la que se llega por el camino de todo ZZ, que actúa como la identidad en todos los nodos que no están en ese camino.

Importante

La paridad total aquí es un observable que medir, no una restricción que imponer. Ambos autovalores son físicos, así que no proyectes un estado sobre el sector +1+1: una codificación de árbol ternario gasta un qubit por modo y es sobreyectiva sobre todo el espacio de dimensión 2n2^n, sin dejar ningún subespacio no físico que descartar mediante proyección.

Ejemplo práctico: un árbol de Bravyi-Kitaev​

Las especificaciones suelen calcularse en lugar de escribirse a mano, ya que el árbol de una codificación real rara vez es lo bastante pequeño como para escribirlo a mano. Bravyi-Kitaev es un buen ejemplo: pertenece a esta familia, pero su árbol no es uniforme, así que ningún constructor de conveniencia lo cubre.

Su árbol se construye mediante una división recursiva sobre los nodos, uno por modo. Dentro de un rango de nodos [l,r][l, r], sea hh la mayor potencia de dos que cabe; el nodo colocado para ese rango es l+h−1l + h - 1, el último de ese bloque. Su enlace X desciende al árbol construido a partir del bloque anterior y su enlace Z al árbol construido con el resto posterior, dejando su enlace Y como hoja:

>>> def bravyi_kitaev_tree(num_modes: int) -> TernaryTree:
... """Builds the ternary tree of the Bravyi-Kitaev encoding on ``num_modes`` modes."""
... spec: list[tuple[int, str] | None] = [None] * num_modes
... def build(left: int, right: int) -> int | None:
... if left > right:
... return None # an empty range: the parent's link stays a leg
... block = 1
... while 2 * block <= right - left + 1:
... block *= 2
... node = left + block - 1
... for child, label in (
... (build(left, node - 1), "X"), # the block before this node
... (build(node + 1, right), "Z"), # the remainder after it
... ):
... if child is not None:
... spec[child] = (node, label)
... return node
... build(0, num_modes - 1)
... return TernaryTree(spec)

Con cuatro modos, todo el rango es un solo bloque, así que el primer nodo colocado es el 3, con [0,2][0, 2] antes y nada después; ese subrango da el nodo 1 sobre los nodos individuales 0 y 2. Compilar el árbol confirma que la codificación es Bravyi-Kitaev y no meramente un árbol de la misma clase de forma: su primera imagen de Majorana es X0X1X3X_0 X_1 X_3:

>>> tree = bravyi_kitaev_tree(4)
>>> tree.children(1)
{'X': 0, 'Z': 2}
>>> encoding = TernaryTreeEncoding(tree)
>>> encoding.majorana_image(0)
[(0, 'X'), (1, 'X'), (3, 'X')]

Su paridad total concreta lo dicho en la sección anterior: la hoja sobrante es la que se alcanza por el camino de todo ZZ, y aquí ese camino termina en la raíz, por lo que el observable es el Z3Z_3 de un solo qubit y no una cadena sobre los cuatro qubits:

>>> encoding.total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3)>

Escalar no requiere más que un argumento mayor. Como ningún nodo gasta nunca su enlace Y en una arista, cada uno se ramifica como máximo en dos direcciones, de modo que el peso de Pauli crece de forma logarítmica y no lineal:

>>> [bravyi_kitaev_tree(n).max_weight for n in (4, 8, 16, 32, 64)]
[3, 4, 5, 6, 7]