Map operator representations¶
Mappers are routines that transform operators from one representation to another while preserving mathematical equivalence. Use them to work flexibly across different operator algebras and to prepare operators for quantum circuit execution.
Implement custom mappers¶
The mappers module makes it straightforward to implement custom mappings. Mappers work by transforming the fundamental actions (creation, annihilation, Majorana, and so on) that compose an operator, then combining these transformed actions according to the target representation’s rules.
As a concrete example, consider a Jordan-Wigner transformation that maps
a MajoranaOperator to a qubit operator using 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)>
// 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.
The key steps to implement any custom mapper are:
Define action transformation: Specify how each action (indexed by mode) maps to the target representation. In the example above, Majorana actions map to Pauli strings according to Jordan-Wigner rules.
Use one of the provided mapping functions: The
mappersmodule provides utility functions to handle iterating over the operator terms and subsequent applications of the custom action map for the operator representations provided by theoperatorsmodule. For example,map_majorana_action_generators()works with Majorana operators.Get the result: The mapping function returns an operator in the target representation with the transformation applied consistently across all terms.
This design makes it easy to prototype custom mappings without deep knowledge of internal operator structures. The transformation is applied automatically to every term in the operator while preserving mathematical equivalence.
Hint
You can also iterate the terms of an operator manually rather than using one of the provided iterator functions. See the section on term iteration in the operators guide for details.
Library implementations¶
The qiskit_fermions.mappers.library module provides efficient, thoroughly
tested implementations of common mappings. These follow the same underlying pattern
as custom mappers but are optimized for production use and are available in both the
Python and C APIs.
The example below applies a library mapper to the same MajoranaOperator
from the custom mapper section, demonstrating equivalent results:
>>> 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);
Note
The two-step above is written out to show two library mappers composing. In practice you would map
a MajoranaOperator in one step instead, with majorana_jordan_wigner()
(qf_maj_op_jordan_wigner() in the C API), and likewise for the other operator types –
edge_vertex_jordan_wigner() and transfer_vertex_jordan_wigner()
(qf_edge_op_jordan_wigner(), qf_transfer_op_jordan_wigner()). Going via a
FermionOperator also inflates the intermediate result: every generator of these algebras
maps onto a single Pauli string, whereas each fermionic action maps onto a two-term sum, so a term
built from several generators expands only to be merged back down again. That saving grows with the
length of the terms; for the single-generator terms of a hopping Hamiltonian the two routes cost
about the same.
In the Python API, some of these library mappers are also reachable through type-agnostic
convenience mappers. fermion_operator() and majorana_operator() sit in front of
majorana_to_fermion() and fermion_to_majorana() (among others), delegating to the
appropriate library implementation internally based on the type of object they are given,
rather than requiring you to pick the right function yourself. The same applies to
jordan_wigner() and to ternary_tree() (introduced below), each of which dispatches to
whichever direct implementation matches the operator it is given. See
qiskit_fermions.protocols for how this dispatch is implemented internally.
Choose an encoding with ternary trees¶
Jordan-Wigner is not the only fermion-to-qubit encoding, and it is not the cheapest. Its \(Z\) strings grow with the mode index, so a Majorana operator on the last of \(n\) modes maps onto a Pauli string of weight \(n\). Encodings that spread the parity information across a tree instead of a chain do asymptotically better.
A ternary tree on \(n\) nodes captures this whole family. Each node is a qubit carrying three
downward links labelled X, Y and Z; a link either descends to a child node (an edge) or
terminates (a leg). Each leg’s path back to the root spells a Pauli string: crossing the link
labelled \(P\) that descends from node \(u\) contributes \(P\) on qubit \(u\). Every
such tree yields a valid encoding, and the familiar ones are simply particular shapes – a chain along
Z is Jordan-Wigner, while a tree of branching rate 3 is balanced.
>>> 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);
That weight is optimal: no fermion-to-qubit mapping of \(n\) modes can do better than \(\lceil \log_3(2n+1) \rceil\) on average. Both trees use exactly \(n\) qubits, so the saving is in Pauli weight, not qubit count.
To map an operator, compile a tree into an encoding and hand that to a mapper along with the qubit count for the result. Compiling pairs the tree’s legs into Majorana operators and builds their Pauli strings once, so the same encoding should be reused across many operators. The qubit count stays with the mapping call rather than the encoding, so one encoding can serve registers of different widths; a count above the tree’s node count pads with the identity.
>>> 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);
Mapping through a chain along Z reproduces fermion_jordan_wigner()
(qf_ferm_op_jordan_wigner() in the C API) exactly, which is the sense in which this generalizes
it:
>>> 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
Every operator representation has a ternary-tree mapper, mirroring the Jordan-Wigner set; see
qiskit_fermions.mappers.library for the full list, and Mapper Library in the C API.
In the Python API they are also reachable through ternary_tree(), the type-agnostic wrapper
that picks the right one for the operator it is given, just as jordan_wigner() does:
>>> 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)>
Note
Some identities that hold under Jordan-Wigner are properties of the chain rather than of ternary trees. The number operator is \(n_j = (1 - Z_j)/2\) only there; on another tree it is a signed \(Z\) string over a set of qubits. Likewise a vertex operator \(V_j\) is the weight-1 Pauli \(Z_j\) only on the chain, where the two Majorana strings’ \(Z\) chains cancel.
Build an arbitrary tree¶
The chain and breadth-first constructors cover the uniform families, but the general interface takes
one (parent, label) pair per node, with None for the root. This matters because the trees worth
searching for are usually neither a chain nor uniform – the Bonsai algorithm, for instance, grows a
spanning tree of a device’s coupling graph, so its branching follows that connectivity.
>>> # 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)
>>> dict(tree.children(0))
{'X': 1, 'Y': 2, 'Z': 3}
// Node 0 branches three ways, node 1 twice, and node 4 carries a single child.
QfTernaryTreeNode spec[7] = {
{0, QfPauliLabel_Z, true}, // the root; parent and label are ignored
{0, QfPauliLabel_X, false}, {0, QfPauliLabel_Y, false}, {0, QfPauliLabel_Z, false},
{1, QfPauliLabel_X, false}, {1, QfPauliLabel_Z, false}, {4, QfPauliLabel_Y, false},
};
QfTernaryTree *tree;
QfExitCode exit = qf_ternary_tree_new(7, spec, &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);
Note the leg count: it is always \(2n+1\), whatever the shape. A node with fewer children does not generate fewer Pauli strings – its unused links are legs, and a leg spells a string just as an edge’s descendants do. Of those \(2n+1\) strings, \(2n\) pair into the Majorana operators of the \(n\) modes, and exactly one is left over.
Total parity¶
That leftover leg is the total fermionic parity \(\prod_j (1 - 2 n_j)\). It is a property of the encoding rather than a mapping of any operator, so it is a method on the encoding itself:
>>> 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);
qf_ternary_tree_encoding_free(encoding);
qf_ternary_tree_free(tree);
qk_obs_free(parity);
As the balanced case shows, this is not in general the all-\(Z\) string: it is the leg reached by the all-\(Z\) path, which acts as the identity on every node not on that path.
Important
Total parity here is an observable to measure, not a constraint to impose. Both of its eigenvalues are physical, so do not project a state onto the \(+1\) sector: a ternary-tree encoding spends one qubit per mode and is onto the full \(2^n\)-dimensional space, leaving no unphysical subspace to project away.
Local encodings that add ancilla qubits, such as the flow sets in 1D and 2D, do work that way: there \(n_\text{qubits} > n_\text{modes}\), the extra dimensions are unphysical, and a stabilizer subspace is what makes the encoding faithful. Ternary trees add no such qubits and have no such subspace.
Worked example: a Bravyi-Kitaev tree¶
Specifications are more often computed than typed out, since the tree for a real encoding is rarely small enough to write by hand. Bravyi-Kitaev is a good example: it belongs to this family, but its tree is not uniform, so no convenience constructor covers it.
Its tree is built by a recursive split over the nodes, one per mode. Within a range of nodes
\([l, r]\), let \(h\) be the largest power of two that fits; the node placed for that range is
\(l + h - 1\), the last one in that block. Its X link descends to the tree built from the block
before it and its Z link to the tree built from the remainder after it, leaving its Y link as a
leg:
>>> 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)
At four modes the whole range is a single block, so the node placed first is 3, with \([0, 2]\) before it and nothing after; that sub-range gives node 1 over the single nodes 0 and 2. Compiling the tree confirms the encoding is Bravyi-Kitaev and not merely a tree of the same shape class – its first Majorana image is \(X_0 X_1 X_3\):
>>> tree = bravyi_kitaev_tree(4)
>>> dict(tree.children(1))
{'X': 0, 'Z': 2}
>>> encoding = TernaryTreeEncoding(tree)
>>> encoding.majorana_image(0)
[(0, 'X'), (1, 'X'), (3, 'X')]
Its total parity makes the point of the previous section concrete: the leftover leg is the one reached by the all-\(Z\) path, and here that path stops at the root, so the observable is the single-qubit \(Z_3\) rather than a string over all four qubits:
>>> encoding.total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3)>
Scaling up needs nothing but a larger argument. Because no node ever spends its Y link on an edge,
each branches at most two ways, so the Pauli weight grows logarithmically rather than linearly:
>>> [bravyi_kitaev_tree(n).max_weight for n in (4, 8, 16, 32, 64)]
[3, 4, 5, 6, 7]