Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Circuits and exact gradients

This tutorial builds circuits, samples them, and computes gradients of expectation values in three ways. It also shows why the adjoint method is the default: its cost does not grow with the number of parameters.

import time

import matplotlib.pyplot as plt
import numpy as np

import metatron_qso as q

print("QSO", q.__version__)
QSO 1.2.0

A GHZ state

Qubit 0 is the least significant bit, so bit strings print qubit 2 first.

ghz = q.Circuit(3).h(0).cx(0, 1).cx(1, 2)
state = q.simulate(ghz)
print(np.round(state.amplitudes, 4))
counts = state.sample(2000, seed=7)
print(counts)
print("entanglement entropy of qubit 0:", state.entanglement_entropy([0]), "bit")
[0.7071+0.j 0.    +0.j 0.    +0.j 0.    +0.j 0.    +0.j 0.    +0.j
 0.    +0.j 0.7071+0.j]
{'000': 1044, '111': 956}
entanglement entropy of qubit 0: 0.9999999999999999 bit
fig, ax = plt.subplots(figsize=(5, 2.5))
labels = [q.bitstring(k, 3) for k in range(8)]
ax.bar(labels, [counts.get(b, 0) for b in labels])
ax.set_xlabel("outcome")
ax.set_ylabel("counts (2000 shots)")
plt.tight_layout()
plt.show()

An energy landscape and its derivative

One parameter θ, observable H = Z₀Z₁ − 0.5·X₀. The adjoint gradient is exact; finite differences agree to their truncation error.

c = q.Circuit(2)
theta = c.new_parameter()
c.ry(0, theta).cx(0, 1).rx(1, 0.5 * theta + 0.2)
h = q.PauliSum("Z0 Z1 - 0.5 X0")

xs = np.linspace(-np.pi, np.pi, 121)
values, adjoint, fd = [], [], []
for x in xs:
    v, g = q.expectation_and_gradient(c, h, [x])
    _, g_fd = q.expectation_and_gradient(c, h, [x], method="finite_difference")
    values.append(v)
    adjoint.append(g[0])
    fd.append(g_fd[0])
print("largest |adjoint − finite difference|:", np.max(np.abs(np.array(adjoint) - fd)))

fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(xs, values, label="⟨H⟩(θ)")
ax.plot(xs, adjoint, label="d⟨H⟩/dθ (adjoint)")
ax.set_xlabel("θ")
ax.legend()
plt.tight_layout()
plt.show()
largest |adjoint − finite difference|: 2.3853558017705723e-11

Cost of a full gradient

For an ansatz with P parameters, parameter shift needs about 2P circuit evaluations; the adjoint method needs about three, whatever P is.

h = q.transverse_field_ising(8, 1.0, 0.7)

def seconds(f, repeats=5):
    f()
    t = time.perf_counter()
    for _ in range(repeats):
        f()
    return (time.perf_counter() - t) / repeats

layers = [1, 2, 4, 8, 12]
params, t_adj, t_shift = [], [], []
for L in layers:
    ansatz = q.efficient_su2(8, L)
    theta = np.linspace(0, 1, ansatz.num_parameters)
    params.append(ansatz.num_parameters)
    t_adj.append(seconds(lambda: q.expectation_and_gradient(ansatz, h, theta)))
    t_shift.append(seconds(lambda: q.expectation_and_gradient(ansatz, h, theta, method="parameter_shift")))

fig, ax = plt.subplots(figsize=(5, 3))
ax.loglog(params, t_adj, "o-", label="adjoint")
ax.loglog(params, t_shift, "s-", label="parameter shift")
ax.set_xlabel("number of parameters")
ax.set_ylabel("seconds per gradient")
ax.legend()
plt.tight_layout()
plt.show()
print(f"{params[-1]} parameters: parameter shift is {t_shift[-1] / t_adj[-1]:.0f}× slower")

208 parameters: parameter shift is 122× slower

OpenQASM round trip

Export binds the parameters; import gives an equivalent circuit.

text = c.to_qasm([0.4], measure=True)
print(text)
again = q.Circuit.from_qasm(text)
print("fidelity:", q.simulate(c, [0.4]).fidelity(q.simulate(again)))
OPENQASM 2.0;
include "qelib1.inc";
qreg q[2];
creg c[2];
ry(0.4) q[0];
cx q[0],q[1];
rx(0.4) q[1];
measure q -> c;

fidelity: 1.0