Mapear representações de operadores
Mappers são rotinas que transformam operadores de uma representação para outra preservando a equivalência matemática. Use-os para trabalhar com flexibilidade entre diferentes álgebras de operadores e para preparar operadores para a execução de circuitos quânticos.
Implemente mappers personalizados
O módulo de mappers torna simples implementar mapeamentos personalizados. Os mappers funcionam transformando as ações fundamentais (criação, aniquilação, Majorana e assim por diante) que compõem um operador, e então combinando essas ações transformadas de acordo com as regras da representação de destino.
Como exemplo concreto, considere uma transformação de Jordan-Wigner que mapeia um MajoranaOperator para um operador de qubits usando map_majorana_action_generators():
- PYTHON
- C
>>> 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)>
// The C API provides direct mapping functions for common transformations.
// Custom mapper prototyping as shown in Python is primarily a Python feature.
// For custom mappings in C, you will need to perform the iteration and
// conversion logic entirely by yourself.
As etapas principais para implementar qualquer mapeador personalizado são:
-
Defina a transformação da ação: especifique como cada ação (indexada por modo) é mapeada para a representação de destino. No exemplo acima, as ações de Majorana são mapeadas para strings de Pauli segundo as regras de Jordan-Wigner.
-
Use uma das funções de mapeamento fornecidas: o módulo
mappersoferece funções utilitárias para iterar sobre os termos do operador e aplicar em seguida o mapa de ações personalizado às representações de operadores fornecidas pelo módulooperators. Por exemplo,map_majorana_action_generators()funciona com operadores de Majorana. -
Obtenha o resultado: a função de mapeamento retorna um operador na representação de destino, com a transformação aplicada de forma consistente a todos os termos.
Esse design facilita a prototipagem de mapeamentos personalizados sem exigir conhecimento profundo das estruturas internas dos operadores. A transformação é aplicada automaticamente a cada termo do operador, preservando a equivalência matemática.
Você também pode iterar manualmente sobre os termos de um operador, em vez de usar uma das funções iteradoras fornecidas. Consulte a seção sobre iteração de termos no guia de operadores para mais detalhes.
Implementações da biblioteca
O módulo qiskit_fermions.mappers.library oferece implementações eficientes e amplamente testadas de mapeamentos comuns. Elas seguem o mesmo padrão básico dos mapeadores personalizados, mas são otimizadas para uso em produção e estão disponíveis nas APIs Python e C.
O exemplo abaixo aplica um mapeador da biblioteca ao mesmo MajoranaOperator da seção de mapeador personalizado, demonstrando resultados equivalentes:
- PYTHON
- C
>>> 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
#include <qiskit_fermions.h>
// Create a MajoranaOperator with the same terms as maj_op above
uint64_t num_terms = 2;
uint64_t num_modes = 3;
uint32_t modes[3] = {0, 1, 2};
QkComplex64 coeffs[2] = {{1.0, 0.0}, {0.5, 0.0}};
uint32_t boundaries[3] = {0, 2, 3};
QfMajoranaOperator *maj_op = qf_maj_op_new(num_terms, num_modes, coeffs, modes, boundaries);
// Convert MajoranaOperator to FermionOperator using library mapper
QfFermionOperator *ferm_op = qf_majorana_to_fermion(maj_op);
// Apply Jordan-Wigner mapper to get qubit operator
QkObs *qubit_op;
QfExitCode exit = qf_ferm_op_jordan_wigner(ferm_op, 2, &qubit_op);
assert(exit == QfExitCode_Success);
// Clean up
qf_ferm_op_free(ferm_op);
qf_maj_op_free(maj_op);
qk_obs_free(qubit_op);
Os dois passos acima foram escritos por extenso para mostrar dois mapeadores da biblioteca sendo compostos. Na prática, você mapearia um MajoranaOperator em um único passo, com majorana_jordan_wigner() (qf_maj_op_jordan_wigner() na API C), e da mesma forma para os outros tipos de operador – edge_vertex_jordan_wigner() e transfer_vertex_jordan_wigner() (qf_edge_op_jordan_wigner(), qf_transfer_op_jordan_wigner()). Passar por um FermionOperator também infla o resultado intermediário: cada gerador dessas álgebras é mapeado para uma única string de Pauli, enquanto cada ação fermiônica é mapeada para uma soma de dois termos, de modo que um termo construído a partir de vários geradores se expande apenas para depois ser recombinado. Essa economia cresce com o comprimento dos termos; para os termos de gerador único de um hamiltoniano de salto (hopping), as duas rotas custam aproximadamente o mesmo.
Na API Python, alguns desses mapeadores da biblioteca também podem ser acessados por meio de mapeadores de conveniência agnósticos quanto ao tipo. fermion_operator() e majorana_operator() ficam na frente de majorana_to_fermion() e fermion_to_majorana() (entre outros), delegando internamente à implementação apropriada da biblioteca com base no tipo do objeto recebido, em vez de exigir que você escolha a função certa. O mesmo vale para jordan_wigner() e para ternary_tree() (apresentado abaixo), cada um dos quais despacha para a implementação direta que corresponde ao operador recebido. Consulte qiskit_fermions.protocols para ver como esse despacho é implementado internamente.
Escolha uma codificação com árvores ternárias
Jordan-Wigner não é a única codificação de férmions para qubits, e também não é a mais barata. Suas strings crescem com o índice do modo, de modo que um operador de Majorana no último de modos é mapeado para uma string de Pauli de peso . Codificações que distribuem a informação de paridade por uma árvore, em vez de uma cadeia, têm desempenho assintoticamente melhor.
Uma árvore ternária com nós captura toda essa família. Cada nó é um qubit com três ligações descendentes rotuladas X, Y e Z; uma ligação ou desce para um nó filho (uma aresta) ou termina (uma folha). O caminho de cada folha de volta até a raiz soletra uma string de Pauli: percorrer a ligação rotulada que desce do nó contribui com no qubit . Toda árvore desse tipo produz uma codificação válida, e as mais conhecidas são simplesmente formatos particulares – uma cadeia ao longo de Z é Jordan-Wigner, enquanto uma árvore em que cada nó tem três filhos, na medida em que a contagem de nós permitir, é balanceada.
- PYTHON
- C
>>> 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
#include <qiskit_fermions.h>
QfTernaryTree *chain;
qf_ternary_tree_chain(13, QfPauliLabel_Z, &chain);
QfPauliLabel order[3] = {QfPauliLabel_X, QfPauliLabel_Y, QfPauliLabel_Z};
QfTernaryTree *balanced;
qf_ternary_tree_breadth_first(13, 3, order, &balanced);
assert(qf_ternary_tree_max_weight(chain) == 13);
assert(qf_ternary_tree_max_weight(balanced) == 3);
qf_ternary_tree_free(chain);
qf_ternary_tree_free(balanced);
Esse peso máximo é ótimo. Jiang et al. provam que os operadores de Majorana de qualquer mapeamento de férmions para qubits de modos têm peso de Pauli médio de pelo menos , de modo que nenhum mapeamento consegue reduzir o maior deles abaixo de . Ambas as árvores usam exatamente qubits, então a economia está no peso de Pauli, não na quantidade de qubits.
Para mapear um operador, compile uma árvore em uma codificação e passe-a a um mapeador junto com a quantidade de qubits do resultado. A compilação emparelha as folhas da árvore em operadores de Majorana e constrói suas strings de Pauli uma única vez, de modo que a mesma codificação deve ser reutilizada em vários operadores. A quantidade de qubits fica na chamada de mapeamento, e não na codificação, de modo que uma codificação pode servir a registradores de larguras diferentes; uma quantidade acima da de nós da árvore é completada com a identidade.
- PYTHON
- C
>>> 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)>
// Create the hopping Hamiltonian a^dagger_0 a_3 + a^dagger_3 a_0
QfFermionOperator *hamiltonian = qf_ferm_op_zero();
QkComplex64 coeffs[2] = {{1.0, 0.0}, {1.0, 0.0}};
bool actions[4] = {true, false, true, false};
uint32_t indices[4] = {0, 3, 3, 0};
for (int i = 0; i < 2; i++) {
qf_ferm_op_add_term(hamiltonian, 2, actions + 2 * i, indices + 2 * i, &coeffs[i]);
}
QfPauliLabel order[3] = {QfPauliLabel_X, QfPauliLabel_Y, QfPauliLabel_Z};
QfTernaryTree *tree;
qf_ternary_tree_breadth_first(4, 3, order, &tree);
// Compile the tree once, then reuse the encoding for every operator mapped through it.
QfTernaryTreeEncoding *encoding;
qf_ternary_tree_encoding_new(tree, NULL, &encoding);
QkObs *qubit_op;
QfExitCode exit = qf_ferm_op_ternary_tree(hamiltonian, encoding, 4, &qubit_op);
assert(exit == QfExitCode_Success);
// Clean up
qf_ferm_op_free(hamiltonian);
qf_ternary_tree_encoding_free(encoding);
qf_ternary_tree_free(tree);
qk_obs_free(qubit_op);
Mapear por uma cadeia ao longo de Z reproduz exatamente fermion_jordan_wigner() (qf_ferm_op_jordan_wigner() na API C), que é o sentido em que isso a 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
Toda representação de operador tem um mapeador de árvore ternária, espelhando o conjunto de Jordan-Wigner; consulte qiskit_fermions.mappers.library para a lista completa e Mapper Library na API C. Na API Python, eles também podem ser acessados por meio de ternary_tree(), o wrapper agnóstico quanto ao tipo que escolhe o mapeador certo para o operador recebido, exatamente como faz 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)>
Algumas identidades que valem sob Jordan-Wigner são propriedades da cadeia, e não das árvores ternárias. O operador de número é apenas na cadeia; em outra árvore, o se torna uma string sobre um conjunto de qubits. Da mesma forma, um operador de vértice é o Pauli de peso 1 apenas na cadeia, onde as cadeias das duas strings de Majorana se cancelam.
Como todo operador de número ainda é mapeado para uma única string , um estado de Fock continua sendo um estado da base computacional em qualquer árvore ternária, mas o bit deixa de indicar a ocupação . A ocupação passa a ser a paridade dos bits medidos no suporte da imagem de . Leia esses suportes a partir do mapeador, em vez de derivá-los da árvore, para que a decodificação permaneça correta para qualquer formato ou permutação 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)>
Nesta árvore balanceada, o modo 0 está ocupado quando os qubits 0, 1 e 2 contêm uma quantidade ímpar de uns; portanto, prepará-lo significa inverter apenas o qubit 0, enquanto decodificar uma bitstring medida exige a paridade dos três. Um único erro de inversão de bit no qubit 1 corrompe então as ocupações tanto do modo 0 quanto do modo 1, o que importa para qualquer fluxo de trabalho que leia ocupações de amostras ruidosas.
Quando uma Evolution é reduzida por um mapeador desse tipo, envolva o mapeador em group_wise() (consulte Sintetizar uma porta Evolution no espaço fermiônico). Um termo de salto é mapeado para duas strings de Pauli que comutam, nenhuma das quais conserva o número de partículas isoladamente; apenas o produto delas na evolução o conserva. Sob Jordan-Wigner, ambas as strings atuam nos mesmos qubits, então uma ordem de termos simplificada as mantém adjacentes. Em outras árvores, seus suportes diferem (como em Y_3 Z_1 Y_0 e X_3 Z_2 X_0 anteriormente), tanto a ordem bruta quanto a simplificada as intercalam com as imagens de outros termos, e uma fórmula de produto do lado dos qubits passa então a vazar número de partículas.
Construa uma árvore arbitrária
Os construtores de cadeia e de busca em largura cobrem as famílias uniformes, mas a interface geral recebe um par (parent, label) por nó, com None para a raiz (a API C recebe o índice da raiz). Isso importa porque as árvores que vale a pena buscar geralmente não são nem uma cadeia nem uniformes – o algoritmo Bonsai, por exemplo, cresce uma árvore geradora do grafo de acoplamento de um dispositivo, de modo que suas ramificações seguem essa conectividade.
- PYTHON
- C
>>> # 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}
// Node 0, the root, branches three ways, node 1 twice, and node 4 carries a single child.
// The root has no parent and no incoming link, so its entries are never read: any value works.
#define IGNORED 0
uint32_t parents[7] = {IGNORED, 0, 0, 0, 1, 1, 4};
QfPauliLabel labels[7] = {
IGNORED, QfPauliLabel_X, QfPauliLabel_Y, QfPauliLabel_Z,
QfPauliLabel_X, QfPauliLabel_Z, QfPauliLabel_Y,
};
QfTernaryTree *tree;
QfExitCode exit = qf_ternary_tree_new(7, 0, parents, labels, &tree);
assert(exit == QfExitCode_Success);
assert(qf_ternary_tree_num_nodes(tree) == 7);
assert(qf_ternary_tree_num_legs(tree) == 15);
assert(qf_ternary_tree_max_weight(tree) == 4);
qf_ternary_tree_free(tree);
Observe a quantidade de folhas: ela é sempre , qualquer que seja o formato. Um nó com menos filhos não gera menos strings de Pauli – suas ligações não usadas são folhas, e uma folha soletra uma string tanto quanto os descendentes de uma aresta. Dessas strings, se emparelham nos operadores de Majorana dos modos, e exatamente uma sobra.
Paridade total
Essa folha restante é a paridade fermiônica total . Ela é uma propriedade da codificação, e não um mapeamento de algum operador, portanto é um método da própria codificação:
- PYTHON
- C
>>> 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)>
QfTernaryTree *tree;
qf_ternary_tree_chain(4, QfPauliLabel_Z, &tree);
QfTernaryTreeEncoding *encoding;
qf_ternary_tree_encoding_new(tree, NULL, &encoding);
// On the chain this is the full Z string; on the balanced tree it is not.
QkObs *parity;
qf_ternary_tree_encoding_total_parity(encoding, 4, &parity);
qf_ternary_tree_encoding_free(encoding);
qf_ternary_tree_free(tree);
qk_obs_free(parity);
Como o caso balanceado mostra, essa string não é, em geral, a string toda de : ela é a folha alcançada pelo caminho todo de , que atua como identidade em todo nó que não está nesse caminho.
A paridade total aqui é um observável a ser medido, não uma restrição a ser imposta. Ambos os seus autovalores são físicos, portanto não projete um estado no setor : uma codificação de árvore ternária gasta um qubit por modo e é sobrejetora sobre todo o espaço de dimensão , não deixando nenhum subespaço não físico a ser eliminado por projeção.
Exemplo prático: uma árvore de Bravyi-Kitaev
As especificações costumam ser calculadas em vez de digitadas, já que a árvore de uma codificação real raramente é pequena o bastante para ser escrita à mão. Bravyi-Kitaev é um bom exemplo: ela pertence a esta família, mas sua árvore não é uniforme, então nenhum construtor de conveniência a cobre.
Sua árvore é construída por uma divisão recursiva sobre os nós, um por modo. Dentro de um intervalo de nós , seja a maior potência de dois que cabe; o nó posicionado para esse intervalo é , o último daquele bloco. Sua ligação X desce para a árvore construída a partir do bloco anterior a ele e sua ligação Z para a árvore construída a partir do restante depois dele, deixando a ligação Y como folha:
>>> 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)
Com quatro modos, o intervalo inteiro é um único bloco, então o nó posicionado primeiro é o 3, com antes dele e nada depois; esse subintervalo dá o nó 1 sobre os nós isolados 0 e 2. Compilar a árvore confirma que a codificação é Bravyi-Kitaev e não apenas uma árvore da mesma classe de formato – sua primeira imagem de Majorana é :
>>> 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')]
Sua paridade total torna concreto o ponto da seção anterior: a folha restante é a alcançada pelo caminho todo de , e aqui esse caminho termina na raiz, de modo que o observável é o de um único qubit, e não uma string sobre os quatro qubits:
>>> encoding.total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3)>
Escalar exige apenas um argumento maior. Como nenhum nó gasta sua ligação Y em uma aresta, cada um se ramifica em no máximo dois sentidos, então o peso de Pauli cresce logaritmicamente, e não linearmente:
>>> [bravyi_kitaev_tree(n).max_weight for n in (4, 8, 16, 32, 64)]
[3, 4, 5, 6, 7]