How to optimize controls using gradient-free optimization
Perform graph-based optimizations when gradients are costly
In addition to a highly-flexible optimization engine for general-purpose gradient-based optimization, Boulder Opal also features a gradient-free optimizer which can be directly applied to model-based control optimization for arbitrary-dimensional quantum systems.
The gradient-free optimizer is exposed by the boulderopal.run_gradient_free_optimization
function, which works similarly to boulderopal.run_optimization
, with most of the parameters overlapping between the two functions.
While the gradient-based optimization is more likely to find better results quicker, the gradient-free optimizer is useful in cases where the gradient is either very costly to compute or inaccessible (for example if the graph includes a node that does not allow gradients).
Also since the gradient is not computed, the memory requirements for the gradient-free optimizer are much lower.
The optimization engine from Boulder Opal allows the user to express their system Hamiltonians as almost-arbitrary functions of the controllable parameters. The underlying structure of this map is a graph, which defines the cost function and can be efficiently evaluated. The resulting optimized controls thus achieve the desired objectives within the constraints imposed by the user-defined Hamiltonian structure.
The example in this user guide illustrates how to optimize multiple controls under different constraints in a single system using the gradient-free optimizer.
Summary workflow
1. Define the computational graph
The Boulder Opal optimization engine expresses all optimization problems as data flow graphs, which you can create by initializing a boulderopal.Graph
class.
The methods of the graph object allow you to represent the mathematical structure of the problem that you want to solve.
For an optimization, a typical workflow is to:
- Create "signals", or scalar-valued functions of time, which typically represent control pulses.
- Create "operators", or matrix-valued functions of time, by modulating constant operators by signals. These typically represent terms of a Hamiltonian.
- Combine the operators into a single Hamiltonian operator.
- Calculate the optimization cost function (typically an infidelity) from the Hamiltonian.
2. Run graph-based gradient-free optimization
You can calculate an optimization from an input graph using the boulderopal.run_gradient_free_optimization
function.
Provide the name of the node of the graph that represents the cost, and this function will return the optimized value of the output nodes that you requested.
Unlike boulderopal.run_optimization
, which uses the gradient and halts when it has converged to a minimum, boulderopal.run_gradient_free_optimization
cannot rely on such convergence criteria.
Instead, the function stops after performing iteration_count
iterations (which defaults to 100), unless the cost reaches a value below target_cost
(if you provide one).
Example: Optimal control of a single qubit
This example shows how to optimize a Hamiltonian with multiple controls. Specifically, consider a single-qubit system represented by the following Hamiltonian:
\begin{equation}H(t) = \frac{\nu}{2} \sigma_{z} + \frac{1}{2}\left[\gamma(t)\sigma_{-} + \gamma^*(t)\sigma_{+}\right] + \frac{\alpha(t)}{2} \sigma_{z} ,\end{equation}
where $\nu$ is the qubit detuning, $\gamma(t)$ and $\alpha(t)$ are, respectively, complex and real time-dependent pulses, $\sigma_{\pm}$ are the qubit ladder operators, and $\sigma_{z}$ is the Pauli-Z operator.
The functions of time $\gamma(t)$ and $\alpha(t)$ are not predetermined, and instead are optimized by Boulder Opal in order to achieve the target operation, which in this case is a Y-gate.
import numpy as np
import qctrlvisualizer as qv
import boulderopal as bo
# Define physical constants.
nu = 2 * np.pi * 5e5 # rad/s
gamma_max = 2 * np.pi * 3e5 # rad/s
alpha_max = 2 * np.pi * 1e5 # rad/s
cutoff_frequency = 5e6 # Hz
segment_count = 50
duration = 10e-6 # s
graph = bo.Graph()
# Create the time-independent detuning term.
detuning = nu * graph.pauli_matrix("Z") / 2
# Add a cosine envelope to the signals, to ensure
# they go to zero at the beginning and end of pulse.
cos_envelope = graph.signals.cosine_pulse_pwc(
duration=duration, segment_count=256, amplitude=1.0
)
# Create an optimizable complex-valued piecewise-constant (PWC) signal.
rough_gamma = graph.complex_optimizable_pwc_signal(
segment_count=segment_count, maximum=gamma_max, duration=duration
)
# Smooth the signal.
gamma = graph.filter_and_resample_pwc(
pwc=rough_gamma,
segment_count=256,
kernel=graph.sinc_convolution_kernel(cutoff_frequency),
)
gamma = gamma * cos_envelope
gamma.name = r"$\gamma$"
# Create a PWC operator representing the drive term.
drive = graph.hermitian_part(gamma * graph.pauli_matrix("M"))
# Create an optimizable real-valued PWC signal.
rough_alpha = graph.real_optimizable_pwc_signal(
segment_count=segment_count,
minimum=-alpha_max,
maximum=alpha_max,
duration=duration,
)
# Smooth the signal.
alpha = graph.filter_and_resample_pwc(
pwc=rough_alpha,
segment_count=256,
kernel=graph.sinc_convolution_kernel(cutoff_frequency),
)
alpha = alpha * cos_envelope
alpha.name = r"$\alpha$"
# Create a PWC operator representing the clock shift term.
shift = alpha * cos_envelope * graph.pauli_matrix("Z") / 2
# Define the total Hamiltonian and the target operation.
hamiltonian = detuning + drive + shift
target = graph.target(graph.pauli_matrix("Y"))
# Create the infidelity.
infidelity = graph.infidelity_pwc(hamiltonian, target, name="infidelity")
# Run the optimization.
result = bo.run_gradient_free_optimization(
graph=graph,
cost_node_name="infidelity",
output_node_names=[r"$\alpha$", r"$\gamma$"],
target_cost=5e-3,
)
print(f"Optimized cost:\t{result['cost']:.3e}")
# Plot the optimized controls.
qv.plot_controls(result["output"])
Your task (action_id="1824881") is queued.
Your task (action_id="1824881") has started.
Your task (action_id="1824881") has completed.
Optimized cost: 5.715e-03