GRAPE Demos

using Piccolo
using Optim
using LinearAlgebra
using SparseArrays
using CairoMakie

# useful
const ⊗ = kron
kron (generic function with 61 methods)

Goals

  • Learn the quantum isomorphisms that map variables to real-valued state vectors
  • Study how gradient descent and Newton's method can be used to optimize quantum controls.

I. Isomorphisms

Piccolo isomorphisms

  • The standard quantum states are kets, $|\psi\rangle$, and Unitaries, $U$.
  • Open quantum system require density matrices, $\rho$, and quantum channels, $\Phi$.
  • Standard quantum states have an open system counterpart,

\[\begin{align} \text{closed} &\longrightarrow \text{open} \\ \hline |\psi\rangle &\longrightarrow |\psi\rangle \langle \psi | \\ U &\longrightarrow U \cdot U^\dagger \end{align}\]

🚧 ⚠️ If you are seeing a lot of boxes like Ũ⃗, it is very useful to have the JuliaMono fonts for Piccolo. Install and change the default font family.

# Ok, so it's not technically a wavefunction
ψ = [1; 2] + im * [3; 4]

ψ̃ = ket_to_iso(ψ)
4-element Vector{Int64}:
 1
 2
 3
 4
iso_to_ket(ψ̃)
2-element Vector{Complex{Int64}}:
 1 + 3im
 2 + 4im
# We often need to convert a complex matrix U to a real vector, Ũ⃗.
U = [1 5; 2 6] + im * [3 7; 4 8]
2×2 Matrix{Complex{Int64}}:
 1+3im  5+7im
 2+4im  6+8im

Remember what you learned about Julia arrays! Why would I write the matrix this way?

Ũ⃗ = operator_to_iso_vec(U)
8-element Vector{Int64}:
 1
 2
 3
 4
 5
 6
 7
 8
iso_vec_to_operator(Ũ⃗)
2×2 Matrix{Complex{Int64}}:
 1+3im  5+7im
 2+4im  6+8im

Physics check: What's an efficiency that we might be able to leverage when storing $\rho$ that you don't see here?

# Warning: The isomorphism `density_to_iso_vec` is not the same as `operator_to_iso_vec`.
ρ = [1 2; 3 4] + im * [5 6; 7 8]
ρ̃⃗ = density_to_iso_vec(ρ)
8-element Vector{Int64}:
 1
 3
 2
 4
 5
 7
 6
 8

Exercise

  • Just how big are these vectors for a single qubit state? A two qubit state?
  • What about quantum channels?

II. Quantum dynamics

Quantum systems

First up, we are going to look at some dynamics convenience functions in Piccolo.

  • Let's flip a qubit from the ground state to the excited state.
  • Introduce the isomorphisms that make quantum dynamics real-valued.
  • Use PiccoloQuantumObjects to make a quantum system.
  • Use a rollout to integrate the quantum system forward in time.

\[H(u(t)) = \underbrace{u_1(t) XI + u_2(t) YI}_\text{qubit 1} + \underbrace{u_3(t) IX + u_4(t) IY}_\text{qubit 2} + \underbrace{u_5(t) XX}_\text{coupling}\]

H_drives = [
    PAULIS.X ⊗ PAULIS.I,
    PAULIS.Y ⊗ PAULIS.I,
    PAULIS.I ⊗ PAULIS.X,
    PAULIS.I ⊗ PAULIS.Y,
    PAULIS.X ⊗ PAULIS.X
]

system = QuantumSystem(H_drives)
QuantumSystem: levels = 4, n_drives = 5
  • Quantum systems contain the operators we need, including the real valued versions.
get_drift(system)
4×4 SparseArrays.SparseMatrixCSC{ComplexF64, Int64} with 0 stored entries:
     ⋅          ⋅          ⋅          ⋅    
     ⋅          ⋅          ⋅          ⋅    
     ⋅          ⋅          ⋅          ⋅    
     ⋅          ⋅          ⋅          ⋅    
  • Quick check: What do we expect to see?
