Skip to content

Parameterized quantum circuits [Experimental]

Variational circuits are a class of circuits which are composed of continuously parameterized gates. These parameters are generally optimized to meet a certain functionality, such as generating a target output quantum state. Such variational circuits are attractive in the NISQ era, where quantum devices are not error-corrected and limited to shallow (i.e., few-gate) circuits.

We use JAX and its ecosystem of associated libraries, such as Optax, for automatically differentiation our circuits and optimzation via a gradient descent algorithm.

%load_ext autoreload
%autoreload 2
# to use differentiation, we must set the array library to 'jax' at the top of the script
import graphiq

graphiq.DENSITY_MATRIX_ARRAY_LIBRARY = "jax"

import optax
import jax
import matplotlib.pyplot as plt
import tqdm

from graphiq.circuit import ops
from graphiq.circuit.circuit_dag import CircuitDAG
import graphiq.backends.density_matrix.functions as dmf
from graphiq.backends.density_matrix.compiler import DensityMatrixCompiler
from graphiq.metrics import Infidelity

import graphiq.benchmarks.circuits

As usual, we start with our target state. For this tutorial, choose any of the Bell state, 3- or 4-qubit GHZ state. We will use the infidelity as our metric to minimize. Currently, variational circuits are only supported for the DensityMatrix backend.

_, target = graphiq.benchmarks.circuits.bell_state_circuit()

compiler = DensityMatrixCompiler()
metric = Infidelity(target=target)
circuit = CircuitDAG(n_emitter=target.n_qubits, n_photon=0, n_classical=0)

circuit.add(
    ops.ParameterizedOneQubitRotation(
        register=0,
        reg_type="e",
        params=(0.0, 1.0, 1.0),  # set the parameters explicitly, if desired
    )
)
for i in range(1, target.n_qubits):
    circuit.add(
        ops.ParameterizedControlledRotationQubit(
            control=i - 1, control_type="e", target=i, target_type="e"
        )
    )

circuit.draw_circuit()

params = circuit.initialize_parameters()  # randomly sample the initial parameters
No description has been provided for this image

We wrap all the functions for computing the metric loss value into a single function, where the first parameter is our parameter dictionary.

def compute_loss(params: dict, circuit: CircuitDAG):
    circuit.parameters = params
    output_state = compiler.compile(circuit)
    cost = metric.evaluate(output_state, circuit)
    return cost


compute_loss(params, circuit)
0.8265726022284829

Now, we define our Optax optimizer to use. For this demonstration, we will use the Adagrad algorithm, but many other popular optimizers exist and are already implemented in Optax. These include Adam, SGD, RMSProp, etc.

learning_rate = 0.4
n_step = 100
progress = True
optimizer = optax.adagrad(learning_rate)
opt_state = optimizer.init(params)

Here is the main learning loop. We iterate n_step times in our gradient-descent algorithm. Each loss value is stored to an array for plotting the learning curve later.

cost_curve = []
for step in (pbar := tqdm.tqdm(range(n_step), disable=(not progress))):
    loss = compute_loss(params, circuit)
    grads = jax.grad(compute_loss)(params, circuit)
    updates, opt_state = optimizer.update(grads, opt_state)
    params = optax.apply_updates(params, updates)
    cost_curve.append(loss)
    if progress:
        pbar.set_description(f"Cost: {loss:.10f}")
    else:
        print(step, loss, params)
  0%|          | 0/100 [00:00<?, ?it/s]

---------------------------------------------------------------------------
TracerArrayConversionError                Traceback (most recent call last)
Cell In[9], line 4
      2 for step in (pbar := tqdm.tqdm(range(n_step), disable=(not progress))):
      3     loss = compute_loss(params, circuit)
----> 4     grads = jax.grad(compute_loss)(params, circuit)
      5     updates, opt_state = optimizer.update(grads, opt_state)
      6     params = optax.apply_updates(params, updates)

    [... skipping hidden 10 frame]

Cell In[7], line 3, in compute_loss(params, circuit)
      1 def compute_loss(params: dict, circuit: CircuitDAG):
      2     circuit.parameters = params
----> 3     output_state = compiler.compile(circuit)
      4     cost = metric.evaluate(output_state, circuit)
      5     return cost

File ~/Library/CloudStorage/OneDrive-UniversityofWaterloo/Desktop/1 - Projects/AF Inverse Graph Design/graph-compiler/graphiq/backends/compiler_base.py:121, in CompilerBase.compile(self, circuit, initial_state)
    118     no_noise = no_noise or isinstance(op.noise, nm.NoNoise)
    120 if no_noise:
--> 121     self.compile_one_gate(
    122         state, op, circuit.n_quantum, q_index, classical_registers
    123     )
    125 else:
    126     if is_controlled_op:

File ~/Library/CloudStorage/OneDrive-UniversityofWaterloo/Desktop/1 - Projects/AF Inverse Graph Design/graph-compiler/graphiq/backends/density_matrix/compiler.py:86, in DensityMatrixCompiler.compile_one_gate(self, state, op, n_quantum, q_index, classical_registers)
     80     pass  # TODO: should think about best way to handle inputs/outputs
     82 elif isinstance(op, ops.OneQubitOperationBase):
     83     unitary = dm.get_one_qubit_gate(
     84         n_quantum,
     85         q_index(op.register, op.reg_type),
---> 86         self.ops[op.__class__](*params),
     87     )
     88     state.apply_unitary(unitary)
     90 elif isinstance(op, ops.ControlledPairOperationBase):

File ~/Library/CloudStorage/OneDrive-UniversityofWaterloo/Desktop/1 - Projects/AF Inverse Graph Design/graph-compiler/graphiq/backends/density_matrix/compiler.py:31, in DensityMatrixCompiler.<lambda>(theta, phi, lam)
     19 # TODO: [longer term] refactor to take a QuantumState object input instead of creating its own initial state?
     21 name = "dm"
     22 ops = {  # the accepted operations and the single-qubit action needed for each gate
     23     ops.Input: lambda: None,
     24     ops.Identity: lambda: None,
     25     ops.Phase: lambda: dm.phase(),
     26     ops.PhaseDagger: lambda: dm.phase_dag(),
     27     ops.Hadamard: lambda: dm.hadamard(),
     28     ops.SigmaX: lambda: dm.sigmax(),
     29     ops.SigmaY: lambda: dm.sigmay(),
     30     ops.SigmaZ: lambda: dm.sigmaz(),
---> 31     ops.ParameterizedOneQubitRotation: lambda theta, phi, lam: dm.parameterized_one_qubit_unitary(
     32         theta, phi, lam
     33     ),
     34     ops.ParameterizedControlledRotationQubit: lambda theta, phi, lam: dm.parameterized_one_qubit_unitary(
     35         theta, phi, lam
     36     ),
     37     ops.CNOT: lambda: dm.sigmax(),
     38     ops.CZ: lambda: dm.sigmaz(),
     39     ops.ClassicalCNOT: lambda: dm.sigmax(),
     40     ops.ClassicalCZ: lambda: dm.sigmaz(),
     41     ops.MeasurementZ: lambda: dm.sigmaz(),
     42     ops.MeasurementCNOTandReset: lambda: dm.sigmax(),
     43     ops.Output: lambda: None,
     44 }
     46 def __init__(self, *args, **kwargs):
     47     """
     48     Create a compiler which acts on a DensityMatrix state representation
     49 
     50     :return: nothing
     51     :rtype: None
     52     """

File ~/Library/CloudStorage/OneDrive-UniversityofWaterloo/Desktop/1 - Projects/AF Inverse Graph Design/graph-compiler/graphiq/backends/density_matrix/functions.py:794, in parameterized_one_qubit_unitary(theta, phi, lam)
    775 def parameterized_one_qubit_unitary(theta, phi, lam):
    776     """
    777     Define a generic 3-parameter one-qubit unitary gate.
    778 
   (...)
    790     :rtype: numpy.ndarray
    791     """
    792     gate = np.array(
    793         [
--> 794             [np.cos(theta / 2), -np.exp(1j * lam) * np.sin(theta / 2)],
    795             [
    796                 np.exp(1j * phi) * np.sin(theta / 2),
    797                 np.exp(1j * (phi + lam)) * np.cos(theta / 2),
    798             ],
    799         ]
    800     )
    801     return gate

File ~/.virtualenvs/graph-compiler/lib/python3.9/site-packages/jax/_src/core.py:640, in Tracer.__array__(self, *args, **kw)
    639 def __array__(self, *args, **kw):
--> 640   raise TracerArrayConversionError(self)

TracerArrayConversionError: The numpy.ndarray conversion method __array__() was called on traced array with shape float32[].
See https://jax.readthedocs.io/en/latest/errors.html#jax.errors.TracerArrayConversionError

Plot the resulting state to see how the optimization performed.

circuit.parameters = params
output_state = compiler.compile(circuit)
output_state.dm.draw()

fig, ax = plt.subplots(1, 1)
ax.plot(cost_curve)
ax.set(xlabel="Optimization Step", ylabel="Infidelity")
plt.show()
No description has been provided for this image
No description has been provided for this image

Now we do it with the GradientDescentSolver.

from src.solvers.gradient_descent_solver import GradientDescentSolver, adagrad
optimizer = adagrad(learning_rate=0.4)
solver = GradientDescentSolver(
    target=target,
    metric=metric,
    compiler=compiler,
    circuit=circuit,
    optimizer=optimizer,
    n_step=n_step,
)
cost_curve, params = solver.solve()
Cost: 0.0000000596: 100%|██████████| 100/100 [00:13<00:00,  7.48it/s]

fig, ax = plt.subplots(1, 1)
ax.plot(cost_curve)
ax.set(xlabel="Optimization Step", ylabel="Infidelity")
plt.show()
No description has been provided for this image