Principios de diseño de las representaciones de operadores
Esta guía explica cómo se organiza un BosonOperator y cómo trabajar con él: cómo construir uno, cómo inspeccionar y combinar operadores, y cómo llevar dos operadores a una forma en la que se puedan comparar. Empieza aquí cuando escribas un hamiltoniano.
Un BosonOperator es una suma exacta de productos de operadores bosónicos de creación y aniquilación con coeficientes complejos. No lleva ningún corte de ocupación. Un modo bosónico admite un número arbitrario de bosones, por lo que su espacio de Fock es de dimensión infinita, y el truncamiento solo interviene cuando el operador se asigna a qubits (consulta Truncar el espacio de Fock). Por tanto, todo lo de esta guía ocurre en el álgebra exacta de las relaciones canónicas de conmutación,
Dos consecuencias configuran la interfaz. Un índice de modo puede repetirse dentro de un mismo término, porque es un operador válido que crea dos bosones. Además, reordenar dos factores nunca introduce un signo, aunque mover un operador de aniquilación más allá de un operador de creación sobre el mismo modo añade un término, .
Almacenamiento
Un operador guarda sus términos en cuatro arreglos planos, de forma similar al formato de fila dispersa comprimida de una matriz dispersa:
coeffs | Un coeficiente complejo por término. |
actions | Un booleano por factor: True para un operador de creación y False para uno de aniquilación. |
modes | Un índice de modo por factor. |
boundaries | Las posiciones en actions y modes donde empieza y termina cada término. |
El constructor recibe directamente estos cuatro arreglos. El operador de abajo tiene tres términos, a saber , y la constante , y puedes leer los arreglos de vuelta con los métodos get_*:
>>> from qiskit_bosons.operators import BosonOperator, ann, cre
>>> op = BosonOperator(
... coeffs=[2.0, -1.0j, 0.5],
... actions=[True, False, True, True],
... modes=[0, 1, 1, 1],
... boundaries=[0, 2, 4, 4],
... )
>>> op.get_coeffs()
[(2+0j), (-0-1j), (0.5+0j)]
>>> op.get_actions()
[True, False, True, True]
>>> op.get_modes()
[0, 1, 1, 1]
>>> op.get_boundaries()
[0, 2, 4, 4]
>>> len(op)
3
El primer término abarca las posiciones 0 a 2 de actions y modes, el segundo de 2 a 4 y el tercero de 4 a 4, que es como se almacena el término constante, un término sin factores. El modo repetido 1 del segundo término se conserva tal como está escrito; un término nunca se descarta ni se fusiona porque un índice de modo aparezca dos veces.
Un operador construido con from_dict() no almacena sus términos en el orden del diccionario, por lo que sus arreglos pueden enumerar los términos en un orden distinto de una ejecución a otra. El operador en sí es el mismo, y nada de lo que sigue que compare o imprima operadores depende del orden almacenado.
La disposición hace que la construcción y la aritmética sean baratas, con una consecuencia: un operador puede contener el mismo término varias veces a la vez. Las sumas y los productos añaden términos y no los fusionan, así que llama a simplify() cuando necesites términos únicos (consulta Hacer aritmética).
Construir operadores
La forma más directa de construir un operador es from_dict(), que recibe un diccionario de una tupla de factores a un coeficiente. Las funciones cre() y ann() crean los factores, y la tupla vacía es el término constante:
>>> b = BosonOperator.from_dict({(ann(0),): 1.0})
>>> b_dag = BosonOperator.from_dict({(cre(0),): 1.0})
>>> number = BosonOperator.from_dict({(cre(0), ann(0)): 1.0})
>>> print(format(number))
1.000000e0 +0.000000e0j * (+0 -0)
En la forma impresa, +0 representa y -0 representa . Los constructores zero() y one() devuelven la identidad aditiva y la multiplicativa, y from_terms() reconstruye un operador a partir del iterador de iter_terms().
La biblioteca de operadores proporciona las cuadraturas adimensionales de posición y momento,
como position() y momentum(). Junto con las funciones de conmutador del mismo módulo, puedes comprobar el álgebra directamente:
>>> from qiskit_bosons.operators.library import commutator, momentum, position
>>> q, p = position(0), momentum(0)
>>> q.is_hermitian() and p.is_hermitian()
True
>>> commutator(q, p).equiv(1j * BosonOperator.one())
True
>>> commutator(b, b_dag).equiv(BosonOperator.one())
True
El módulo también proporciona anti_commutator() y double_commutator(). Las tres reciben operadores del mismo tipo y devuelven un operador nuevo.
Ojo con el orden de un producto
Dos operadores se pueden multiplicar de dos maneras, y es fácil confundirlas. El operador @ es el producto matemático, así que a @ b es . El operador & es la composición, que sigue la convención de Qiskit de leer de izquierda a derecha: a & b aplica primero a y luego b, así que es . Las funciones de conmutador usan @, por lo que commutator(a, b) es . Construir tú mismo el corchete con & lo niega silenciosamente:
>>> (b @ b_dag).equiv(b_dag @ b + BosonOperator.one())
True
>>> (b & b_dag).equiv(b_dag @ b)
True
>>> ((b @ b_dag) - (b_dag @ b)).equiv(BosonOperator.one())
True
>>> ((b & b_dag) - (b_dag & b)).equiv(-BosonOperator.one())
True
Inspeccionar un operador
Un operador no es iterable directamente, porque sus términos viven en los arreglos planos de arriba. Usa en su lugar iter_terms(), que produce para cada término una lista de pares (is_creation, mode) y un coeficiente. El iterador devuelve copias, así que modificarlas no cambia el operador.
>>> hopping = BosonOperator.from_dict(
... {
... (cre(0), ann(1)): -1.0,
... (cre(1), ann(0)): -1.0,
... (cre(2), cre(2), ann(2), ann(2)): 0.5,
... }
... )
>>> len(hopping)
3
>>> sorted(hopping.iter_terms())
[([(True, 0), (False, 1)], (-1+0j)), ([(True, 1), (False, 0)], (-1+0j)), ([(True, 2), (True, 2), (False, 2), (False, 2)], (0.5+0j))]
Varios métodos resumen la estructura de un operador:
>>> hopping.get_support()
{0, 1, 2}
>>> hopping.min_num_modes()
3
>>> hopping.max_rank()
4
get_support() es el conjunto de modos sobre los que actúa el operador, min_num_modes() es el registro más pequeño que puede contenerlo, y max_rank() es la longitud de su término más largo.
Un modo es un índice abstracto que etiqueta un grado de libertad bosónico. No tiene significado propio: tú determinas qué sitio de una red, qué coordenada vibracional o qué puerto óptico representa un modo. El BosonicCircuit usa los mismos índices (consulta Trabajar con circuitos bosónicos), de modo que un operador y las puertas que lo llevan coinciden en qué es el modo 2 sin que ninguno sepa lo que representa. Nada en el operador fija cuántos niveles tiene un modo, que es también la razón por la que el tamaño del registro es solo una cota inferior; min_num_modes() es el registro más pequeño que contiene el operador, no una propiedad de este.
Los métodos de ocupación indican cuántos bosones puede colocar un solo término en un modo. Esto es una cota inferior del corte que necesita una asignación. No es un corte suficiente, porque las aplicaciones repetidas del operador, como en una evolución temporal, pueden poblar niveles más altos (consulta Truncar el espacio de Fock):
>>> hopping.mode_occupations()
[1, 1, 2]
>>> hopping.mode_occupations([2, 0])
[2, 1]
>>> hopping.max_occupation()
2
Otras dos comprobaciones examinan cantidades conservadas. Un operador conserva el número de partículas cuando cada término tiene tantos operadores de creación como de aniquilación. Pasar una lista de tamaños de bloque comprueba la misma condición dentro de cada bloque de modos consecutivos:
>>> hopping.conserves_particle_number()
True
>>> move = BosonOperator.from_dict({(cre(0), ann(2)): 1.0})
>>> move.conserves_sector([3])
True
>>> move.conserves_sector([2, 1])
False
Por último, relabel_modes() cambia el nombre de los modos: el modo i pasa a ser permutation[i]. La lista debe ser lo bastante larga para indexar todos los modos del operador y no debe repetir ninguna entrada:
>>> print(format(move.relabel_modes([5, 6, 7])))
1.000000e0 +0.000000e0j * (+5 -7)
Hacer aritmética
Los operadores admiten suma, resta, negación, y multiplicación y división por un número, en la forma habitual de operador o in situ:
>>> total = 2 * b_dag + b_dag - 3j * b
>>> len(total)
3
>>> len(total.simplify())
2
>>> total.simplify().equiv(3 * b_dag - 3j * b)
True
Los tres términos de total nunca se fusionaron, así que el operador contenía b_dag dos veces hasta que simplify() los combinó. El método suma los coeficientes de términos idénticos y descarta aquellos cuya magnitud total está por debajo de una tolerancia. Es una operación estructural: compara los factores tal como están escritos y no aplica las relaciones de conmutación. El método relacionado ichop() elimina los coeficientes pequeños in situ sin fusionar antes, así que llama a simplify() antes de ichop() cuando un operador pueda contener términos duplicados.
Los productos usan @ o & como se describió arriba, y ** eleva un operador a una potencia entera:
>>> x = b_dag + b
>>> print(format((x ** 2).simplify()))
1.000000e0 +0.000000e0j * (-0 -0)
1.000000e0 +0.000000e0j * (-0 +0)
1.000000e0 +0.000000e0j * (+0 -0)
1.000000e0 +0.000000e0j * (+0 +0)
El resultado enumera los cuatro ordenamientos tal como están escritos. Reducirlos a una sola forma requiere las relaciones de conmutación, que aplica la siguiente sección.
El adjunto invierte el orden de los factores de cada término, intercambia los operadores de creación y aniquilación, y conjuga los coeficientes. El método is_hermitian() comprueba si un operador es igual a su adjunto:
>>> hop = BosonOperator.from_dict({(cre(0), ann(1)): 1j})
>>> print(format(hop.adjoint()))
0.000000e0 -1.000000e0j * (+1 -0)
>>> hop.is_hermitian()
False
>>> (hop + hop.adjoint()).is_hermitian()
True
Un término de salto aislado no es hermítico, lo cual importa cuando agrupas términos para una evolución temporal: cada grupo debe ser hermítico por sí solo (consulta Agrupar términos de operadores: usar la estructura del operador).
Orden normal y comparación de operadores
Dos productos de operadores de escalera pueden ser el mismo operador y aun así verse distintos, porque y son iguales en el álgebra. Los operadores que son iguales en este sentido no tienen por qué almacenarse igual, así que compararlos requiere las relaciones de conmutación. El método normal_ordered() las aplica: mueve cada operador de creación a la izquierda de cada operador de aniquilación y añade un término cada vez que se intercambia un par sobre el mismo modo.
>>> product = BosonOperator.from_dict({(ann(0), cre(0), ann(0), cre(0)): 1.0})
>>> ordered = product.normal_ordered()
>>> len(ordered)
5
>>> print(format(ordered.simplify()))
1.000000e0 +0.000000e0j * ()
3.000000e0 +0.000000e0j * (+0 -0)
1.000000e0 +0.000000e0j * (+0 +0 -0 -0)
El operador ordenado se devuelve como una suma sin fusionar, con cinco términos almacenados que se reducen a tres después de que simplify() los fusione. El orden normal nunca hace desaparecer un término: y cualquier otro factor repetido permanecen.
El argumento sandwich establece cómo se disponen los modos dentro de los dos grupos de factores. De forma predeterminada, ambos grupos se ordenan de forma descendente. Con sandwich=True, los operadores de creación quedan en orden ascendente y los de aniquilación en orden descendente, lo que coloca los índices mayores hacia el centro:
>>> pair = BosonOperator.from_dict({(cre(0), cre(1), ann(0), ann(1)): 1.0})
>>> list(pair.normal_ordered().iter_terms())
[([(True, 1), (True, 0), (False, 1), (False, 0)], (1+0j))]
>>> list(pair.normal_ordered(sandwich=True).iter_terms())
[([(True, 0), (True, 1), (False, 1), (False, 0)], (1+0j))]
El operador == comprueba la igualdad exacta de los términos almacenados, sin tolerancia ni simplificación, por lo que puede indicar una diferencia entre dos operadores que son matemáticamente iguales. El método equiv() lleva primero la diferencia de los dos operadores a orden normal y luego comprueba que cada coeficiente esté por debajo de una tolerancia. Es la comparación que casi siempre quieres:
>>> reordered = BosonOperator.from_dict({(cre(0), ann(0)): 1.0, (): 1.0})
>>> contracted = BosonOperator.from_dict({(ann(0), cre(0)): 1.0})
>>> contracted == reordered
False
>>> contracted.simplify() == reordered.simplify()
False
>>> contracted.equiv(reordered)
True
El método simplify por sí solo deja las dos formas separadas, porque solo fusiona términos escritos de forma idéntica. Del mismo modo, is_hermitian() compara un operador con su adjunto en orden normal, así que reconoce como hermítico sin más trabajo.
El orden normal es una identidad exacta en el álgebra sin truncar, y equiv se apoya en ello. No es una identidad después de un truncamiento. Un operador asignado depende de la forma que asignes, así que las imágenes asignadas de contracted y reordered de arriba difieren, solo en el nivel más alto retenido. Consulta Truncar el espacio de Fock para ver el tamaño de la diferencia y cómo elegir una forma.
Ordenar y filtrar términos
Reordenar los términos de un operador, a diferencia de los factores dentro de un término, no cambia el operador pero puede cambiar el comportamiento de los algoritmos que leen los términos en orden, como una fórmula de producto aleatorizada. Las funciones de qiskit_bosons.operators.terms devuelven un operador nuevo y mantienen cualquier índice de grupo asociado a sus términos.
canonical_order() ordena los términos en un orden fijo que depende solo de los factores escritos, de modo que un operador se lee igual sin importar cómo se haya ensamblado. order_terms() ordena según una clave que tú proporcionas, la cual recibe cada término como la tupla que produce iter_terms():
>>> from qiskit_bosons.operators.terms import canonical_order, order_terms
>>> mixed = BosonOperator.from_dict({(cre(1), ann(0)): 1.0, (cre(0), ann(1)): 3.0, (): 2.0})
>>> list(canonical_order(mixed).iter_terms())
[([], (2+0j)), ([(True, 0), (False, 1)], (3+0j)), ([(True, 1), (False, 0)], (1+0j))]
>>> by_weight = order_terms(mixed, key=lambda term: abs(term[1]), reverse=True)
>>> [coeff for _, coeff in by_weight.iter_terms()]
[(3+0j), (2+0j), (1+0j)]
Si un operador lleva índices de grupo (consulta Agrupar términos de operadores: usar la estructura del operador), la función group_order() hace que cada grupo sea una secuencia contigua de términos:
>>> from qiskit_bosons.operators.terms import group_order
>>> grouped = BosonOperator.from_dict({(cre(1), ann(0)): 1.0, (cre(0), ann(1)): 3.0, (): 2.0})
>>> grouped.groups = [1, 0, 1]
>>> group_order(grouped).groups
[0, 1, 1]
El último auxiliar elimina términos en lugar de reordenarlos. Un término que es un producto de operadores de número, , solo multiplica cada estado de Fock por una fase, así que filter_diagonal_terms() puede descartar la constante, los operadores de número y sus productos cuando esa fase es irrelevante para lo que calculas. Cambia el operador in situ y requiere un operador en orden normal, lo cual no comprueba:
>>> from qiskit_bosons.operators.terms import filter_diagonal_terms
>>> model = BosonOperator.from_dict(
... {
... (cre(0), ann(1)): -1.0,
... (cre(1), ann(0)): -1.0,
... (cre(0), cre(0), ann(0), ann(0)): 0.5,
... (cre(0), ann(0)): 0.2,
... (): 0.1,
... }
... )
>>> filter_diagonal_terms(model)
>>> len(model)
2
>>> model.is_hermitian()
True
Solo quedan los dos términos de salto. La interacción en el sitio y el operador de número son diagonales en la base de ocupación, por lo que se eliminan junto con la constante. El modo repetido en la interacción se gestiona correctamente: es diagonal, mientras que no lo sería.
Siguientes pasos
-
Agrupar términos de operadores: usar la estructura del operador muestra cómo etiquetar términos con índices de grupo.
-
Asignar representaciones de operadores convierte un operador en un operador de qubits, incluida la elección de la codificación y el efecto de un truncamiento.
-
Trabajar con circuitos bosónicos coloca un operador en un circuito como una puerta
Evolution. -
Simular la dinámica de Bose-Hubbard con una elección de codificación construye el hamiltoniano de una cadena de Bose-Hubbard y la simula de principio a fin.
-
Los módulos
qiskit_bosons.operatorsyqiskit_bosons.operators.termsdocumentan la interfaz completa.