get_drives(system)[1]
4×4 SparseArrays.SparseMatrixCSC{ComplexF64, Int64} with 4 stored entries:
     ⋅          ⋅      1.0+0.0im      ⋅    
     ⋅          ⋅          ⋅      1.0+0.0im
 1.0+0.0im      ⋅          ⋅          ⋅    
     ⋅      1.0+0.0im      ⋅          ⋅    
system.H(randn(system.n_drives))
4×4 SparseArrays.SparseMatrixCSC{ComplexF64, Int64} with 12 stored entries:
          ⋅             1.03208-0.0300109im  …  -1.35906+0.0im
  1.03208+0.0300109im           ⋅               0.610696-1.0478im
 0.610696+1.0478im     -1.35906+0.0im            1.03208-0.0300109im
 -1.35906+0.0im        0.610696+1.0478im                 ⋅    
  • Quick check: How big will this operator be?
system.G(randn(system.n_drives))
8×8 SparseArrays.SparseMatrixCSC{Float64, Int64} with 40 stored entries:
   ⋅         -0.748511   -0.45111    …   1.12976    -1.67731    -0.0282423
  0.748511     ⋅           ⋅              ⋅         -0.0282423  -1.67731
  0.45111      ⋅           ⋅            -0.0282423    ⋅          1.12976
   ⋅          0.45111     0.748511      -1.67731     1.12976      ⋅ 
   ⋅         -1.12976     1.67731       -0.748511   -0.45111      ⋅ 
 -1.12976      ⋅          0.0282423  …    ⋅           ⋅         -0.45111
  1.67731     0.0282423    ⋅              ⋅           ⋅         -0.748511
  0.0282423   1.67731    -1.12976        0.45111     0.748511     ⋅ 
  • We can use a system to perform a rollout.
# Timing information (e.g. 20 ns superconducting qubit gate)
T = 40
Δt = 0.5
timesteps = fill(Δt, T)
40-element Vector{Float64}:
 0.5
 0.5
 0.5
 0.5
 0.5
 0.5
 0.5
 0.5
 0.5
 0.5
 ⋮
 0.5
 0.5
 0.5
 0.5
 0.5
 0.5
 0.5
 0.5
 0.5
# Controls
controls = randn(system.n_drives, T + 1);
5×41 Matrix{Float64}:
 -0.192976   1.51629    -1.27529   …  -0.809342    0.791013     0.771139
  1.68979   -0.279634   -0.767858     -0.0849856   0.404975    -0.0782606
  0.315975  -0.23213     0.375845     -0.301382    0.582139     0.46862
  0.166577  -0.0600409  -1.25386       0.542118   -1.86174      0.836567
 -0.260813  -1.46123     1.65408       0.0265883   0.00184781   0.95562
unitary_rollout(controls, timesteps, system)
32×41 Matrix{Float64}:
 1.0   0.641823     0.457662  …  -0.011918    0.127227    0.120502
 0.0   0.0435325    0.329316      0.372037    0.220352    0.399733
 0.0   0.750614     0.366133      0.544586    0.228119    0.485619
 0.0   0.0722386    0.32301      -0.0348248   0.142998   -0.374511
 0.0   0.00457179  -0.347449      0.564748    0.747483    0.348779
 0.0  -0.102446     0.343685  …  -0.0946765   0.125117   -0.498224
 0.0   0.0836819   -0.222096     -0.470652   -0.511475   -0.189459
 0.0   0.00305514   0.394387      0.119336    0.163644    0.209377
 0.0  -0.0654048    0.290355      0.22644     0.103573    0.368296
 1.0   0.641824     0.405682      0.170323    0.367169    0.00992179
 ⋮                            ⋱                           ⋮
 0.0  -0.102446    -0.373989     -0.379692   -0.0704556  -0.702964
 0.0   0.0776745   -0.317856      0.587627    0.41328     0.698375
 0.0  -0.714618    -0.274229  …   0.281902    0.215013   -0.0745987
 0.0  -0.0654048    0.314768      0.0877565   0.197407   -0.128976
 1.0   0.641823     0.376557     -0.523956   -0.597694   -0.440975
 0.0   0.22295      0.448272     -0.14678    -0.0760651  -0.0885984
 0.0   0.0826485   -0.256464      0.245497    0.0765019   0.147365
 0.0  -0.102543    -0.410124  …   0.224435    0.208211    0.481541
 0.0   0.00457179   0.384667      0.401011    0.575996    0.184841
