Note
Run interactively in jupyter notebook.
Introduction & Fermionic Circuits¶
In digital quantum computing, the fundamental unit of information is typically an addressable two-level quantum system called a qubit. Within the circuit model, each qubit is assigned a wire in the circuit. After initialization in a well-defined state (e.g. the ground state 0), gates applied to these wires describe an evolution of the state under a sequence of unitary operations. A measurement on a wire then projects the corresponding unit of information onto one of its internal states (0 or 1 for qubits).
Quantum simulation architectures based on cold atoms have been vastly explored for the simulation of condensed matter systems in the last two decades. [1] Their experimental control has advanced to a point where they now offer varying degrees of programmability making them strong candidates for a novel kind of quantum information processor. Similar to the qubit circuits described above, experiments run on cold atomic quantum simulators are also commonly described by an initialization in some easy-to-prepare state, a subsequent evolution of the state under programmable unitary operations and finally a measurement yielding information about the quantum state of the atoms such as location, momentum or spin.
Due to these similarities between the qubits and cold atomic quantum systems we can abstract quantum computations and simulations run on cold atomic systems in quantum circuits where the wires represent the internal degrees of freedom of the atoms which are accessed by the measurement.
Qiskit’s cold atom module explores such circuit-based descriptions of cold atomic experiments, leveraging the existing Qiskit control stack and building upon functionality such as circuits, backends, gates, providers etc.
Specifically, this module enables two different settings:
A “fermionic” setting where the individual units of information are the occupations of fermionic modes realized by trapped fermionic atoms as described in this tutorial.
A “spin” setting where the individual units of information are orientations of long spins as realized by trapped Bose-Einstein-Condensates. This is introduced in the spin circuits tutorial.
Fermionic setting¶
In a fermionic setting, the wires of a quantum circuit describe the occupations of individual fermionic modes. Due to the Pauli exclusion principle, a fermionic mode can either be empty, i.e. in the “vacuum state” \(\left| 0 \right>\) or occupied \(\left| 1 \right>\). However, no two particles can occupy the same mode. We assign one wire to each fermionic mode in the quantum circuit describing such systems.
Before applying gates to this fermionic register, we need to initialize some modes with particles, which is done with a load
instruction. Importing the qiskit_cold_atom.fermions
module will add this instruction to the QuantumCircuit
class.
[1]:
from qiskit_cold_atom.fermions import FermionSimulator
# initialize the generic fermionic simulator backend
backend = FermionSimulator()
[2]:
from qiskit import QuantumCircuit
circ1 = QuantumCircuit(4) # Create a quantum circuit describing four fermionic modes.
circ1.fload([1, 2]) # Load fermions into modes 1 and 2.
circ1.draw(output='mpl')
[2]:
A measurement will yield the occupations of each wire. Let’s run the circuit on the FermionSimulator
backend and confirm this. Calling backend.run()
will create an AerJob
object from qiskit-aer which comes with the same functionality to handle jobs familiar from the Aer simulation backends, such as a .result()
method to retrieve the results.
[3]:
circ1.measure_all()
job = backend.run(circ1) # defaults to 1000 shots taken
print(job.result().get_counts())
{'0110': 1000}
A fermionic register can also be created directly from a list of initial occupations:
[4]:
circ2 = backend.initialize_circuit([1, 0, 1])
circ2.draw(output='mpl')
[4]:
Observe that the first quantum circuit has a register named q
while the second circuit has a register named fer_mode
to emphasize that the wires are fermionic in nature. This does not affect the instructions that can be added to a circuit and reminds us of the different nature of the wires. Circuits with registers labeled fer_mode
can be conveniently obtained from the cold atom backend instances using the initialize_circuit
method.
[5]:
print(f"First circuit's register: {circ1.qregs}")
print(f"Second circuit's register: {circ2.qregs}")
First circuit's register: [QuantumRegister(4, 'q')]
Second circuit's register: [QuantumRegister(3, 'fer_mode')]
Fermionic Gates¶
Quantum gates define the unitary operations that are carried out on the system. The gate unitary \(U\) can be uniquely identified by the hermitian Hamiltonian \(H\) that generates the evolution of the state (see Qiskit textbook) as \(U = e^{-i H}\). The dynamics of cold atomic quantum simulators is commonly described by the Hamiltonian implemented by the system. The Qiskit Cold Atom module therefore defines fermionic gates using their generating Hamiltonians.
As a formal language to describe fermionic Hamiltonians, we utilize the FermionicOp
from Qiskit Nature. Gates are then defined as instances or subclasses of the FermionicGate
class which inherits from Qiskit’s original Gate
class.
In order for the FermionSimulator
to run a circuit, each gate of the circuit needs to have its generating Hamiltonian given as a FermionicOp
.
As a first example, let’s define a gate \(U_{\text{swap}} = e^{-i H_{\text{swap}}}\) which takes one particle from mode \(i\) to another mode \(j\) (and vice versa). In second quantization, the generating Hamiltonian is given as
[6]:
from qiskit_nature.operators.second_quantization import FermionicOp
import numpy as np
# define the Hamiltonian as a FermionicOp
H_swap = np.pi/2 * FermionicOp([("+_0 -_1", 1), ("-_0 +_1", -1)], register_length=2)
For details on the syntax of how to define a FermionicOp
please see the FermionicOp documentation.
We can now define the fermionic gate:
[7]:
from qiskit_cold_atom.fermions import FermionicGate
swap_fermions = FermionicGate(name="swap_fermion", num_modes=2, generator=H_swap)
Let’s use this gate to define and run a circuit on the simulator:
[8]:
circ = backend.initialize_circuit([1, 0, 1, 0])
circ.append(swap_fermions, qargs=[0, 1])
circ.append(swap_fermions, qargs=[2, 3])
circ.append(swap_fermions, qargs=[1, 2])
circ.measure_all()
# circ.draw(output='mpl', scale=0.8)
[9]:
job = backend.run(circ)
print(job.result().get_counts())
{'0011': 1000}
The fermions initialized at positions 0 and 2 got moved to position 2 and 3. Therefore, the outcome ‘0011’ is always measured at each shot.
Superposition¶
With the SWAP gate only, the system will always stay in a computational basis state. To create a superposition we define a similar gate which lets a particle tunnel from mode \(i\) to mode \(j\). This is achieved by applying the same Hamiltonian \(H_{\text{swap}}\) for half the time, i.e. with a prefactor half as large.
[10]:
# define a gate which will create superposition in the circuit
split_fermions = FermionicGate(name="split_fermion", num_modes=2, generator=H_swap/2)
[11]:
qc_sup = backend.initialize_circuit([1, 0, 0])
qc_sup.append(split_fermions, qargs=[0, 1])
qc_sup.append(swap_fermions, qargs=[1, 2])
qc_sup.measure_all()
qc_sup.draw(output='mpl', scale=0.8)
[11]:
[12]:
from qiskit.visualization import plot_histogram
job_sup = backend.run(qc_sup)
plot_histogram(job_sup.result().get_counts(), figsize=(5, 3))
[12]:
We now see that the single particle in the register is found either in mode 0 or mode 2 with equal probability.
The fermionic simulator backend¶
We have already seen above how to use the FermionSimulator
to simulate measurements of the occupation of the fermionic modes. Internally, this backend simulates the evolution of the circuit by exact diagonalization and therefore also has access to the statevector and unitary of the system in the familiar way of result.get_statevector()
and result.get_unitary()
. If there is a final measurement in the circuit, the returned state and unitary of the circuit are those just prior to
measurement.
[13]:
qc = backend.initialize_circuit([1, 0, 0, 1])
qc.append(split_fermions, qargs=[0, 1])
qc.append(swap_fermions, qargs=[2, 3])
qc.append(swap_fermions, qargs=[0, 1])
qc.append(split_fermions, qargs=[2, 3])
qc.append(swap_fermions, qargs=[0, 2])
qc.measure_all()
job = backend.run(qc, shots = 10, seed=1234)
# access the counts
print("counts :", job.result().get_counts())
# access the memory of individual outcomes
print("\nmemory :", job.result().get_memory())
# access the statevector
print("\nstatevector :", job.result().get_statevector())
# accedd the unitary
print("\ncircuit unitary : \n", job.result().get_unitary())
qc.draw(output='mpl')
counts : {'0101': 3, '1100': 5, '0011': 1, '1010': 1}
memory : ['0011', '1010', '0101', '1100', '1100', '0101', '0101', '1100', '1100', '1100']
statevector : [0.-0.5j 0.+0.5j 0.+0.j 0.+0.j 0.+0.5j 0.-0.5j]
circuit unitary :
[[ 0. +0.j 0.5+0.j 0. +0.5j 0. -0.5j 0.5+0.j 0. +0.j ]
[ 0. +0.j 0.5+0.j 0. +0.5j 0. +0.5j -0.5+0.j 0. +0.j ]
[ 0. +0.j 0. +0.j 0. +0.j 0. +0.j 0. +0.j 0. +1.j ]
[ 0. -1.j 0. +0.j 0. +0.j 0. +0.j 0. +0.j 0. +0.j ]
[ 0. +0.j -0.5+0.j 0. +0.5j 0. +0.5j 0.5+0.j 0. +0.j ]
[ 0. +0.j -0.5+0.j 0. +0.5j 0. -0.5j -0.5+0.j 0. +0.j ]]
[13]:
Note that the statevector and unitary are defined in a Hilbert space of dimension six. This is because the simulator checks for the conservation of the total number of particles under the applied gates. If all gates conserve the total particle number, the simulation will only be carried out in the relevant subspace. In order to see which index in this basis belongs to which state, the basis of the simulation of a circuits can be accessed as:
[14]:
print(backend.get_basis(qc))
0. |0, 0, 1, 1>
1. |0, 1, 0, 1>
2. |0, 1, 1, 0>
3. |1, 0, 0, 1>
4. |1, 0, 1, 0>
5. |1, 1, 0, 0>
Measuring observables¶
The fermionic backends come with a method to sample the expectation value of observables which are diagonal in the occupation number basis. Like the gate Hamiltonians, these observables are given as a FermionicOp
.
For example, we can measure the correlation of the occupations of two sites in the above circuit:
[15]:
corr = FermionicOp("NINI") + FermionicOp("EIEI")
exp_val = backend.measure_observable_expectation(qc, observable=corr, shots=1000)
print(exp_val)
[0.484]
Further remarks¶
The FermionSimulator
is a general simulator backend in the sense that it accepts any FermionicGate
with a well-defined fermionic generator. Similar to the qasm_simulator
for qubits, there are no coupling maps or further restrictions posed on the applicable gates.
To see how fermionic circuits can describe a concrete experimental system of ultracold fermionic atoms with the gateset and coupling map of a real device please see the fermionic tweezer hardware tutorial.
References¶
[1] Gross, Christian and Bloch, Immanuel Quantum simulations with ultracold atoms in optical lattices, Science, 357, 995-1001, 2017
[16]:
import qiskit.tools.jupyter
%qiskit_version_table
%qiskit_copyright
Version Information
Qiskit Software | Version |
---|---|
qiskit-terra | 0.23.1 |
qiskit-aer | 0.11.2 |
qiskit-nature | 0.5.2 |
System information | |
Python version | 3.9.16 |
Python compiler | MSC v.1916 64 bit (AMD64) |
Python build | main, Jan 11 2023 16:16:36 |
OS | Windows |
CPUs | 8 |
Memory (Gb) | 63.724937438964844 |
Wed Feb 22 16:56:36 2023 W. Europe Standard Time |
This code is a part of Qiskit
© Copyright IBM 2017, 2023.
This code is licensed under the Apache License, Version 2.0. You may
obtain a copy of this license in the LICENSE.txt file in the root directory
of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
Any modifications or derivative works of this code must retain this
copyright notice, and modified files need to carry a notice indicating
that they have been altered from the originals.
[ ]: