Skip to content

Commit

Permalink
Docs, examples, correct expectation shape (#31)
Browse files Browse the repository at this point in the history
Co-authored-by: Mario Dagrada <[email protected]>
Co-authored-by: Niklas Heim <[email protected]>
Co-authored-by: Dominik Seitz <[email protected]>
Co-authored-by: Roland Guichard <[email protected]>
Co-authored-by: Joao Moutinho <[email protected]>
Co-authored-by: Vytautas Abramavicius <[email protected]>
  • Loading branch information
6 people authored Oct 3, 2023
1 parent d799bb0 commit 3a30973
Show file tree
Hide file tree
Showing 74 changed files with 5,476 additions and 63 deletions.
19 changes: 19 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,22 @@ docs/**/*.py
# event files
events.out.tfevents.*
/examples/notebooks/onboarding_sandbox.ipynb

# latex
*.aux
*.lof
*.log
*.lot
*.fls
*.out
*.toc
*.fmt
*.fot
*.cb
*.cb2
*.lb
*.pdf
*.ps
*.dvi

*.gv
137 changes: 137 additions & 0 deletions docs/advanced_tutorials/custom-models.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
In `qadence`, the `QuantumModel` is the central class point for executing
`QuantumCircuit`s. The idea of a `QuantumModel` is to decouple the backend
execution from the management of circuit parameters and desired quantum
computation output.

In the following, we create a custom `QuantumModel` instance which introduces
some additional optimizable parameters:
* an adjustable scaling factor in front of the observable to measured
* adjustable scale and shift factors to be applied to the model output before returning the result

This can be easily done using PyTorch flexible model definition, and it will
automatically work with the rest of `qadence` infrastructure.


```python exec="on" source="material-block" session="custom-model"
import torch
from qadence import QuantumModel, QuantumCircuit


class CustomQuantumModel(QuantumModel):

def __init__(self, circuit: QuantumCircuit, observable, backend="pyqtorch", diff_mode="ad"):
super().__init__(circuit, observable=observable, backend=backend, diff_mode=diff_mode)

self.n_qubits = circuit.n_qubits

# define some additional parameters which will scale and shift (variationally) the
# output of the QuantumModel
# you can use all torch machinery for building those
self.scale_out = torch.nn.Parameter(torch.ones(1))
self.shift_out = torch.nn.Parameter(torch.ones(1))

# override the forward pass of the model
# the forward pass is the output of your QuantumModel and in this case
# it's the (scaled) expectation value of the total magnetization with
# a variable coefficient in front
def forward(self, values: dict[str, torch.Tensor]) -> torch.Tensor:

# scale the observable
res = self.expectation(values)

# scale and shift the result before returning
return self.shift_out + res * self.scale_out
```

The custom model can be used like any other `QuantumModel`:
```python exec="on" source="material-block" result="json" session="custom-model"
from qadence import Parameter, RX, CNOT, QuantumCircuit
from qadence import chain, kron, total_magnetization
from sympy import acos

def quantum_circuit(n_qubits):

x = Parameter("x", trainable=False)
fm = kron(RX(i, acos(x) * (i+1)) for i in range(n_qubits))

ansatz = kron(RX(i, f"theta{i}") for i in range(n_qubits))
ansatz = chain(ansatz, CNOT(0, n_qubits-1))

block = chain(fm, ansatz)
block.tag = "circuit"
return QuantumCircuit(n_qubits, block)

n_qubits = 4
batch_size = 10
circuit = quantum_circuit(n_qubits)
observable = total_magnetization(n_qubits)

model = CustomQuantumModel(circuit, observable, backend="pyqtorch")

values = {"x": torch.rand(batch_size)}
res = model(values)
print("Model output: ", res)
assert len(res) == batch_size
```


## Quantum model with wavefunction overlaps

`QuantumModel`'s can also use different quantum operations in their forward
pass, such as wavefunction overlaps described [here](../tutorials/overlap.md). Beware that the resulting overlap tensor
has to be differentiable to apply gradient-based optimization. This is only applicable to the `"EXACT"` overlap method.

Here we show how to use overlap calculation when fitting a parameterized quantum circuit to act as a standard Hadamard gate.

```python exec="on" source="material-block" result="json" session="custom-model"
from qadence import RY, RX, H, Overlap

# create a quantum model which acts as an Hadamard gate after training
class LearnHadamard(QuantumModel):
def __init__(
self,
train_circuit: QuantumCircuit,
target_circuit: QuantumCircuit,
backend="pyqtorch",
):
super().__init__(circuit=train_circuit, backend=backend)
self.overlap_fn = Overlap(train_circuit, target_circuit, backend=backend, method="exact", diff_mode='ad')

def forward(self):
return self.overlap_fn()

# compute the wavefunction of the associated train circuit
def wavefunction(self):
return model.overlap_fn.run({})


train_circuit = QuantumCircuit(1, chain(RX(0, "phi"), RY(0, "theta")))
target_circuit = QuantumCircuit(1, H(0))

model = LearnHadamard(train_circuit, target_circuit)

# get the overlap between model and target circuit wavefunctions
print(model())
```

This model can then be trained with the standard Qadence helper functions.

```python exec="on" source="material-block" result="json" session="custom-model"
from qadence import run
from qadence.ml_tools import train_with_grad, TrainConfig

criterion = torch.nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-1)

def loss_fn(model: LearnHadamard, _unused) -> tuple[torch.Tensor, dict]:
loss = criterion(torch.tensor([[1.0]]), model())
return loss, {}

config = TrainConfig(max_iter=2500)
model, optimizer = train_with_grad(
model, None, optimizer, config, loss_fn=loss_fn
)

wf_target = run(target_circuit)
assert torch.allclose(wf_target, model.wavefunction(), atol=1e-2)
```
167 changes: 167 additions & 0 deletions docs/advanced_tutorials/differentiability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Differentiability

Many application in quantum computing and quantum machine learning more specifically requires the differentiation
of a quantum circuit with respect to its parameters.

In Qadence, we perform quantum computations via the `QuantumModel` interface. The derivative of the outputs of quantum
models with respect to feature and variational parameters in the quantum circuit can be implemented in Qadence
with two different modes:

- Automatic differentiation (AD) mode [^1]. This mode allows to differentiation both
`run()` and `expectation()` methods of the `QuantumModel` and it is the fastest
available differentiation method. Under the hood, it is based on the PyTorch autograd engine wrapped by
the `DifferentiableBackend` class. This mode is not working on quantum devices.
- Generalized parameter shift rule (GPSR) mode. This is general implementation of the well known parameter
shift rule algorithm [^2] which works for arbitrary quantum operations [^3]. This mode is only applicable to
the `expectation()` method of `QuantumModel` but it is compatible with execution or quantum devices.

## Automatic differentiation

Automatic differentiation [^1] is a procedure to derive a complex function defined as a sequence of elementary
mathematical operations in
the form of a computer program. Automatic differentiation is a cornerstone of modern machine learning and a crucial
ingredient of its recent successes. In its so-called *reverse mode*, it follows this sequence of operations in reverse order by systematically applying the chain rule to recover the exact value of derivative. Reverse mode automatic differentiation
is implemented in Qadence leveraging the PyTorch `autograd` engine.

!!! warning "Only available with PyQTorch backend"
Currently, automatic differentiation mode is only
available when the `pyqtorch` backend is selected.

## Generalized parameter shift rule

The generalized parameter shift rule implementation in Qadence was introduced in [^3]. Here the standard parameter shift rules,
which only works for quantum operations whose generator has a single gap in its eigenvalue spectrum, was generalized
to work with arbitrary generators of quantum operations.

For this, we define the differentiable function as quantum expectation value

$$
f(x) = \left\langle 0\right|\hat{U}^{\dagger}(x)\hat{C}\hat{U}(x)\left|0\right\rangle
$$

where $\hat{U}(x)={\rm exp}{\left( -i\frac{x}{2}\hat{G}\right)}$ is the quantum evolution operator with generator $\hat{G}$ representing the structure of the underlying quantum circuit and $\hat{C}$ is the cost operator. Then using the eigenvalue spectrum $\left\{ \lambda_n\right\}$ of the generator $\hat{G}$ we calculate the full set of corresponding unique non-zero spectral gaps $\left\{ \Delta_s\right\}$ (differences between eigenvalues). It can be shown that the final expression of derivative of $f(x)$ is then given by the following expression:

$\begin{equation}
\frac{{\rm d}f\left(x\right)}{{\rm d}x}=\overset{S}{\underset{s=1}{\sum}}\Delta_{s}R_{s},
\end{equation}$

where $S$ is the number of unique non-zero spectral gaps and $R_s$ are real quantities that are solutions of a system of linear equations

$\begin{equation}
\begin{cases}
F_{1} & =4\overset{S}{\underset{s=1}{\sum}}{\rm sin}\left(\frac{\delta_{1}\Delta_{s}}{2}\right)R_{s},\\
F_{2} & =4\overset{S}{\underset{s=1}{\sum}}{\rm sin}\left(\frac{\delta_{2}\Delta_{s}}{2}\right)R_{s},\\
& ...\\
F_{S} & =4\overset{S}{\underset{s=1}{\sum}}{\rm sin}\left(\frac{\delta_{M}\Delta_{s}}{2}\right)R_{s}.
\end{cases}
\end{equation}$

Here $F_s=f(x+\delta_s)-f(x-\delta_s)$ denotes the difference between values of functions evaluated at shifted arguments $x\pm\delta_s$.

## Usage

### Basics

In Qadence, the GPSR differentiation engine can be selected by passing `diff_mode="gpsr"` or, equivalently, `diff_mode=DiffMode.GPSR` to a `QuantumModel` instance. The code in the box below shows how to create `QuantumModel` instances with both AD and GPSR engines.

```python exec="on" source="material-block" session="differentiability"
from qadence import (FeatureParameter, HamEvo, X, I, Z,
total_magnetization, QuantumCircuit,
QuantumModel, BackendName, DiffMode)
import torch

n_qubits = 2

# define differentiation parameter
x = FeatureParameter("x")

# define generator and HamEvo block
generator = X(0) + X(1) + 0.2 * (Z(0) + I(1)) * (I(0) + Z(1))
block = HamEvo(generator, x)

# create quantum circuit
circuit = QuantumCircuit(n_qubits, block)

# create total magnetization cost operator
obs = total_magnetization(n_qubits)

# create models with AD and GPSR differentiation engines
model_ad = QuantumModel(circuit, obs,
backend=BackendName.PYQTORCH,
diff_mode=DiffMode.AD)
model_gpsr = QuantumModel(circuit, obs,
backend=BackendName.PYQTORCH,
diff_mode=DiffMode.GPSR)

# generate value for circuit's parameter
xs = torch.linspace(0, 2*torch.pi, 100, requires_grad=True)
values = {"x": xs}

# calculate function f(x)
exp_val_ad = model_ad.expectation(values)
exp_val_gpsr = model_gpsr.expectation(values)

# calculate derivative df/dx using the PyTorch
# autograd engine
dexpval_x_ad = torch.autograd.grad(
exp_val_ad, values["x"], torch.ones_like(exp_val_ad), create_graph=True
)[0]
dexpval_x_gpsr = torch.autograd.grad(
exp_val_gpsr, values["x"], torch.ones_like(exp_val_gpsr), create_graph=True
)[0]
```

We can plot the resulting derivatives and see that in both cases they coincide.

```python exec="on" source="material-block" session="differentiability"
import matplotlib.pyplot as plt

# plot f(x) and df/dx derivatives calculated using AD and GPSR
# differentiation engines
fig, ax = plt.subplots()
ax.scatter(xs.detach().numpy(),
exp_val_ad.detach().numpy(),
label="f(x)")
ax.scatter(xs.detach().numpy(),
dexpval_x_ad.detach().numpy(),
label="df/dx AD")
ax.scatter(xs.detach().numpy(),
dexpval_x_gpsr.detach().numpy(),
s=5,
label="df/dx GPSR")
plt.legend()
from docs import docsutils # markdown-exec: hide
print(docsutils.fig_to_html(plt.gcf())) # markdown-exec: hide
```

### Low-level control on the shift values

In order to get a finer control over the GPSR differentiation engine we can use the low-level Qadence API to define a `DifferentiableBackend`.

```python exec="on" source="material-block" session="differentiability"
from qadence import DifferentiableBackend
from qadence.backends.pyqtorch import Backend as PyQBackend

# define differentiable quantum backend
quantum_backend = PyQBackend()
conv = quantum_backend.convert(circuit, obs)
pyq_circ, pyq_obs, embedding_fn, params = conv
diff_backend = DifferentiableBackend(quantum_backend, diff_mode=DiffMode.GPSR, shift_prefac=0.2)

# calculate function f(x)
expval = diff_backend.expectation(pyq_circ, pyq_obs, embedding_fn(params, values))
```

Here we passed an additional argument `shift_prefac` to the `DifferentiableBackend` instance that governs the magnitude of shifts $\delta\equiv\alpha\delta^\prime$ shown in equation (2) above. In this relation $\delta^\prime$ is set internally and $\alpha$ is the value passed by `shift_prefac` and the resulting shift value $\delta$ is then used in all the following GPSR calculations.

Tuning parameter $\alpha$ is useful to improve results
when the generator $\hat{G}$ or the quantum operation is a dense matrix, for example a complex `HamEvo` operation; if many entries of this matrix are sufficiently larger than 0 the operation is equivalent to a strongly interacting system. In such case parameter $\alpha$ should be gradually lowered in order to achieve exact derivative values.


## References

[^1]: [A. G. Baydin et al., Automatic Differentiation in Machine Learning: a Survey](https://www.jmlr.org/papers/volume18/17-468/17-468.pdf)

[^2]: [Schuld et al., Evaluating analytic gradients on quantum hardware (2018).](https://arxiv.org/abs/1811.11184)

[^3]: [Kyriienko et al., General quantum circuit differentiation rules](https://arxiv.org/abs/2108.01218)
Loading

0 comments on commit 3a30973

Please sign in to comment.