How to reuse graph definitions in different calculations
Reapply graph nodes for multiple applications
In Boulder Opal, graph definitions can be easily carried over between different graphs. For example, the nodes used to create a Hamiltonian can first be used in an optimization task, and then later used in simulation. This is especially useful if one is changing properties of the graph that could not be easily batched together, for example, the dimension of the Fock space or the structure of the Hamiltonian. If instead you want to perform a simulation using the results of an optimization in a single calculation, see this user guide.
Below we show an example of reusing nodes for optimization and simulation in separate graphs.
Summary workflow
1. Define a function for the nodes to be reused
For convenience, start by creating a function that contains the nodes to be reused.
One of the arguments of the function should be graph
so that the function can be used to add nodes on to the graph.
The function should also be passed any parameters required to define the nodes.
After these nodes are added to graph, any nodes required to compute the graph (for example the Hamiltonian) should be returned.
For example, one might define a function to create an infidelity node for a given Hamiltonian and some target:
def create_infidelity(graph, hamiltonian):
target = graph.target(operator=graph.pauli_matrix("Y"))
return graph.infidelity_pwc(
hamiltonian=hamiltonian,
target=target,
name="infidelity",
)
2. Define and execute the computational graph
Set up a graph object to carry out some task, be it to perform a control optimization or simulation, using the previously defined function.
With the graph object created, an optimization can be run using the boulderopal.run_optimization
function or a simulation can be run using the boulderopal.execute_graph
.
3. Define and execute other graphs
Reusing the convenience function, define a new graph that carries out a related task. For example, if the first calculation is an optimization, then the new graph might be a noisy simulation or a quasi-static parameter scan with the optimized results.
Example: Reusing nodes for simulation and optimization of a qubit
In this example we will implement a Y gate in a noisy single-qubit system. The system is described by a Hamiltonian of the form, \begin{equation} H(t) = \alpha(t) \sigma_{z} + \frac{1}{2}\left(\gamma(t)\sigma_{-} + \gamma^*(t)\sigma_{+}\right) + \delta \sigma_{z} + \beta(t) \sigma_{z} \end{equation} where, $\sigma_{z}$ is the Pauli Z operator, $\sigma_{\pm}$ are the qubit ladder operators, $\alpha(t)$ and $\gamma(t)$ are, respectively, real and complex optimizable time-dependent controls, $\delta$ is the qubit detuning, and, $\beta(t)$ is a dephasing noise process.
First we will optimize $\alpha(t)$ and $\gamma(t)$ to produce a Y gate in the absence of any noise, $\beta(t)=0$. Then we will optimize the controls in the presence of a dephasing amplitude on the order of $\beta(t)=\beta_0$. Finally we will test how both sets of controls perform in the presence of real stochastic noise sampled from a normal distribution centered at $\beta_0$. We see that for the robust controls optimized in the presence of constant noise, the associated infidelity is significantly lower than for the controls optimized without any noise.
To reduce the amount of code required nodes are reused by defining functions to create the optimiziable controls, compute the Hamiltonian and to compute infidelity. In total three graphs are used, two for optimizating the controls and another for simulation.
import numpy as np
import boulderopal as bo
def create_optimizable_controls(graph):
"""Create the optimizable controls α(t) and γ(t)."""
# Maximum value for |α(t)|.
alpha_max = 2 * np.pi * 0.25e6 # rad/s
# Real PWC signal representing α(t).
alpha = graph.real_optimizable_pwc_signal(
segment_count=segment_count,
duration=duration,
minimum=-alpha_max,
maximum=alpha_max,
name="$\\alpha$",
)
# Maximum value for |γ(t)|.
gamma_max = 2 * np.pi * 0.5e6 # rad/s
# Complex PWC signal representing γ(t)
gamma = graph.complex_optimizable_pwc_signal(
segment_count=segment_count,
duration=duration,
maximum=gamma_max,
name="$\\gamma$",
)
return alpha, gamma
def create_noiseless_hamiltonian(graph, alpha_control, gamma_control):
"""Create the noiseless Hamiltonian from the controls α(t) and γ(t)."""
# Detuning δ.
delta = 2 * np.pi * 0.25e6 # rad/s
detuning_hamiltonian = delta * graph.pauli_matrix("Z")
# Control Hamiltonian.
control_hamiltonian = alpha_control * graph.pauli_matrix(
"Z"
) + graph.hermitian_part(gamma_control * graph.pauli_matrix("M"))
# Total Hamiltonian.
return detuning_hamiltonian + control_hamiltonian
def create_infidelity(graph, hamiltonian, noise_operators=None):
"""
Create the infidelity node with the option to pass a noise operator.
If a noise operator is passed then the operational infidelity plus
filter function values is calculated.
"""
# Target operation node.
target = graph.target(operator=graph.pauli_matrix("Y"))
# Compute infidelity.
graph.infidelity_pwc(
hamiltonian=hamiltonian,
noise_operators=noise_operators,
target=target,
name="infidelity",
)
# Pulse parameters.
segment_count = 50
duration = 10e-6 # s
# Dephasing noise amplitude.
beta_0 = 2 * np.pi * 20e3 # rad/s
# Generate noiseless optimized controls.
graph = bo.Graph()
# Use convenience functions to create the computational graph.
alpha, gamma = create_optimizable_controls(graph)
hamiltonian = create_noiseless_hamiltonian(graph, alpha, gamma)
create_infidelity(graph, hamiltonian)
optimization_result = bo.run_optimization(
graph=graph,
cost_node_name="infidelity",
output_node_names=["$\\alpha$", "$\\gamma$"],
optimization_count=4,
)
print(f"Optimized noiseless cost: {optimization_result['cost']:.3e}")
# Retrieve values of the PWC controls α(t) and γ(t).
alpha_values_noiseless = optimization_result["output"]["$\\alpha$"]["values"]
gamma_values_noiseless = optimization_result["output"]["$\\gamma$"]["values"]
Your task (action_id="1829137") has started.
Your task (action_id="1829137") has completed.
Optimized noiseless cost: 4.885e-15
# Generate robust optimized controls.
graph = bo.Graph()
# Use convenience functions to create the computational graph.
alpha, gamma = create_optimizable_controls(graph)
hamiltonian = create_noiseless_hamiltonian(graph, alpha, gamma)
# This time passing a constant dephasing term with amplitude beta
# to `compute_infidelity`.
noise_operators = [beta_0 * graph.pauli_matrix("Z")]
create_infidelity(graph, hamiltonian, noise_operators)
optimization_result = bo.run_optimization(
graph=graph,
cost_node_name="infidelity",
output_node_names=["$\\alpha$", "$\\gamma$"],
optimization_count=4,
)
print(f"Optimized robust cost: {optimization_result['cost']:.3e}")
# Retrieve values of the robust PWC controls α(t) and γ(t).
alpha_values_robust = optimization_result["output"]["$\\alpha$"]["values"]
gamma_values_robust = optimization_result["output"]["$\\gamma$"]["values"]
Your task (action_id="1829138") is queued.
Your task (action_id="1829138") has started.
Your task (action_id="1829138") has completed.
Optimized robust cost: 5.564e-13
# Run this number of simulations with different noise trajectories.
simulation_count = 100
# Create a new Boulder Opal graph.
graph = bo.Graph()
# Create a batch of real PWC signals representing α(t) using the optimized values.
alpha_values = np.array([alpha_values_noiseless, alpha_values_robust])
alpha = graph.pwc_signal(values=alpha_values, duration=duration)
# Create a batch of complex PWC signals representing γ(t) using the optimized values.
gamma_values = np.array([gamma_values_noiseless, gamma_values_robust])
gamma = graph.pwc_signal(values=gamma_values, duration=duration)
# System Hamiltonian.
system_hamiltonian = create_noiseless_hamiltonian(graph, alpha, gamma)
# Noise Hamiltonian.
noise = graph.random.normal(
shape=(simulation_count, 2, segment_count),
mean=beta_0,
standard_deviation=2e4,
seed=0,
)
noise_pwc = graph.pwc_signal(noise, duration=duration, name="noise_pwc")
noise_hamiltonian = noise_pwc * graph.pauli_matrix("Z")
# Combine the system and the noise Hamiltonian to get the total Hamiltonian.
hamiltonian = system_hamiltonian + noise_hamiltonian
create_infidelity(graph, hamiltonian)
result = bo.execute_graph(graph=graph, output_node_names="infidelity")
infidelity = result["output"]["infidelity"]["value"]
print("Infidelity of pulse optimized without noise")
print(f"\tMean: {infidelity[:, 0].mean():.3}")
print(f"\tStd : {infidelity[:, 0].std():.3}")
print("\nInfidelity of pulse optimized with noise")
print(f"\tMean: {infidelity[:, 1].mean():.3}")
print(f"\tStd : {infidelity[:, 1].std():.3}")
Your task (action_id="1829139") has started.
Your task (action_id="1829139") has completed.
Infidelity of pulse optimized without noise
Mean: 0.534
Std : 0.0189
Infidelity of pulse optimized with noise
Mean: 0.0222
Std : 0.00352