State And Unitary Synthesis¶
import marimo as mo
State preparation¶
A common workflow in experiments is to approximate some desired quantum state \(\ket{\phi}\) by fixing a circuit structure that’s amenable to hardware and optimizing its parameters numerically. In this notebook, we’ll demonstrate the tools hybridlane has to enable this workflow.
Our goal for this first example will be to prepare the binomial code state \(\ket{\phi} = \frac{1}{\sqrt{2}}(\ket{0} + \ket{4})\) on a qumode, and we’ll use the universal gate set of \(\{SNAP, D\}\). Let’s define the ansatz as interleaving \(SNAP\) (up to 8 Fock levels) and \(D\) gates on the qumode:
import pennylane as qp
from pennylane.typing import TensorLike
import hybridlane as hl
# snap_ansatz takes an array of shape (layers, 10) as input
def snap_ansatz(x: TensorLike):
layers = hl.math.shape(x)[0]
@qp.for_loop(0, layers)
def loop_body(i):
hl.D(x[i, 0], x[i, 1], wires=0)
# hybridlane's SNAP gate acts on a single Fock level `j`, so we chain 8 of them
# to manipulate the lowest 8 energy levels of the qumode.
for j in range(8):
hl.SNAP(x[i, 2 + j], j, wires=0)
loop_body()
Now let’s define our optimization objective, which will be to maximize the fidelity \(F(\ket{\phi}, \ket{\psi(\theta)})\).
fock_level = 20
dev = qp.device("default.hybrid", fock_level=fock_level)
@qp.qnode(dev)
def state_prep(x):
snap_ansatz(x)
return hl.state()
def loss(x, phi):
psi = state_prep(x)
return 1 - hl.math.fidelity_statevector(phi, psi)
With that in place, we can optimize our circuit using Scipy. Here we’re using a small number of iterations so that the notebook runs quickly.
import numpy as np
from scipy.optimize import minimize
def _():
# Create random parameters for 3 layers
rng = np.random.default_rng(42)
x = rng.standard_normal((3, 10))
# Instantiate the binomial state
phi = hl.math.concatenate(
[
hl.math.array([1 / np.sqrt(2), 0, 0, 0, 1 / np.sqrt(2)], like=x),
hl.math.zeros(fock_level - 5, like=x),
]
)
# Scipy requires a 1D array
def scipy_loss(x):
return loss(x.reshape(3, 10), phi)
result = minimize(scipy_loss, x.flat, method="L-BFGS-B", options=dict(maxiter=10))
return result
scipy_result = _()
scipy_result
message: STOP: TOTAL NO. OF ITERATIONS REACHED LIMIT
success: False
status: 1
fun: 0.14654343871085296
x: [ 1.174e+00 -1.340e+00 ... 4.127e-01 4.308e-01]
nit: 10
jac: [-1.222e-01 1.314e-01 ... 0.000e+00 0.000e+00]
nfev: 434
njev: 14
hess_inv: <30x30 LbfgsInvHessProduct with dtype=float64>
With our 10 BFGS steps, we were able to reach an infidelity of \(0.103\), and the optimized circuit parameters could be obtained from x.
Speeding up with jax.jit¶
The above example is very slow because on each function invocation (above 434 of them to be precise), the circuit structure must be reconstructed and simulated. However the structure isn’t changing, and it’d be nice to reuse it across iterations. To speed up optimization workflows like this, hybridlane’s default.hybrid device supports JAX, which will trace the computation once and produce a highly optimized native binary that does reuse the circuit structure.
We’ll now modify the above code to be JIT-compatible. The first thing is to make sure that default.hybrid is using diff_method = "backprop" to let JAX take gradients through the entire simulation.
@qp.qnode(dev, interface="jax", diff_method="backprop")
def state_prep_jax(x):
snap_ansatz(x)
return hl.state()
def loss_jax(x, phi):
psi = state_prep_jax(x)
return 1 - hl.math.fidelity_statevector(phi, psi).real
Now we have to write a little more code than Scipy to produce the optimizer. To be compatible with JAX, we need to write it in a functional style.
import jax
import jax.numpy as jnp
import optax
# By default, jax does everything in f32, but we want double precision
jax.config.update("jax_enable_x64", True)
# With `jax.jit`, this entire comptation will be traced. Because `opt` and `maxiter` are not
# tensors, we must declare them to be "static"
@jax.jit(static_argnames=("opt", "loss", "maxiter"))
def optimize_jax(opt, x0, loss, maxiter):
# We explicitly tell jax how to update at each step. Following the
# semantics of `jax.lax.fori_loop`, this inner function takes two parameters,
# the loop iteration `i` (unused), and `val`, the state to be carried through
# each iteration.
def update(i, val):
x, opt_state = val
_, grads = jax.value_and_grad(loss)(x)
updates, opt_state = opt.update(grads, opt_state)
x = optax.apply_updates(x, updates)
return x, opt_state
val = (x0, opt.init(x0))
x, opt_state = jax.lax.fori_loop(0, maxiter, update, val)
return x
def _():
# Create parameters with same shape as before
key = jax.random.key(42)
x0 = jax.random.normal(key, (3, 10))
# Instantiate our optimizer, we'll choose the adam optimizer
opt = optax.adam(learning_rate=1e-2)
# Instantiate the binomial state
phi = hl.math.concatenate(
[
hl.math.array([1 / np.sqrt(2), 0, 0, 0, 1 / np.sqrt(2)], like=x0),
hl.math.zeros(fock_level - 5, like=x0),
]
)
# Perform the optimization
starting_loss = loss_jax(x0, phi)
x = optimize_jax(opt, x0, loss=lambda x: loss_jax(x, phi), maxiter=1000)
final_loss = loss_jax(x, phi)
return starting_loss, final_loss
starting_loss, final_loss = _()
print(f"Starting loss: {starting_loss}")
print(f"Final loss: {final_loss}")
Starting loss: 0.8034498748112241 Final loss: 0.0006827120050625357
Using JAX, we were able to achieve a much smaller loss in just a fraction of the time it took Scipy.
Unitary synthesis¶
The above ideas extend to performing numerical unitary synthesis too: define your circuit architecture with hybridlane and then throw it into a numerical optimizer. To extract the unitary matrix implemented by your circuit, hybridlane provides the hl.fock_matrix function.
To demonstrate this, we’ll attempt to produce the nonlinear Kerr gate \(K(\theta) = e^{-i\theta\hat{n}^2}\) using linear \(CR(\theta) = e^{-i\theta Z\hat{n}/2}\) gates and single-qubit rotations.
wire_dims = {0: 2, 1: fock_level}
# x has shape (layers, 5)
def cr_ansatz(x):
layers = hl.math.shape(x)[0]
@qp.for_loop(0, layers)
def loop_body(i):
qp.Rot(*x[i, 0:3], wires=0)
hl.CR(x[i, 3], wires=(0, 1))
loop_body()
def unitary(x):
return hl.fock_matrix(cr_ansatz, wire_order=(0, 1), wire_dims=wire_dims)(x)
Now define the loss function based on the unitary fidelity
where \(d\) is the dimension of our Hilbert space.
def fidelity(U, V):
d = hl.math.shape(U)[0]
norm = hl.math.trace(hl.math.dag(U) @ V)
return (hl.math.abs(norm) ** 2).real / d**2
def cr_loss(x, V):
U = unitary(x)
return 1 - fidelity(U, V)
And finally, we can use a similar optimizer loop as before
def _():
# Create parameters with required shape
key = jax.random.key(42)
x0 = jax.random.normal(key, (10, 4))
# Instantiate our optimizer, we'll choose the adam optimizer
opt = optax.adam(learning_rate=1e-2)
# Build our target unitary
op = hl.K(0.5, wires=1)
V = op.fock_matrix(wire_dims, wire_order=(0, 1))
# Perform the optimization
starting_loss = cr_loss(x0, V)
x = optimize_jax(
opt,
x0,
loss=lambda x: cr_loss(x, V),
maxiter=1000,
)
final_loss = cr_loss(x, V)
U = unitary(x)
f_u = fidelity(U, U)
return f_u, starting_loss, final_loss
f_u, starting_loss_u, final_loss_u = _()
print(f"Starting loss: {starting_loss_u}")
print(f"Final loss: {final_loss_u}")
print(f"F(U, U): {f_u}")
Starting loss: 0.9969683832671158 Final loss: 0.5744325741589539 F(U, U): 1.0000000000000004
While we haven’t gotten a “good” loss, this does illustrate the workflow to numerically synthesize unitary gates. You might play with the circuit architecture, loss function, and optimizer settings to achieve a better result.