Pular para o conteúdo principal
Este projeto está em fase beta. Encontrou um bug? Abra uma issue no GitHub. Tem uma pergunta ou ideia? Pergunte no Discussions — adoraríamos receber seu feedback!
Como está a qualidade da tradução nesta página?

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

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

As etapas principais para implementar qualquer mapeador personalizado são:

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

  2. Use uma das funções de mapeamento fornecidas: o módulo mappers oferece 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ódulo operators. Por exemplo, map_majorana_action_generators() funciona com operadores de Majorana.

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

Dica

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:

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

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 ZZ crescem com o índice do modo, de modo que um operador de Majorana no último de nn modos é mapeado para uma string de Pauli de peso nn. 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 nn 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 PP que desce do nó uu contribui com PP no qubit uu. 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.

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

Esse peso máximo é ótimo. Jiang et al. provam que os operadores de Majorana de qualquer mapeamento de férmions para qubits de nn modos têm peso de Pauli médio de pelo menos log⁡3(2n)\log_3(2n), de modo que nenhum mapeamento consegue reduzir o maior deles abaixo de ⌈log⁡3(2n+1)⌉\lceil \log_3(2n+1) \rceil. Ambas as árvores usam exatamente nn 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.

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

Algumas identidades que valem sob Jordan-Wigner são propriedades da cadeia, e não das árvores ternárias. O operador de número é nj=(1−Zj)/2n_j = (1 - Z_j)/2 apenas na cadeia; em outra árvore, o ZjZ_j se torna uma string ZZ sobre um conjunto de qubits. Da mesma forma, um operador de vértice VjV_j é o Pauli de peso 1 ZjZ_j apenas na cadeia, onde as cadeias ZZ das duas strings de Majorana se cancelam.

Como todo operador de número ainda é mapeado para uma única string ZZ, um estado de Fock continua sendo um estado da base computacional em qualquer árvore ternária, mas o bit qq deixa de indicar a ocupação qq. A ocupação jj passa a ser a paridade dos bits medidos no suporte da imagem de njn_j. 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.

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

Observe a quantidade de folhas: ela é sempre 2n+12n+1, 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 2n+12n+1 strings, 2n2n se emparelham nos operadores de Majorana dos nn modos, e exatamente uma sobra.

Paridade total​

Essa folha restante é a paridade fermiônica total ∏j(1−2nj)\prod_j (1 - 2 n_j). Ela é uma propriedade da codificação, e não um mapeamento de algum operador, portanto é um método da própria codificação:

>>> 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 o caso balanceado mostra, essa string não é, em geral, a string toda de ZZ: ela é a folha alcançada pelo caminho todo de ZZ, que atua como identidade em todo nó que não está nesse caminho.

Importante

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 +1+1: uma codificação de árvore ternária gasta um qubit por modo e é sobrejetora sobre todo o espaço de dimensão 2n2^n, 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 [l,r][l, r], seja hh a maior potência de dois que cabe; o nó posicionado para esse intervalo é l+h−1l + h - 1, 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 [0,2][0, 2] 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 é 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')]

Sua paridade total torna concreto o ponto da seção anterior: a folha restante é a alcançada pelo caminho todo de ZZ, e aqui esse caminho termina na raiz, de modo que o observável é o Z3Z_3 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]