Note
This is the documentation for the current state of the development branch of Qiskit Experiments. The documentation or APIs here can change prior to being released.
Calibrations: Schedules and gate parameters from experiments¶
Caution
Support for calibrating pulses is deprecated as of Qiskit Experiments 0.8 and will be removed in a future version. There is no alternative support path because Qiskit Pulse is deprecated in Qiskit SDK with planned removal in Qiskit 2.0.
To produce high fidelity quantum operations, we want to be able to run good gates. The calibration module in Qiskit Experiments allows users to run experiments to find the pulse shapes and parameter values that maximize the fidelity of the resulting quantum operations. Calibration experiments encapsulate the internal processes and allow experimenters to perform calibration operations in a quicker way. Without the experiments module, we would need to define pulse schedules and plot the resulting measurement data manually.
In this tutorial, we demonstrate how to calibrate single-qubit gates using the
calibration framework in Qiskit Experiments. We will run experiments on our test pulse
backend, SingleTransmonTestBackend
, a backend that simulates the underlying
pulses with qiskit_dynamics
on a three-level model of a transmon. You can also
run these experiments on any real backend with Pulse enabled (see
qiskit.providers.models.BackendConfiguration
).
We will run experiments to find the qubit frequency, calibrate the amplitude of DRAG pulses, and choose the value of the DRAG parameter that minimizes leakage. The calibration framework requires the user to:
Set up an instance of
Calibrations
,Run calibration experiments found in
qiskit_experiments.library.calibration
.
Note that the values of the parameters stored in the instance of the Calibrations
class
will automatically be updated by the calibration experiments.
This automatic updating can also be disabled using the auto_update
flag.
Note
This tutorial requires the qiskit_dynamics
package to run simulations.
You can install it with python -m pip install qiskit-dynamics
.
import pandas as pd
import numpy as np
import qiskit.pulse as pulse
from qiskit.circuit import Parameter
from qiskit_experiments.calibration_management.calibrations import Calibrations
from qiskit import schedule
from qiskit_experiments.test.pulse_backend import SingleTransmonTestBackend
backend = SingleTransmonTestBackend(5.2e9,-.25e9, 1e9, 0.8e9, noise=False, seed=100)
qubit = 0
cals=Calibrations.from_backend(backend)
print(cals.get_inst_map())
<InstructionScheduleMap(1Q instructions:
Multi qubit instructions:
)>
The two functions below show how to set up an instance of Calibrations
.
To do this the user defines the template schedules to calibrate.
These template schedules are fully parameterized, even the channel indices
on which the pulses are played. Furthermore, the name of the parameter in the channel
index must follow the convention laid out in the documentation
of the calibration module. Note that the parameters in the channel indices
are automatically mapped to the channel index when Calibrations.get_schedule()
is called.
# A function to instantiate calibrations and add a couple of template schedules.
def setup_cals(backend) -> Calibrations:
cals = Calibrations.from_backend(backend)
dur = Parameter("dur")
amp = Parameter("amp")
sigma = Parameter("σ")
beta = Parameter("β")
drive = pulse.DriveChannel(Parameter("ch0"))
# Define and add template schedules.
with pulse.build(name="xp") as xp:
pulse.play(pulse.Drag(dur, amp, sigma, beta), drive)
with pulse.build(name="xm") as xm:
pulse.play(pulse.Drag(dur, -amp, sigma, beta), drive)
with pulse.build(name="x90p") as x90p:
pulse.play(pulse.Drag(dur, Parameter("amp"), sigma, Parameter("β")), drive)
cals.add_schedule(xp, num_qubits=1)
cals.add_schedule(xm, num_qubits=1)
cals.add_schedule(x90p, num_qubits=1)
return cals
# Add guesses for the parameter values to the calibrations.
def add_parameter_guesses(cals: Calibrations):
for sched in ["xp", "x90p"]:
cals.add_parameter_value(80, "σ", schedule=sched)
cals.add_parameter_value(0.5, "β", schedule=sched)
cals.add_parameter_value(320, "dur", schedule=sched)
cals.add_parameter_value(0.5, "amp", schedule=sched)
When setting up the calibrations we add three pulses: a \(\pi\)-rotation,
with a schedule named xp
, a schedule xm
identical to xp
but with a nagative amplitude, and a \(\pi/2\)-rotation, with a schedule
named x90p
. Here, we have linked the amplitude of the xp
and xm
pulses.
Therefore, calibrating the parameters of xp
will also calibrate
the parameters of xm
.
cals = setup_cals(backend)
add_parameter_guesses(cals)
A similar setup is achieved by using a pre-built library of gates.
The library of gates provides a standard set of gates and some initial guesses
for the value of the parameters in the template schedules.
This is shown below using the FixedFrequencyTransmon
library which provides the x
,
y
, sx
, and sy
pulses. Note that in the example below
we change the default value of the pulse duration to 320 samples
from qiskit_experiments.calibration_management.basis_gate_library import FixedFrequencyTransmon
library = FixedFrequencyTransmon(default_values={"duration": 320})
cals = Calibrations.from_backend(backend, libraries=[library])
print(library.default_values()) # check what parameter values this library has
print(cals.get_inst_map()) # check the new cals's InstructionScheduleMap made from the library
print(cals.get_schedule('x',(0,))) # check one of the schedules built from the new calibration
[DefaultCalValue(value=0.0, parameter='angle', qubits=(), schedule_name='x'), DefaultCalValue(value=80.0, parameter='σ', qubits=(), schedule_name='x'), DefaultCalValue(value=0.5, parameter='amp', qubits=(), schedule_name='x'), DefaultCalValue(value=320, parameter='duration', qubits=(), schedule_name='x'), DefaultCalValue(value=0.0, parameter='β', qubits=(), schedule_name='x'), DefaultCalValue(value=80.0, parameter='σ', qubits=(), schedule_name='sx'), DefaultCalValue(value=0.25, parameter='amp', qubits=(), schedule_name='sx'), DefaultCalValue(value=0.0, parameter='β', qubits=(), schedule_name='sx'), DefaultCalValue(value=320, parameter='duration', qubits=(), schedule_name='sx'), DefaultCalValue(value=0.0, parameter='angle', qubits=(), schedule_name='sx')]
<InstructionScheduleMap(1Q instructions:
q0: {'sy', 'y', 'sx', 'x'}
Multi qubit instructions:
)>
ScheduleBlock(Play(Drag(duration=320, sigma=80, beta=0, amp=0.5, angle=0), DriveChannel(0)), name="x", transform=AlignLeft())
We are going to run the spectroscopy, Rabi, DRAG, and fine amplitude calibration experiments one after another and update the parameters after every experiment, keeping track of parameter values.
Finding qubits with spectroscopy¶
Here, we are using a backend for which we already know the qubit frequency. We will therefore use the spectroscopy experiment to confirm that there is a resonance at the qubit frequency reported by the backend.
from qiskit_experiments.library.calibration.rough_frequency import RoughFrequencyCal
We first show the contents of the calibrations for qubit 0.
Note that the guess values that we added before apply to all qubits on the chip.
We see this in the table below as an empty tuple ()
in the qubits column.
Observe that the parameter values of y
do not appear in this table as they are given by the values of x
.
columns_to_show = ["parameter", "qubits", "schedule", "value", "date_time"]
pd.DataFrame(**cals.parameters_table(qubit_list=[qubit, ()]))[columns_to_show]
parameter | qubits | schedule | value | date_time | |
---|---|---|---|---|---|
0 | meas_freq | (0,) | None | 0.00000e+00 | 2024-11-15 17:18:04.... |
1 | angle | () | x | 0.00000e+00 | 2024-11-15 17:18:04.... |
2 | β | () | x | 0.00000e+00 | 2024-11-15 17:18:04.... |
3 | σ | () | x | 8.00000e+01 | 2024-11-15 17:18:04.... |
4 | duration | () | sx | 3.20000e+02 | 2024-11-15 17:18:04.... |
5 | amp | () | x | 5.00000e-01 | 2024-11-15 17:18:04.... |
6 | duration | () | x | 3.20000e+02 | 2024-11-15 17:18:04.... |
7 | angle | () | sx | 0.00000e+00 | 2024-11-15 17:18:04.... |
8 | drive_freq | (0,) | None | 5.20000e+09 | 2024-11-15 17:18:04.... |
9 | β | () | sx | 0.00000e+00 | 2024-11-15 17:18:04.... |
10 | σ | () | sx | 8.00000e+01 | 2024-11-15 17:18:04.... |
11 | amp | () | sx | 2.50000e-01 | 2024-11-15 17:18:04.... |
Instantiate the experiment and draw the first circuit in the sweep:
freq01_estimate = backend.defaults().qubit_freq_est[qubit]
frequencies = np.linspace(freq01_estimate-15e6, freq01_estimate+15e6, 51)
spec = RoughFrequencyCal((qubit,), cals, frequencies, backend=backend)
spec.set_experiment_options(amp=0.005)
circuit = spec.circuits()[0]
circuit.draw(output="mpl", style="iqp")
We can also visualize the pulse schedule for the circuit:
next(iter(circuit.calibrations["Spec"].values())).draw()
circuit.calibrations["Spec"]
{((0,),
(-15000000.0,)): ScheduleBlock(ShiftFrequency(-15000000, DriveChannel(0)), Play(GaussianSquare(duration=2400, sigma=599.9999999999999, width=0.0, amp=0.005, angle=0.0), DriveChannel(0)), ShiftFrequency(15000000, DriveChannel(0)), name="spectroscopy", transform=AlignLeft())}
Run the calibration experiment:
spec_data = spec.run().block_for_results()
spec_data.figure(0)
print(spec_data.analysis_results("f01"))
AnalysisResult
- name: f01
- value: (5.20002+/-0.00012)e+09
- χ²: 0.013432983929055514
- quality: good
- extra: <3 items>
- device_components: ['Q0']
- verified: False
The instance of calibrations
has been automatically updated with the measured
frequency, as shown below. In addition to the columns shown below, calibrations
also
stores the group to which a value belongs, whether a value is valid or not, and the
experiment id that produced a value.
pd.DataFrame(**cals.parameters_table(qubit_list=[qubit]))[columns_to_show]
parameter | qubits | schedule | value | date_time | |
---|---|---|---|---|---|
0 | meas_freq | (0,) | None | 0.00000e+00 | 2024-11-15 17:18:04.... |
1 | drive_freq | (0,) | None | 5.20002e+09 | 2024-11-15 17:18:31.... |
Calibrating the pulse amplitudes with a Rabi experiment¶
In the Rabi experiment we apply a pulse at the frequency of the qubit
and scan its amplitude to find the amplitude that creates a rotation
of a desired angle. We do this with the calibration experiment RoughXSXAmplitudeCal
.
This is a specialization of the Rabi
experiment that will update the calibrations
for both the \(X\) pulse and the \(SX\) pulse using a single experiment.
from qiskit_experiments.library.calibration import RoughXSXAmplitudeCal
rabi = RoughXSXAmplitudeCal((qubit,), cals, backend=backend, amplitudes=np.linspace(-0.1, 0.1, 51))
The rough amplitude calibration is therefore a Rabi experiment in which each circuit contains a pulse with a gate. Different circuits correspond to pulses with different amplitudes.
rabi.circuits()[0].draw(output="mpl", style="iqp")
After the experiment completes the value of the amplitudes in the calibrations
will automatically be updated. This behaviour can be controlled using the auto_update
argument given to the calibration experiment at initialization.
rabi_data = rabi.run().block_for_results()
rabi_data.figure(0)
print(rabi_data.analysis_results("rabi_rate"))
AnalysisResult
- name: rabi_rate
- value: 17.129+/-0.011
- χ²: 1.6507539262473254
- quality: bad
- extra: <2 items>
- device_components: ['Q0']
- verified: False
pd.DataFrame(**cals.parameters_table(qubit_list=[qubit, ()], parameters="amp"))[columns_to_show]
parameter | qubits | schedule | value | date_time | |
---|---|---|---|---|---|
0 | amp | (0,) | sx | 0.01460 | 2024-11-15 17:18:39.... |
1 | amp | (0,) | x | 0.02919 | 2024-11-15 17:18:39.... |
2 | amp | () | sx | 0.25000 | 2024-11-15 17:18:04.... |
3 | amp | () | x | 0.50000 | 2024-11-15 17:18:04.... |
The table above shows that we have now updated the amplitude of our \(\pi\) pulse
from 0.5 to the value obtained in the most recent Rabi experiment.
Importantly, since we linked the amplitudes of the x
and y
schedules
we will see that the amplitude of the y
schedule has also been updated
as seen when requesting schedules from the Calibrations
instance.
Furthermore, we used the result from the Rabi experiment to also update
the value of the sx
pulse.
cals.get_schedule("sx", qubit)
ScheduleBlock(Play(Drag(duration=320, sigma=80, beta=0, amp=0.01459518, angle=0), DriveChannel(0)), name="sx", transform=AlignLeft())
cals.get_schedule("x", qubit)
ScheduleBlock(Play(Drag(duration=320, sigma=80, beta=0, amp=0.02919036, angle=0), DriveChannel(0)), name="x", transform=AlignLeft())
cals.get_schedule("y", qubit)
ScheduleBlock(Play(Drag(duration=320, sigma=80, beta=0, amp=0.02919036, angle=1.5707963268), DriveChannel(0)), name="y", transform=AlignLeft())
Saving and loading calibrations¶
The values of the calibrated parameters can be saved to a .csv file and reloaded at a later point in time.
cals.save(file_type="csv", overwrite=True, file_prefix="PulseBackend")
After saving the values of the parameters you may restart your kernel. If you do so,
you will only need to run the following cell to recover the state of your calibrations.
Since the schedules are currently not stored we need to call our setup_cals
function
or use a library to populate an instance of Calibrations with the template schedules.
By contrast, the value of the parameters will be recovered from the file.
cals = Calibrations.from_backend(backend, library)
cals.load_parameter_values(file_name="PulseBackendparameter_values.csv")
pd.DataFrame(**cals.parameters_table(qubit_list=[qubit, ()], parameters="amp"))[columns_to_show]
parameter | qubits | schedule | value | date_time | |
---|---|---|---|---|---|
0 | amp | (0,) | sx | 0.01460 | 2024-11-15 17:18:39.... |
1 | amp | (0,) | x | 0.02919 | 2024-11-15 17:18:39.... |
2 | amp | () | sx | 0.25000 | 2024-11-15 17:18:04.... |
3 | amp | () | x | 0.50000 | 2024-11-15 17:18:04.... |
Calibrating the value of the DRAG coefficient¶
A Derivative Removal by Adiabatic Gate (DRAG) pulse is designed to minimize leakage and phase errors to a neighbouring transition. It is a standard pulse with an additional derivative component. It is designed to reduce the frequency spectrum of a normal pulse near the \(|1\rangle - |2\rangle\) transition, reducing the chance of leakage to the \(|2\rangle\) state. The optimal value of the DRAG parameter is chosen to minimize both leakage and phase errors resulting from the AC Stark shift. The pulse envelope is \(f(t)=\Omega_x(t)+j\beta\frac{\rm d}{{\rm d}t}\Omega_x(t)\). Here, \(\Omega_x(t)\) is the envelop of the in-phase component of the pulse and \(\beta\) is the strength of the quadrature which we refer to as the DRAG parameter and seek to calibrate in this experiment. The DRAG calibration will run several series of circuits. In a given circuit a Rp(β) - Rm(β) block is repeated \(N\) times. Here, Rp is a rotation with a positive angle and Rm is the same rotation with a negative amplitude.
from qiskit_experiments.library import RoughDragCal
cal_drag = RoughDragCal([qubit], cals, backend=backend, betas=np.linspace(-20, 20, 25))
cal_drag.set_experiment_options(reps=[3, 5, 7])
cal_drag.circuits()[5].draw(output="mpl", style="iqp")
drag_data = cal_drag.run().block_for_results()
drag_data.figure(0)
print(drag_data.analysis_results("beta"))
AnalysisResult
- name: beta
- value: -1.511+/-0.017
- χ²: 0.9623748856974638
- quality: bad
- extra: <2 items>
- device_components: ['Q0']
- verified: False
pd.DataFrame(**cals.parameters_table(qubit_list=[qubit, ()], parameters="β"))[columns_to_show]
parameter | qubits | schedule | value | date_time | |
---|---|---|---|---|---|
0 | β | () | sx | 0.00000 | 2024-11-15 17:18:04.... |
1 | β | () | x | 0.00000 | 2024-11-15 17:18:04.... |
2 | β | (0,) | x | -1.51105 | 2024-11-15 17:18:45.... |
Fine calibrations of a pulse amplitude¶
The amplitude of a pulse can be precisely calibrated using error amplifying gate
sequences. These gate sequences apply the same gate a variable number of times.
Therefore, if each gate has a small error \(d\theta\) in the rotation angle then a
sequence of \(n\) gates will have a rotation error of \(n\) * \(d\theta\).
The FineAmplitude
experiment and its subclass experiments implements these
sequences to obtain the correction value of imperfect pulses. We will first examine how
to detect imperfect pulses using the characterization version of these experiments, then
update calibrations with a calibration experiment.
from qiskit.pulse import InstructionScheduleMap
from qiskit_experiments.library import FineXAmplitude
Detecting over- and under-rotated pulses¶
We now run the error amplifying experiments with our own pulse schedules on which we
purposefully add over- and under-rotations to observe their effects. To do this, we
create an instruction to schedule map which we populate with the schedules we wish to
work with. This instruction schedule map is then given to the transpile options of the
experiment so that the Qiskit transpiler can attach the pulse schedules to the gates in
the experiments. We base all our pulses on the default \(X\) pulse of
SingleTransmonTestBackend
.
x_pulse = backend.defaults().instruction_schedule_map.get('x', (qubit,)).instructions[0][1].pulse
d0, inst_map = pulse.DriveChannel(qubit), pulse.InstructionScheduleMap()
We now take the ideal \(X\) pulse amplitude reported by the backend and add/subtract a 2% over/underrotation to it by scaling the ideal amplitude and see if the experiment can detect this over/underrotation. We replace the default \(X\) pulse in the instruction schedule map with this over/under-rotated pulse.
ideal_amp = x_pulse.amp
over_amp = ideal_amp*1.02
under_amp = ideal_amp*0.98
print(f"The reported amplitude of the X pulse is {ideal_amp:.4f} which we set as ideal_amp.")
print(f"we use {over_amp:.4f} amplitude for overrotation pulse and {under_amp:.4f} for underrotation pulse.")
# build the over rotated pulse and add it to the instruction schedule map
with pulse.build(backend=backend, name="x") as x_over:
pulse.play(pulse.Drag(x_pulse.duration, over_amp, x_pulse.sigma, x_pulse.beta), d0)
inst_map.add("x", (qubit,), x_over)
The reported amplitude of the X pulse is 0.0582 which we set as ideal_amp.
we use 0.0594 amplitude for overrotation pulse and 0.0570 for underrotation pulse.
Let’s look at one of the circuits of the FineXAmplitude
experiment. To
calibrate the \(X\) gate, we add an \(SX\) gate before the \(X\) gates to
move the ideal population to the equator of the Bloch sphere where the sensitivity to
over/under rotations is the highest.
overamp_exp = FineXAmplitude((qubit,), backend=backend)
overamp_exp.set_transpile_options(inst_map=inst_map)
overamp_exp.circuits()[4].draw(output="mpl", style="iqp")
# do the experiment
exp_data_over = overamp_exp.run(backend).block_for_results()
exp_data_over.figure(0)
The ping-pong pattern on the figure indicates an over-rotation which makes the initial state rotate more than \(\pi\).
We now look at a pulse with an under rotation to see how the FineXAmplitude
experiment detects this error. We will compare the results to the over-rotation above.
# build the under rotated pulse and add it to the instruction schedule map
with pulse.build(backend=backend, name="x") as x_under:
pulse.play(pulse.Drag(x_pulse.duration, under_amp, x_pulse.sigma, x_pulse.beta), d0)
inst_map.add("x", (qubit,), x_under)
# do the experiment
underamp_exp = FineXAmplitude((qubit,), backend=backend)
underamp_exp.set_transpile_options(inst_map=inst_map)
exp_data_under = underamp_exp.run(backend).block_for_results()
exp_data_under.figure(0)
Similarly to the over-rotation, the under-rotated pulse creates qubit populations that do not lie on the equator of the Bloch sphere. However, compared to the ping-pong pattern of the over rotated pulse, the under rotated pulse produces an inverted ping-pong pattern. This allows us to determine not only the magnitude of the rotation error but also its sign.
# analyze the results
target_angle = np.pi
dtheta_over = exp_data_over.analysis_results("d_theta").value.nominal_value
scale_over = target_angle / (target_angle + dtheta_over)
dtheta_under = exp_data_under.analysis_results("d_theta").value.nominal_value
scale_under = target_angle / (target_angle + dtheta_under)
print(f"The ideal angle is {target_angle:.2f} rad. We measured a deviation of {dtheta_over:.3f} rad in over-rotated pulse case.")
print(f"Thus, scale the {over_amp:.4f} pulse amplitude by {scale_over:.3f} to obtain {over_amp*scale_over:.5f}.")
print(f"On the other hand, we measured a deviation of {dtheta_under:.3f} rad in under-rotated pulse case.")
print(f"Thus, scale the {under_amp:.4f} pulse amplitude by {scale_under:.3f} to obtain {under_amp*scale_under:.5f}.")
The ideal angle is 3.14 rad. We measured a deviation of 0.064 rad in over-rotated pulse case.
Thus, scale the 0.0594 pulse amplitude by 0.980 to obtain 0.05819.
On the other hand, we measured a deviation of -0.061 rad in under-rotated pulse case.
Thus, scale the 0.0570 pulse amplitude by 1.020 to obtain 0.05818.
Calibrating a \(\pi\)/2 \(X\) pulse¶
Now we apply the same principles to a different example using the calibration version of
a Fine Amplitude experiment. The amplitude of the \(SX\) gate, which is an \(X\)
pulse with half the amplitude, is calibrated with the FineSXAmplitudeCal
experiment. Unlike the FineSXAmplitude
experiment, the
FineSXAmplitudeCal
experiment does not require other gates than the \(SX\)
gate since the number of repetitions can be chosen such that the ideal population is
always on the equator of the Bloch sphere. To demonstrate the
FineSXAmplitudeCal
experiment, we create a \(SX\) pulse by dividing the
amplitude of the X pulse by two. We expect that this pulse might have a small rotation
error which we want to correct.
from qiskit_experiments.library import FineSXAmplitudeCal
amp_cal = FineSXAmplitudeCal((qubit,), cals, backend=backend, schedule_name="sx")
amp_cal.circuits()[4].draw(output="mpl", style="iqp")
Let’s run the calibration experiment:
exp_data_x90p = amp_cal.run().block_for_results()
exp_data_x90p.figure(0)
Observe, once again, that the calibrations have automatically been updated.
pd.DataFrame(**cals.parameters_table(qubit_list=[qubit, ()], parameters="amp"))[columns_to_show]
parameter | qubits | schedule | value | date_time | |
---|---|---|---|---|---|
0 | amp | (0,) | sx | 0.01454 | 2024-11-15 17:18:48.... |
1 | amp | (0,) | x | 0.02919 | 2024-11-15 17:18:39.... |
2 | amp | () | sx | 0.25000 | 2024-11-15 17:18:04.... |
3 | amp | () | x | 0.50000 | 2024-11-15 17:18:04.... |
cals.get_schedule("sx", qubit)
ScheduleBlock(Play(Drag(duration=320, sigma=80, beta=0, amp=0.0145409194, angle=0), DriveChannel(0)), name="sx", transform=AlignLeft())
If we run the experiment again, we expect to see that the updated calibrated gate will have a smaller \(d\theta\) error:
exp_data_x90p_rerun = amp_cal.run().block_for_results()
exp_data_x90p_rerun.figure(0)
See also¶
API documentation:
calibration_management
andcalibration
Qiskit Textbook: Calibrating Qubits with Qiskit Pulse