# Entangling gate
U_goal = GATES.CX

# How'd we do?
println("ℱ = ", unitary_rollout_fidelity(U_goal, controls, timesteps, system))
ℱ = 0.04016281101271073

We have all the pieces we need to solve!

Let's put Piccolo to work.

# Piccolo (we'll learn more about this later)
prob = UnitarySmoothPulseProblem(system, U_goal, T, Δt);
DirectTrajOptProblem
   timesteps            = 40
   duration             = 19.5
   variable names       = (:Ũ⃗, :a, :da, :dda, :Δt)
   knot point dimension = 48
# save these initial controls for later
a_init = prob.trajectory.a
plot(prob.trajectory, :a)
Example block output
solve!(
    prob,
    max_iter=20, print_level=1, verbose=false, options=IpoptOptions(eval_hessian=false)
)

ℱ = unitary_rollout_fidelity(prob.trajectory, system)

println("The fidelity is ", ℱ)

******************************************************************************
This program contains Ipopt, a library for large-scale nonlinear optimization.
 Ipopt is released as open source code under the Eclipse Public License (EPL).
         For more information visit https://github.com/coin-or/Ipopt
******************************************************************************

The fidelity is 0.9791586907388493
a_final = prob.trajectory.a
plot(prob.trajectory, :a)
Example block output

III. GRAPE

The GRAPE algorithm comes from NMR in 2004, and there is a Julia version. We'll reproduce GRAPE in this example.

# We work with timesteps between knot points
timesteps = fill(Δt, T)

# Let's use our previous function to compute the fidelity
GRAPE(controls) = abs(1 - unitary_rollout_fidelity(U_goal, controls, timesteps, system))
GRAPE (generic function with 1 method)

Automatic differentiation

  • It's quick to test! Compare different algorithms, e.g., Newton(), GradientDescent(), LBFGS()
  • If you switch from gradient descent to a quasi-Newton method, you get to write another paper.
result_GRAPE = optimize(GRAPE, collect(a_init), LBFGS())
 * Status: success

 * Candidate solution
    Final objective value:     1.332268e-15

 * Found with
    Algorithm:     L-BFGS

 * Convergence measures
    |x - x'|               = 9.21e-09 ≰ 0.0e+00
    |x - x'|/|x'|          = 8.31e-09 ≰ 0.0e+00
    |f(x) - f(x')|         = 6.66e-16 ≰ 0.0e+00
    |f(x) - f(x')|/|f(x')| = 5.00e-01 ≰ 0.0e+00
    |g(x)|                 = 8.46e-09 ≤ 1.0e-08

 * Work counters
    Seconds run:   68  (vs limit Inf)
    Iterations:    59
    f(x) calls:    176
    ∇f(x) calls:   176
a_GRAPE = Optim.minimizer(result_GRAPE)
println("The fidelity is ", unitary_rollout_fidelity(U_goal, a_GRAPE, timesteps, system))
The fidelity is 0.9999999999999987
  • What do we think we'll see here?
series(cumsum(timesteps), a_GRAPE)
Example block output

Analytic gradients

Calculus practice

  • We can combine forward and backward rollouts to compute the gradients,

\[\begin{align} \frac{\partial U(T)}{\partial u_k(t)} &= U(T, t) (-i H_k \Delta t) U(t) \\ \Rightarrow \langle\psi_\text{goal} | \frac{\partial U(T)}{\partial u_k(t)} |\psi_\text{init.}\rangle &= -i \Delta t \langle\psi_\text{goal}^\text{bwd.}(t) | H_k |\psi_\text{init.}^\text{fwd.}(t) \rangle. \end{align}\]

Exercise

  • Implement gradient descent using the analytic gradients.
  • Sometimes, there are insights you can only get by opening up the black box, e.g. d-GRAPE.

IV. Function Spaces

  • Pick a function basis for the controls and optimize the coefficients. Some choices are trig functions or Slepians.
  • Our optimization parameters are now coefficients of the basis,

\[u(t) = u_0 + \sum_{j=1}^{n} c_j a_j(t)\]

  • The modes $a_j(t)$ stay fixed, and the coefficients $c_j$ are optimized.
# First n = 5 entries in a Fourier series, including the constant term
n = 5
fourier_series = [cos.(π * j * (0:T-1) / T .- π/2) for j in 0:n-1]

function get_controls(coefficients)
    a(c) = sum(cⱼ * aⱼ for (cⱼ, aⱼ) in zip(c, fourier_series))
    return stack([a(c) for c in eachrow(coefficients)], dims=1)
end

function GRAFS(coefficients)
    controls = get_controls(coefficients)
    return abs(1 - unitary_rollout_fidelity(U_goal, controls, timesteps, system))
end
GRAFS (generic function with 1 method)
c_init = rand(system.n_drives, n)
result_GRAFS = optimize(GRAFS, c_init, LBFGS())
 * Status: success

 * Candidate solution
    Final objective value:     0.000000e+00

 * Found with
    Algorithm:     L-BFGS

 * Convergence measures
    |x - x'|               = 7.44e-09 ≰ 0.0e+00
    |x - x'|/|x'|          = 5.32e-09 ≰ 0.0e+00
    |f(x) - f(x')|         = 0.00e+00 ≤ 0.0e+00
    |f(x) - f(x')|/|f(x')| = NaN ≰ 0.0e+00
    |g(x)|                 = 3.54e-08 ≰ 1.0e-08

 * Work counters
    Seconds run:   29  (vs limit Inf)
    Iterations:    91
    f(x) calls:    379
    ∇f(x) calls:   379
a_GRAFS = Optim.minimizer(result_GRAFS)
println("The fidelity is ", 1 - unitary_rollout_fidelity(U_goal, a_GRAFS, timesteps, system))
The fidelity is 0.993771770586151
series(cumsum(timesteps), get_controls(a_GRAFS))
Example block output
  • These shapes are a lot nicer! But performance depends a lot on the expressivity and initial condition.
c_init = randn(system.n_drives, n)
result_GRAFS_2 = optimize(GRAFS, c_init, LBFGS())
 * Status: success

 * Candidate solution
    Final objective value:     0.000000e+00

 * Found with
    Algorithm:     L-BFGS

 * Convergence measures
    |x - x'|               = 4.81e-13 ≰ 0.0e+00
    |x - x'|/|x'|          = 2.21e-13 ≰ 0.0e+00
    |f(x) - f(x')|         = 0.00e+00 ≤ 0.0e+00
    |f(x) - f(x')|/|f(x')| = NaN ≰ 0.0e+00
    |g(x)|                 = 9.65e-08 ≰ 1.0e-08

 * Work counters
    Seconds run:   29  (vs limit Inf)
    Iterations:    82
    f(x) calls:    365
    ∇f(x) calls:   365
a_GRAFS_2 = Optim.minimizer(result_GRAFS_2)
println("The fidelity is ", 1 - unitary_rollout_fidelity(U_goal, a_GRAFS_2, timesteps, system))
The fidelity is 0.6996978325918379
f = Figure()
ax = Axis(f[1,1])
series!(ax, cumsum(timesteps), get_controls(a_GRAFS))
ax = Axis(f[2,1])
series!(ax, cumsum(timesteps), get_controls(a_GRAFS_2))
f
Example block output

Exercise: A filtering approach

  • Pass the controls through a spectral filter: Look up Slepians and consider how to bound the bandwidth by choice of basis.
  • How might we shape the bandwidth of the controls? (Remember, we can just rely on automatic differentiation!)

V. States in costs

Exercise:

  • Let's switch to a transmon, which has more than two levels and can be leaky.

\[H(u(t)) = \tfrac{1}{2} \eta a^\dagger a^\dagger a a + u_1(t) (a + a^\dagger) - i u_2(t) (a - a^\dagger)\]

  • The optimizer can exploit the higher levels!

  • Add a leakage penalty to a guard state. Notice that working with states can be awkward.

T = 40
Δt = 0.25
timesteps = fill(Δt, T)

function Transmon(n)
    a = annihilate(n)
    x = a + a'
    p = -im * (a - a')
    η = 0.1
    return QuantumSystem(1/2 * a'a'a*a, [x, p])
end

transmon_2 = Transmon(2)
transmon_4 = Transmon(4)
QuantumSystem: levels = 4, n_drives = 2
function TransmonGRAFS(
    goal::AbstractPiccoloOperator, coefficients, timesteps, sys::AbstractQuantumSystem
)
    controls = get_controls(coefficients)
    return abs(1 - unitary_rollout_fidelity(goal, controls, timesteps, sys))
end
TransmonGRAFS (generic function with 1 method)
  • Quick aside: Embedded operators
U_emb(n) = EmbeddedOperator(GATES.X, 1:2, n)
U_emb (generic function with 1 method)
U_emb(4).operator
4×4 Matrix{ComplexF64}:
 0.0+0.0im  1.0+0.0im  0.0+0.0im  0.0+0.0im
 1.0+0.0im  0.0+0.0im  0.0+0.0im  0.0+0.0im
 0.0+0.0im  0.0+0.0im  0.0+0.0im  0.0+0.0im
 0.0+0.0im  0.0+0.0im  0.0+0.0im  0.0+0.0im
unembed(U_emb(4))
2×2 Matrix{ComplexF64}:
 0.0+0.0im  1.0+0.0im
 1.0+0.0im  0.0+0.0im
sys2, U2 = Transmon(2), U_emb(2)
c_init = randn(sys2.n_drives, n)
result_GRAFS_3 = optimize(a -> TransmonGRAFS(U2, a, timesteps, sys2), c_init, LBFGS())
 * Status: success

 * Candidate solution
    Final objective value:     8.881784e-16

 * Found with
    Algorithm:     L-BFGS

 * Convergence measures
    |x - x'|               = 2.16e-08 ≰ 0.0e+00
    |x - x'|/|x'|          = 1.02e-08 ≰ 0.0e+00
    |f(x) - f(x')|         = 2.44e-15 ≰ 0.0e+00
    |f(x) - f(x')|/|f(x')| = 2.75e+00 ≰ 0.0e+00
    |g(x)|                 = 2.93e-10 ≤ 1.0e-08

 * Work counters
    Seconds run:   0  (vs limit Inf)
    Iterations:    11
    f(x) calls:    34
    ∇f(x) calls:   34
a_GRAFS_3 = get_controls(Optim.minimizer(result_GRAFS_3))
println("The fidelity is ", unitary_rollout_fidelity(U2, a_GRAFS_3, timesteps, sys2))
The fidelity is 1.0000000000000009
  • Quick check: What might happen now?
println(
    "The fidelity is ", unitary_rollout_fidelity(U_emb(4), a_GRAFS_3, timesteps, Transmon(4))
)
The fidelity is 0.08858572794785598

TODO:

  • Add an L2 penalty to states that are not in the computational basis.
  • Use a modified GRAPE cost to penalize leakage while maintaining fidelity.
  • Study how leakage and fidelity change with the penalty.
  • Study how the anharmonicity η affects leakage.