From 3a309738a5e7858f41de5ef3297bf291e9a87828 Mon Sep 17 00:00:00 2001 From: Niklas Heim Date: Tue, 3 Oct 2023 16:22:06 +0200 Subject: [PATCH] Docs, examples, correct expectation shape (#31) Co-authored-by: Mario Dagrada Co-authored-by: Niklas Heim Co-authored-by: Dominik Seitz Co-authored-by: Roland Guichard Co-authored-by: Joao Moutinho Co-authored-by: Vytautas Abramavicius --- .gitignore | 19 ++ docs/advanced_tutorials/custom-models.md | 137 +++++++++ docs/advanced_tutorials/differentiability.md | 167 ++++++++++ docs/advanced_tutorials/vqe.md | 153 ++++++++++ docs/backends/backend.md | 1 + docs/backends/braket.md | 5 + docs/backends/differentiable.md | 1 + docs/backends/pulser.md | 16 + docs/backends/pyqtorch.md | 10 + docs/css/mkdocstrings.css | 24 ++ docs/development/architecture.md | 175 +++++++++++ docs/development/contributing.md | 65 ++++ docs/development/draw.md | 210 +++++++++++++ docs/digital_analog_qc/analog-basics.md | 200 ++++++++++++ docs/digital_analog_qc/analog-qubo.md | 195 ++++++++++++ docs/digital_analog_qc/daqc-basics.md | 30 ++ docs/digital_analog_qc/daqc-cnot.md | 265 ++++++++++++++++ docs/digital_analog_qc/daqc-qft.md | 270 +++++++++++++++++ docs/digital_analog_qc/pulser-basic.md | 275 +++++++++++++++++ docs/index.md | 164 ++++++++++ docs/javascripts/mathjax.js | 16 + docs/models.md | 3 + docs/qadence/blocks.md | 44 +++ docs/qadence/constructors.md | 17 ++ docs/qadence/execution.md | 2 + docs/qadence/ml_tools.md | 13 + docs/qadence/operations.md | 155 ++++++++++ docs/qadence/parameters.md | 8 + docs/qadence/quantumcircuit.md | 5 + docs/qadence/register.md | 3 + docs/qadence/serialization.md | 3 + docs/qadence/states.md | 3 + docs/qadence/transpile.md | 9 + docs/qadence/types.md | 3 + docs/qml/index.md | 92 ++++++ docs/qml/qaoa.md | 170 +++++++++++ docs/qml/qcl.md | 141 +++++++++ docs/tutorials/backends.md | 211 +++++++++++++ docs/tutorials/getting_started.md | 250 +++++++++++++++ docs/tutorials/hamiltonians.md | 121 ++++++++ docs/tutorials/overlap.md | 77 +++++ docs/tutorials/parameters.md | 285 ++++++++++++++++++ docs/tutorials/quantummodels.md | 91 ++++++ docs/tutorials/register.md | 129 ++++++++ docs/tutorials/serializ_and_prep.md | 79 +++++ docs/tutorials/state_conventions.md | 145 +++++++++ examples/backends/README.md | 1 + examples/backends/differentiable_backend.py | 95 ++++++ examples/backends/low_level/README.md | 6 + examples/backends/low_level/braket_digital.py | 125 ++++++++ examples/backends/low_level/overlap.py | 101 +++++++ examples/backends/low_level/pyq.py | 59 ++++ examples/digital-analog/fit-sin.py | 108 +++++++ examples/digital-analog/qubo.py | 149 +++++++++ examples/draw.py | 72 +++++ examples/models/qnn.py | 67 ++++ examples/models/quantum_model.py | 86 ++++++ examples/quick_start.py | 55 ++++ mkdocs.yml | 1 + qadence/backends/braket/backend.py | 3 +- qadence/backends/gpsr.py | 14 +- qadence/backends/pulser/backend.py | 2 +- qadence/backends/pyqtorch/backend.py | 12 +- qadence/backends/pytorch_wrapper.py | 6 +- qadence/measurements/shadow.py | 2 +- qadence/measurements/tomography.py | 4 +- readthedocs.yml | 2 + tests/backends/braket/test_quantum_braket.py | 3 +- tests/backends/pyq/test_quantum_pyq.py | 13 +- tests/backends/test_gpsr.py | 24 +- tests/backends/test_pytorch_wrapper.py | 24 +- tests/models/test_qnn.py | 4 +- tests/models/test_quantum_model.py | 29 +- .../test_measurements/test_tomography.py | 15 +- 74 files changed, 5476 insertions(+), 63 deletions(-) create mode 100644 docs/advanced_tutorials/custom-models.md create mode 100644 docs/advanced_tutorials/differentiability.md create mode 100644 docs/advanced_tutorials/vqe.md create mode 100644 docs/backends/backend.md create mode 100644 docs/backends/braket.md create mode 100644 docs/backends/differentiable.md create mode 100644 docs/backends/pulser.md create mode 100644 docs/backends/pyqtorch.md create mode 100644 docs/css/mkdocstrings.css create mode 100644 docs/development/architecture.md create mode 100644 docs/development/contributing.md create mode 100644 docs/development/draw.md create mode 100644 docs/digital_analog_qc/analog-basics.md create mode 100644 docs/digital_analog_qc/analog-qubo.md create mode 100644 docs/digital_analog_qc/daqc-basics.md create mode 100644 docs/digital_analog_qc/daqc-cnot.md create mode 100644 docs/digital_analog_qc/daqc-qft.md create mode 100644 docs/digital_analog_qc/pulser-basic.md create mode 100644 docs/index.md create mode 100644 docs/javascripts/mathjax.js create mode 100644 docs/models.md create mode 100644 docs/qadence/blocks.md create mode 100644 docs/qadence/constructors.md create mode 100644 docs/qadence/execution.md create mode 100644 docs/qadence/ml_tools.md create mode 100644 docs/qadence/operations.md create mode 100644 docs/qadence/parameters.md create mode 100644 docs/qadence/quantumcircuit.md create mode 100644 docs/qadence/register.md create mode 100644 docs/qadence/serialization.md create mode 100644 docs/qadence/states.md create mode 100644 docs/qadence/transpile.md create mode 100644 docs/qadence/types.md create mode 100644 docs/qml/index.md create mode 100644 docs/qml/qaoa.md create mode 100644 docs/qml/qcl.md create mode 100644 docs/tutorials/backends.md create mode 100644 docs/tutorials/getting_started.md create mode 100644 docs/tutorials/hamiltonians.md create mode 100644 docs/tutorials/overlap.md create mode 100644 docs/tutorials/parameters.md create mode 100644 docs/tutorials/quantummodels.md create mode 100644 docs/tutorials/register.md create mode 100644 docs/tutorials/serializ_and_prep.md create mode 100644 docs/tutorials/state_conventions.md create mode 100644 examples/backends/README.md create mode 100644 examples/backends/differentiable_backend.py create mode 100644 examples/backends/low_level/README.md create mode 100644 examples/backends/low_level/braket_digital.py create mode 100644 examples/backends/low_level/overlap.py create mode 100644 examples/backends/low_level/pyq.py create mode 100644 examples/digital-analog/fit-sin.py create mode 100644 examples/digital-analog/qubo.py create mode 100644 examples/draw.py create mode 100644 examples/models/qnn.py create mode 100644 examples/models/quantum_model.py create mode 100644 examples/quick_start.py diff --git a/.gitignore b/.gitignore index 376db79f..5d0e70d7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/advanced_tutorials/custom-models.md b/docs/advanced_tutorials/custom-models.md new file mode 100644 index 00000000..c55609a6 --- /dev/null +++ b/docs/advanced_tutorials/custom-models.md @@ -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) +``` diff --git a/docs/advanced_tutorials/differentiability.md b/docs/advanced_tutorials/differentiability.md new file mode 100644 index 00000000..5c300704 --- /dev/null +++ b/docs/advanced_tutorials/differentiability.md @@ -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) diff --git a/docs/advanced_tutorials/vqe.md b/docs/advanced_tutorials/vqe.md new file mode 100644 index 00000000..66b8e40f --- /dev/null +++ b/docs/advanced_tutorials/vqe.md @@ -0,0 +1,153 @@ +## Restricted Hamiltonian + +Simple implementation of the UCC ansatz for computing the ground state of the H2 +molecule. The Hamiltonian coefficients are taken from the following paper: +https://arxiv.org/pdf/1512.06860.pdf. + +Simple 2 qubits unitary coupled cluster ansatz for H2 molecule +```python exec="on" source="material-block" html="1" session="vqe" +import torch +from qadence import X, RX, RY, RZ, CNOT, chain, kron + +def UCC_ansatz_H2(): + ansatz=chain( + kron(chain(X(0), RX(0, -torch.pi/2)), RY(1, torch.pi/2)), + CNOT(1,0), + RZ(0, f"theta"), + CNOT(1,0), + kron(RX(0, torch.pi/2), RY(1, -torch.pi/2)) + ) + return ansatz + + +from qadence.draw import html_string # markdown-exec: hide +print(html_string(UCC_ansatz_H2())) # markdown-exec: hide +``` + + +Let's define the Hamiltonian of the problem in the following form: hamilt = +[list of coefficients, list of Pauli operators, list of qubits]. For example: +`hamilt=[[3,4],[[X,X],[Y]],[[0,1],[3]]]`. + +In the following function we generate the Hamiltonian with the format above. + +```python exec="on" source="material-block" html="1" session="vqe" +from typing import Iterable +from qadence import X, Y, Z, I, add +def make_hamiltonian(hamilt: Iterable, nqubits: int): + + nb_terms = len(hamilt[0]) + blocks = [] + + for iter in range(nb_terms): + block = kron(gate(qubit) for gate,qubit in zip(hamilt[1][iter], hamilt[2][iter])) + blocks.append(hamilt[0][iter] * block) + + return add(*blocks) + + +nqbits = 2 + +# Hamiltonian definition using the convention outlined above +hamilt_R07 = [ + [0.2976, 0.3593, -0.4826,0.5818, 0.0896, 0.0896], + [[I,I],[Z],[Z],[Z,Z],[X,X],[Y,Y]], + [[0,1],[0],[1],[0,1],[0,1],[0,1]] +] + +hamiltonian = make_hamiltonian(hamilt_R07, nqbits) +from qadence.draw import html_string # markdown-exec: hide +print(html_string(hamiltonian)) # markdown-exec: hide +``` + +Let's now create a `QuantumCircuit` representing the variational ansatz and plug +it into a `QuantumModel` instance. From there, it is very easy to compute the +energy by simply evaluating the expectation value of the Hamiltonian operator. + +```python exec="on" source="material-block" result="json" session="vqe" +from qadence import QuantumCircuit, QuantumModel + +ansatz = QuantumCircuit(nqbits, UCC_ansatz_H2()) +model = QuantumModel(ansatz, observable=hamiltonian, backend="pyqtorch", diff_mode="ad") + +values={} +out = model.expectation(values) +print(out) +``` +Let's now resent the parameters and set them randomly before starting the optimization loop. + +```python exec="on" source="material-block" result="json" session="vqe" +init_params = torch.rand(model.num_vparams) +model.reset_vparams(init_params) + +n_epochs = 100 +lr = 0.05 +optimizer = torch.optim.Adam(model.parameters(), lr=lr) +for i in range(n_epochs): + optimizer.zero_grad() + out=model.expectation({}) + out.backward() + optimizer.step() + +print("Ground state energy =", out.item(), "Hatree") +``` + +## Unrestricted Hamiltonian + +This result is in line with what obtained in the reference paper. Let's now +perform the same calculations but with a standard hardware efficient ansatz +(i.e. not specifically tailored for the H2 molecule) and with an unrestricted +Hamiltonian on 4 qubits. The values of the coefficients are taken from BK Hamiltonian, page 28[^2]. + +```python exec="on" source="material-block" html="1" session="vqe" +from qadence import hea + +nqbits = 4 + +gates = [[I,I,I,I],[Z],[Z],[Z],[Z,Z],[Z,Z],[Z,Z],[X,Z,X],[Y,Z,Y],[Z,Z,Z],[Z,Z,Z],[Z,Z,Z],[Z,X,Z,X],[Z,Y,Z,Y],[Z,Z,Z,Z]] +qubits = [[0,1,2,3],[0],[1],[2],[0,1],[0,2],[1,3],[2,1,0],[2,1,0],[2,1,0],[3,2,0],[3,2,1],[3,2,1,0],[3,2,1,0],[3,2,1,0]] +coeffs = [ + -0.81261,0.171201,0.16862325,- 0.2227965,0.171201,0.12054625,0.17434925 ,0.04532175,0.04532175,0.165868 , + 0.12054625,-0.2227965 ,0.04532175 ,0.04532175,0.165868 +] + +hamilt_R074_bis = [coeffs,gates,qubits] +Hamiltonian_bis = make_hamiltonian(hamilt_R074_bis, nqbits) +ansatz_bis = QuantumCircuit(4, hea(nqbits)) + +from qadence.draw import html_string # markdown-exec: hide +print(html_string(ansatz_bis)) # markdown-exec: hide +``` +```python exec="on" source="material-block" result="json" session="vqe" +model = QuantumModel(ansatz_bis, observable=Hamiltonian_bis, backend="pyqtorch", diff_mode="ad") + +values={} +out=model.expectation(values) + +# initialize some random initial parameters +init_params = torch.rand(model.num_vparams) +model.reset_vparams(init_params) + +n_epochs = 100 +lr = 0.05 +optimizer = torch.optim.Adam(model.parameters(), lr=lr) +for i in range(n_epochs): + + optimizer.zero_grad() + out=model.expectation(values) + out.backward() + optimizer.step() + if (i+1) % 10 == 0: + print(f"Epoch {i+1} - Loss: {out.item()}") + +print("Ground state energy =", out.item(),"a.u") +``` + +In a.u, the final ground state energy is a bit higher the expected -1.851 a.u +(see page 33 of the reference paper mentioned above). Increasing the ansatz +depth is enough to reach the desired accuracy. + + +## References + +[^1]: [Seeley et al.](https://arxiv.org/abs/1208.5986) - The Bravyi-Kitaev transformation for quantum computation of electronic structure diff --git a/docs/backends/backend.md b/docs/backends/backend.md new file mode 100644 index 00000000..967fea42 --- /dev/null +++ b/docs/backends/backend.md @@ -0,0 +1 @@ +### ::: qadence.backend diff --git a/docs/backends/braket.md b/docs/backends/braket.md new file mode 100644 index 00000000..23e5bf13 --- /dev/null +++ b/docs/backends/braket.md @@ -0,0 +1,5 @@ +## Braket Digital backend + +### ::: qadence.backends.braket.backend + +### ::: qadence.backends.braket.convert_ops diff --git a/docs/backends/differentiable.md b/docs/backends/differentiable.md new file mode 100644 index 00000000..3341e5a2 --- /dev/null +++ b/docs/backends/differentiable.md @@ -0,0 +1 @@ +### ::: qadence.backends.pytorch_wrapper diff --git a/docs/backends/pulser.md b/docs/backends/pulser.md new file mode 100644 index 00000000..46056227 --- /dev/null +++ b/docs/backends/pulser.md @@ -0,0 +1,16 @@ +The **Pulser backend** features a basic integration with the pulse-level programming +interface Pulser. This backend offers for now few simple operations +which are translated into a valid, non time-dependent pulse sequence. In particular, one has access to: + +* analog rotations: `AnalogRx` and `AnalogRy` blocks +* free evolution blocks (basically no pulse, just interaction): `AnalogWait` block +* a block for creating entangled states: `AnalogEntanglement` +* digital rotation `Rx` and `Ry` + +### ::: qadence.backends.pulser.backend + +### ::: qadence.backends.pulser.devices + +### ::: qadence.backends.pulser.pulses + +### ::: qadence.backends.pulser.convert_ops diff --git a/docs/backends/pyqtorch.md b/docs/backends/pyqtorch.md new file mode 100644 index 00000000..7ac7edfb --- /dev/null +++ b/docs/backends/pyqtorch.md @@ -0,0 +1,10 @@ +Fast differentiable statevector emulator based on PyTorch. The code is open source, +hosted on [Github](https://github.com/pasqal-io/PyQ) and maintained by Pasqal. + +### ::: qadence.backends.pyqtorch.backend + options: + inherited_members: true + +### ::: qadence.backends.pyqtorch.config + +### ::: qadence.backends.pyqtorch.convert_ops diff --git a/docs/css/mkdocstrings.css b/docs/css/mkdocstrings.css new file mode 100644 index 00000000..7c7c6d29 --- /dev/null +++ b/docs/css/mkdocstrings.css @@ -0,0 +1,24 @@ +/* Indentation. */ +div.doc-contents:not(.first) { + padding-left: 25px; + border-left: 4px solid rgba(230, 230, 230); + margin-bottom: 80px; + } + +/* Justified text */ +.md-content p { + text-align: justify; +} + +/* Avoid breaking parameters name, etc. in table cells. */ +td code { + word-break: normal !important; +} + +:root { + --md-primary-fg-color: #00704a; +} + +.md-content p { + text-align: justify; +} diff --git a/docs/development/architecture.md b/docs/development/architecture.md new file mode 100644 index 00000000..45243070 --- /dev/null +++ b/docs/development/architecture.md @@ -0,0 +1,175 @@ +Qadence as a software library mixes functional and object-oriented programming. We do that by maintaining core objects and operating on them with functions. + +Furthermore, Qadence strives at keeping the lower level abstraction layers for automatic differentiation and quantum computation +fully stateless while only the frontend layer which is the main user-facing interface is stateful. + +!!! note "**Code design philosopy**" + Functional, stateless core with object-oriented, stateful user interface. + +## Abstraction layers + +In Qadence there are 4 main objects spread across 3 different levels of abstraction: + +* **Frontend layer**: The user facing layer and encompasses two objects: + * [`QuantumCircuit`][qadence.circuit.QuantumCircuit]: A class representing an abstract quantum + circuit not tight not any particular framework. Parameters are represented symbolically using + `sympy` expressions. + * [`QuantumModel`][qadence.models.QuantumModel]: The models are higher-level abstraction + providing an interface for executing different kinds of common quantum computing models such + quantum neural networks (QNNs), quantum kernels etc. + +* **Differentiation layer**: Intermediate layer has the purpose of integrating quantum + computation with a given automatic differentiation engine. It is meant to be purely stateless and + contains one object: + * [`DifferentiableBackend`][qadence.backends.pytorch_wrapper.DifferentiableBackend]: + An abstract class whose concrete implementation wraps a quantum backend and make it + automatically differentiable using different engines (e.g. PyTorch or Jax). + Note, that today only PyTorch is supported but there is plan to add also a Jax + differentiable backend which will require some changes in the base class implementation. + +* **Quantum layer**: The lower-level layer which directly interfaces with quantum emulators + and processing units. It is meant to be purely stateless and it contains one base object which is + specialized for each supported backend: + * [`Backend`][qadence.backend.Backend]: An abstract class whose concrete implementation + enables the execution of quantum circuit with a variety of quantum backends (normally non + automatically differentiable by default) such as PyQTorch, Pulser or Braket. + + +## Main components + +### `QuantumCircuit` + +We consider `QuantumCircuit` to be an abstract object, i.e. it is not tied to any backend. However, it blocks are even more abstract. This is because we consider `QuantumCircuit`s "real", whereas the blocks are largely considered just syntax. + +Unitary `QuantumCircuits` (this encompasses digital, or gate-based, circuits as well as analog circuits) are constructed by [`PrimitiveBlocks`] using a syntax that allows you to execute them in sequence, dubbed `ChainBlock` in the code, or in parallel (i.e. at the same time) where applicable, dubbed `KronBlock` in the code. +Notice that this differs from other packages by providing more control of the layout of the circuit than conventional packages like Qiskit, and from Yao where the blocks are the primary type. + +### `QuantumModel` + +`QuantumModel`s are meant to be the main entry point for quantum computations in `qadence`. In general, they take one or more +quantum circuit as input and they wrap all the necessary boiler plate code to make the circuit executable and differentiable +on the chosen backend. + +Models are meant to be specific for a certain kind of quantum problem or algorithm and you can easily create new ones starting +from the base class `QuantumModel`, as explained in the [custom model tutorial](../advanced_tutorials/custom-models.md). Currently, Qadence offers +a `QNN` model class which provides convenient methods to work with quantum neural networks with multi-dimensional inputs +and outputs. + +### `DifferentiableBackend` + +The differentiable backend is a thin wrapper which takes as input a `QuantumCircuit` instance and a chosen quantum backend and make the circuit execution routines (expectation value, overalap, etc.) differentiable. Currently, the only implemented differentiation engine is PyTorch but it is easy to add support to another one like Jax. + +### Quantum `Backend` + +For execution the primary object is the `Backend`. Backends maintain the same user-facing interface, and internally connects to other libraries to execute circuits. Those other libraries can execute the code on QPUs and local or cloud-based emulators. The `Backends` use PyTorch tensors to represent data and leverages PyTorchs autograd to help compute derivatives of circuits. + +## Symbolic parameters + +To illustrate how parameters work in Qadence, let's consider the following simple block composed of just two rotations: + +```python exec="on" source="material-block" session="architecture" +import sympy +from qadence import Parameter, RX + +param = Parameter("phi", trainable=False) +block = RX(0, param) * RX(1, sympy.acos(param)) +``` + +The rotation angles assigned to `RX` (and to any Qadence quantum operation) are defined as arbitrary expressions of `Parameter`'s. `Parameter` is a subclass of `sympy.Symbol`, thus fully interoperable with it. + +To assign values of the parameter `phi` in a quantum model, one should use a dictionary containing the a key with parameter name and the corresponding values values: + +```python exec="on" source="material-block" session="architecture" +import torch +from qadence import run + +values = {"phi": torch.rand(10)} +wf = run(block, values=values) +``` + +This is the only interface for parameter assignment exposed to the user. Under the hood, parameters applied to every quantum operation are identified in different ways: + +* By default, with a stringified version of the `sympy` expression supplied to the quantum operation. Notice that multiple operations can have the same expression. + +* In certain case, e.g. for constructing parameter shift rules, one must access a *unique* identifier of the parameter for each quantum operation. Therefore, Qadence also creates unique identifiers for each parametrized operation (see the [`ParamMap`][qadence.parameters.ParamMap] class). + +By default, when one constructs a new backend, the parameter identifiers are the `sympy` expressions +which are used when converting an abstract block into a native circuit for the chosen backend. +However, one can use the unique identifiers as parameter names by setting the private flag +`_use_gate_params` to `True` in the backend configuration +[`BackendConfiguration`][qadence.backend.BackendConfiguration]. +This is automatically set when PSR differentiation is selected (see next section for more details). + +You can see the logic for choosing the parameter identifier in [`get_param_name`][qadence.backend.BackendConfiguration.get_param_name]. + +## Differentiation with parameter shift rules (PSR) + +In Qadence, parameter shift rules are implemented by extending the PyTorch autograd engine using custom `Function` +objects. The implementation is based on this PyTorch [guide](https://pytorch.org/docs/stable/notes/extending.html). + +A custom PyTorch `Function` looks like this: + +```python +import torch +from torch.autograd import Function + +class CustomFunction(Function): + + # forward pass implementation giving the output of the module + @staticmethod + def forward(ctx, inputs: torch.Tensor, params: torch.Tensor): + ctx.save_for_backward(inputs, params) + ... + + # backward pass implementation giving the derivative of the module + # with respect to the parameters. This must return the whole vector-jacobian + # product to integrate within the autograd engine + @staticmethod + def backward(ctx, grad_output: torch.Tensor): + inputs, params = ctx.saved_tensors + ... +``` + +The class [`PSRExpectation`][qadence.backends.pytorch_wrapper.PSRExpectation] implements parameter shift rules for all parameters using +a custom function as the one above. There are a few implementation details to keep in mind if you want +to modify the PSR code: + +* **PyTorch `Function` only works with tensor arguments**. Parameters in Qadence are passed around as + dictionaries with parameter names as keys and current parameter values (tensors) + as values. This works for both variational and feature parameters. However, the `Function` class + only work with PyTorch tensors as input, not dictionaries. Therefore, the forward pass of + `PSRExpectation` accepts one argument `param_keys` with the + parameter keys and a variadic positional argument `param_values` with the parameter values one by + one. The dictionary is reconstructed within the `forward()` pass body. + +* **Higher-order derivatives with PSR**. Higher-order PSR derivatives can be tricky. Parameter shift + rules calls, under the hood, the `QuantumBackend` expectation value routine that usually yield a + non-differentiable output. Therefore, a second call to the backward pass would not work. However, + Qadence employs a very simple trick to make higher-order derivatives work: instead of using + directly the expectation value of the quantum backend, the PSR backward pass uses the PSR forward + pass itself as expectation value function (see the code below). In this way, multiple calls to the + backward pass are allowed since the `expectation_fn` routine is always differentiable by + definition. Notice that this implementation is simple but suboptimal since, in some corner cases, + higher-order derivates might include some repeated terms that, with this implementation, are + always recomputed. + +```python +# expectation value used in the PSR backward pass +def expectation_fn(params: dict[str, Tensor]) -> Tensor: + return PSRExpectation.apply( + ctx.expectation_fn, + ctx.param_psrs, + params.keys(), + *params.values(), + ) +``` + +* **Operation parameters must be uniquely identified for PSR to work**. Parameter shift rules work at the level of individual quantum operations. This means that, given a parameter `x`, one needs to sum the contributions from shifting the parameter values of **all** the operation where the parameter `x` appears. When constructing the PSR rules, one must access a unique parameter identifier for each operation even if the corresponding user-facing parameter is the same. Therefore, when PSR differentiation is selected, the flag `_use_gate_params` is automatically set to `True` in the backend configuration [`BackendConfiguration`][qadence.backend.BackendConfiguration] (see previous section). + +* **PSR must not be applied to observable parameters**. In Qadence, Pauli observables can also be parametrized. However, the tunable parameters of observables are purely classical and should not be included in the differentiation with PSRs. However, the quantum expectation value depends on them, thus they still need to enter into the PSR evaluation. To solve this issue, the code sets the `requires_grad` attribute of all observable parameters to `False` when constructing the PSRs for the circuit as in the snippet below: + +```python +for obs in observable: + for param_id, _ in uuid_to_eigen(obs).items(): + param_to_psr[param_id] = lambda x: torch.tensor([0.0], requires_grad=False) +``` diff --git a/docs/development/contributing.md b/docs/development/contributing.md new file mode 100644 index 00000000..4bc39f83 --- /dev/null +++ b/docs/development/contributing.md @@ -0,0 +1,65 @@ +If you want to contribute to Qadence, feel free to branch out from `main` and send a merge request to the Qadence repository. +This will be reviewed, commented and eventually integrated in the codebase. + +## Install from source + +Before installing `qadence` from source, make sure you have Python >=3.9. For development, the preferred method to +install `qadence` is to use [hatch](https://hatch.pypa.io/latest/). Clone this repository and run: + +```bash +python -m pip install hatch + +# to enter into a shell with all dependencies +python -m hatch -v shell + +# to run a script into the shell +python -m hatch -v run my_script_with_qadence.py +``` + +If you after some time you have issues with your development environment, you can rebuild it by running: + +```bash +python -m hatch env prune +python -m hatch -v shell +``` + +You also have the following (non recommended) installation methods: + +* install with `pip` in development mode by simply running `pip install -e .`. Notice that in this way + you will install all the dependencies, including extras. +* install it with `conda` by simply using `pip` within a clean Conda environment. + +## Before developing + +Before starting to develop code, please keep in mind the following: + +1. Use `pre-commit` hooks to make sure that the code is properly linted before pushing a new commit. To do so, execute the following commands in the virtual environment where you installed Qadence: + +```bash +python -m pip install pre-commit # this will be already available if you installed the package with Poetry +pre-commit install # this will install the pre-commit hook +pre-commit run --all-files +``` + +2. Make sure that the unit tests and type checks are passing since the merge request will not be accepted if the automatic CI/CD pipeline do not pass. To do so, execute the following commands in the virtual environment where you installed Qadence: + +```bash +# if you used Hatch for installing these dependencies will be already available +python -m pip install pytest pytest-cov mypy + +# run the full test suite without some longer running tests +# remove the `-m` option to run the full test suite +python -m hatch -v run test -m "not slow" # with Hatch outside the shell +pytest -m "not slow" # with pytest directly +``` + +## Build documentation + +For building the documentation locally, we recommend to use `hatch` as follows: + +```bash +python -m hatch -v run docs:build +python -m hatch -v run docs:serve +``` + +Notice that this will build the documentation in strict mode, thus it will fail if even just one warning is detected. diff --git a/docs/development/draw.md b/docs/development/draw.md new file mode 100644 index 00000000..803101bf --- /dev/null +++ b/docs/development/draw.md @@ -0,0 +1,210 @@ +# `qadence.draw` example plots + +Mostly for quick, manual checking of correct plotting output. + +```python exec="on" source="material-block" html="1" +from qadence import X, Y, kron +from qadence.draw import display + +b = kron(X(0), Y(1)) +from qadence.draw import html_string # markdown-exec: hide +print(html_string(b)) # markdown-exec: hide +``` + +```python exec="on" source="material-block" html="1" +from qadence import X, Y, chain +from qadence.draw import display + +b = chain(X(0), Y(0)) +from qadence.draw import html_string # markdown-exec: hide +print(html_string(b)) # markdown-exec: hide +``` + +```python exec="on" source="material-block" html="1" +from qadence import X, Y, chain +from qadence.draw import display + +b = chain(X(0), Y(1)) +from qadence.draw import html_string # markdown-exec: hide +print(html_string(b)) # markdown-exec: hide +``` + +```python exec="on" source="material-block" html="1" +from qadence import X, Y, add +from qadence.draw import display + +b = add(X(0), Y(1), X(2)) +from qadence.draw import html_string # markdown-exec: hide +print(html_string(b)) # markdown-exec: hide +``` + +```python exec="on" source="material-block" html="1" +from qadence import CNOT, RX, HamEvo, X, Y, Z, chain, kron + +rx = kron(RX(3,0.5), RX(2, "x")) +rx.tag = "rx" +gen = chain(Z(i) for i in range(4)) + +# `chain` puts things in sequence +block = chain( + kron(X(0), Y(1), rx), + CNOT(2,3), + HamEvo(gen, 10) +) +from qadence.draw import html_string # markdown-exec: hide +print(html_string(block)) # markdown-exec: hide +``` + +```python exec="on" source="material-block" html="1" +from qadence import feature_map, hea, chain + +block = chain(feature_map(4, fm_type="tower"), hea(4,2)) +from qadence.draw import html_string # markdown-exec: hide +print(html_string(block)) # markdown-exec: hide +``` + + +## Developer documentation + +This section contains examples in pure graphviz that can be used to understand roughly what is done +in the actual drawing backend. + +```python exec="on" source="material-block" result="json" session="draw-dev" +import graphviz + +font_name = "Sans-Serif" +font_size = "8" + +graph_attr = { + "rankdir": "LR", # LR = left to right, TB = top to bottom + "nodesep": "0.1", # In inches, tells distance between nodes without edges + "compound": "true", # Needed to draw properly edges in hamevo when content is hidden + "splines": "false", # Needed to draw control gates vertical lines one over the other +} # These are the default values for graphs + +node_attr = { + "shape": "box", # 'box' for normal nodes, 'point' for control gates or 'plaintext' for starting nodes (the qubit label). + "style": "rounded", # Unfortunately we can't specify the radius of the rounded, at least for this version + "fontname": font_name, + "fontsize": font_size, + "width": "0.1", # In inches, it doesn't get tinier than the label font. + "height": "0.1" # In inches, it doesn't get tinier than the label font. +} # These are the defaults values that can be overridden at node declaration. + +default_cluster_attr = { + "fontname": font_name, + "fontsize": font_size, + "labelloc": "b", # location of cluster label. b as bottom, t as top + "style": "rounded" +} # These are the defaults values that can be overridden at sub graph declaration + +hamevo_cluster_attr = { + "label": "HamEvo(t=10)" +} +hamevo_cluster_attr.update(default_cluster_attr) + +h = graphviz.Graph(graph_attr=graph_attr, node_attr=node_attr) +h.node("Hello World!") +h +``` + +```python exec="on" source="material-block" result="json" session="draw-dev" +# Define graph +h = graphviz.Graph(node_attr=node_attr, graph_attr=graph_attr) + +# Add start and end nodes +for i in range(4): + h.node(f's{i}', shape="plaintext", label=f'{i}', group=f"{i}") + h.node(f'e{i}', style='invis', group=f"{i}") + +# Add nodes +h.node('X', group="0") +h.node('Y', group="1") + +# Add hamevo and its nodes +hamevo = graphviz.Graph(name='cluster_hamevo', graph_attr=hamevo_cluster_attr) +for i in range(4): + hamevo.node(f'z{i}', shape="box", style="invis", label=f'{i}', group=f"{i}") +h.subgraph(hamevo) + +# Add rx gates cluster and its nodes +cluster_attr = {"label": "RX gates"} +cluster_attr.update(default_cluster_attr) +cluster = graphviz.Graph(name="cluster_0", graph_attr=cluster_attr) +cluster.node('RX(x)', group="2") +cluster.node('RX(0.5)', group="3") +h.subgraph(cluster) + +h.node('cnot0', label='', shape='point', width='0.1', group='0') +h.node('cnot1', label='X', group='1') +h.node('cnot2', label='', shape='point', width='0.1', group='2') +h.node('cnot3', label='', shape='point', width='0.1', group='3') + +# Add edges +h.edge('s0', 'X') +h.edge('X', 'cnot0') +h.edge('cnot0', 'z0', lhead='cluster_hamevo') +h.edge('z0', 'e0', ltail='cluster_hamevo') +h.edge('s1', 'Y') +h.edge('Y', 'cnot1') +h.edge('cnot1', 'z1', lhead='cluster_hamevo') +h.edge('z1', 'e1', ltail='cluster_hamevo') +h.edge('s2', 'RX(x)') +h.edge('RX(x)', 'cnot2') +h.edge('cnot2', 'z2', lhead='cluster_hamevo') +h.edge('z2', 'e2', ltail='cluster_hamevo') +h.edge('s3', 'RX(0.5)') +h.edge('RX(0.5)', 'cnot3') +h.edge('cnot3', 'z3', lhead='cluster_hamevo') +h.edge('z3', 'e3', ltail='cluster_hamevo') +h.edge('cnot1', 'cnot0', constraint='false') # constraint: false is needed to draw vertical edges +h.edge('cnot1', 'cnot2', constraint='false') # constraint: false is needed to draw vertical edges +h.edge('cnot1', 'cnot3', constraint='false') # constraint: false is needed to draw vertical edges +h +``` + +### Example of cluster of clusters +```python exec="on" source="material-block" result="json" session="draw-dev" +# Define graph +h = graphviz.Graph(node_attr=node_attr, graph_attr=graph_attr) + +# Define start and end nodes +for i in range(4): + h.node(f's{i}', shape="plaintext", label=f'{i}', group=f"{i}") + h.node(f'e{i}', style='invis', group=f"{i}") + +# Define outer cluster +cluster_attr = {"label": "Outer cluster"} +cluster_attr.update(default_cluster_attr) +outer_cluster = graphviz.Graph(name="cluster_outer", graph_attr=cluster_attr) + +# Define inner cluster 1 and its nodes +cluster_attr = {"label": "Inner cluster 1"} +cluster_attr.update(default_cluster_attr) +inner1_cluster = graphviz.Graph(name="cluster_inner1", graph_attr=cluster_attr) +inner1_cluster.node("a0", group="0") +inner1_cluster.node("a1", group="1") +outer_cluster.subgraph(inner1_cluster) + +# Define inner cluster 2 and its nodes +cluster_attr = {"label": "Inner cluster 2"} +cluster_attr.update(default_cluster_attr) +inner2_cluster = graphviz.Graph(name="cluster_inner2", graph_attr=cluster_attr) +inner2_cluster.node("a2", group="2") +inner2_cluster.node("a3", group="3") +outer_cluster.subgraph(inner2_cluster) + +# This has to be done here, after inner clusters definitions +h.subgraph(outer_cluster) + +# Define more nodes +for i in range(4): + h.node(f"b{i}", group=f"{i}") + +for i in range(4): + h.edge(f's{i}', f'a{i}') + h.edge(f'a{i}', f'b{i}') + h.edge(f'b{i}', f'e{i}') + +h +``` diff --git a/docs/digital_analog_qc/analog-basics.md b/docs/digital_analog_qc/analog-basics.md new file mode 100644 index 00000000..f4f310b6 --- /dev/null +++ b/docs/digital_analog_qc/analog-basics.md @@ -0,0 +1,200 @@ +# Digital-Analog Emulation + +!!! note "TL;DR: Automatic emulation in the `pyqtorch` backend" + + All analog blocks are automatically translated to their emulated version when running them + with the `pyqtorch` backend (by calling `add_interaction` on them under the hood): + + ```python exec="on" source="material-block" result="json" + import torch + from qadence import Register, AnalogRX, sample + + reg = Register.from_coordinates([(0,0), (0,5)]) + print(sample(reg, AnalogRX(torch.pi))) + ``` + + +Qadence includes primitives for the simple construction of ising-like +Hamiltonians to account for the interaction among qubits. This allows to +simulate systems closer to real quantum computing platforms such as +neutral atoms. The constructed Hamiltonians are of the form + +$$ +\mathcal{H} = \sum_{i} \frac{\hbar\Omega}{2} \hat\sigma^x_i - \sum_{i} \hbar\delta \hat n_i + \mathcal{H}_{int}, +$$ + + +where $\hat n = \frac{1-\hat\sigma_z}{2}$, and $\mathcal{H}_{int}$ is a pair-wise interaction term. + + +We currently have two central operations that can be used to compose analog programs. + +- [`WaitBlock`][qadence.blocks.analog.WaitBlock] for interactions +- [`ConstantAnalogRotation`][qadence.blocks.analog.ConstantAnalogRotation] + +Both are _time-independent_ and can be emulated by calling `add_interaction`. + +To compose analog blocks you can use `chain` and `kron` as usual with the following restrictions: + +- [`AnalogChain`][qadence.blocks.analog.AnalogChain]s can only be constructed from AnalogKron blocks + or _**globally supported**_ primitive, analog blocks. +- [`AnalogKron`][qadence.blocks.analog.AnalogKron]s can only be constructed from _**non-global**_, + analog blocks with the _**same duration**_. + +The `wait` operation can be emulated with an *Ising* or an $XY$-interaction: + +```python exec="on" source="material-block" result="json" +from qadence import Register, wait, add_interaction, run + +block = wait(duration=3000) +print(block) + +print("") # markdown-exec: hide +reg = Register.from_coordinates([(0,0), (0,5)]) # we need atomic distances +emulated = add_interaction(reg, block, interaction="XY") # or: interaction="Ising" +print(emulated.generator) +``` + + +The `AnalogRot` constructor can create any constant (in time), analog rotation. + +```python exec="on" source="material-block" result="json" +import torch +from qadence import AnalogRot, AnalogRX + +# implement a global RX rotation +block = AnalogRot( + duration=1000., # [ns] + omega=torch.pi, # [rad/μs] + delta=0, # [rad/μs] + phase=0, # [rad] +) +print(block) + +# or use the short hand +block = AnalogRX(torch.pi) +print(block) +``` + +Analog blocks can also be `chain`ed, and `kron`ed like all other blocks, but with two small caveats: + +```python exec="on" source="material-block" +import torch +from qadence import AnalogRot, kron, chain, wait + +# only blocks with the same `duration` can be `kron`ed +kron( + wait(duration=1000, qubit_support=(0,1)), + AnalogRot(duration=1000, omega=2.0, qubit_support=(2,3)) +) + +# only blocks with `"global"` or the same qubit support can be `chain`ed +chain(wait(duration=200), AnalogRot(duration=300, omega=2.0)) +``` + +!!! note "Composing digital & analog blocks" + You can also compose digital and analog blocks where the additional restrictions of `chain`/`kron` + only apply to composite blocks which only contain analog blocks. For more details/examples, see + [`AnalogChain`][qadence.blocks.analog.AnalogChain] and [`AnalogKron`][qadence.blocks.analog.AnalogKron]. + + +## Fitting a simple function + +Just as most other blocks, analog blocks can be parametrized, and thus we can build a +small ansatz which can fit a sine wave. When using the `pyqtorch` backend the +`add_interaction` function is called automatically. As usual, we can choose which +differentiation backend we want to use: autodiff or parameter shift rule (PSR). + +First we define an ansatz block and an observable +```python exec="on" source="material-block" session="sin" +import torch +from qadence import Register, FeatureParameter, VariationalParameter +from qadence import AnalogRX, AnalogRZ, Z +from qadence import wait, chain, add + +pi = torch.pi + +# two qubit register +reg = Register.from_coordinates([(0, 0), (0, 12)]) + +# analog ansatz with input parameter +t = FeatureParameter("t") +block = chain( + AnalogRX(pi / 2), + AnalogRZ(t), + wait(1000 * VariationalParameter("theta", value=0.5)), + AnalogRX(pi / 2), +) + +# observable +obs = add(Z(i) for i in range(reg.n_qubits)) +``` + +```python exec="on" session="sin" +def plot(ax, x, y, **kwargs): + xnp = x.detach().cpu().numpy().flatten() + ynp = y.detach().cpu().numpy().flatten() + ax.plot(xnp, ynp, **kwargs) + +def scatter(ax, x, y, **kwargs): + xnp = x.detach().cpu().numpy().flatten() + ynp = y.detach().cpu().numpy().flatten() + ax.scatter(xnp, ynp, **kwargs) +``` + +Then we define the dataset we want to train on and plot the initial prediction. +```python exec="on" source="material-block" html="1" result="json" session="sin" +import matplotlib.pyplot as plt +from qadence import QuantumCircuit, QuantumModel + +# define quantum model; including digital-analog emulation +circ = QuantumCircuit(reg, block) +model = QuantumModel(circ, obs, diff_mode="gpsr") + +x_train = torch.linspace(0, 6, steps=30) +y_train = -0.64 * torch.sin(x_train + 0.33) + 0.1 +y_pred_initial = model.expectation({"t": x_train}) + +fig, ax = plt.subplots() +scatter(ax, x_train, y_train, label="Training points", marker="o", color="green") +plot(ax, x_train, y_pred_initial, label="Initial prediction") +plt.legend() +from docs import docsutils # markdown-exec: hide +print(docsutils.fig_to_html(fig)) # markdown-exec: hide +``` + +The rest is the usual PyTorch training routine. +```python exec="on" source="material-block" html="1" result="json" session="sin" +mse_loss = torch.nn.MSELoss() +optimizer = torch.optim.Adam(model.parameters(), lr=5e-2) + + +def loss_fn(x_train, y_train): + return mse_loss(model.expectation({"t": x_train}).squeeze(), y_train) + + +# train +n_epochs = 200 + +for i in range(n_epochs): + optimizer.zero_grad() + + loss = loss_fn(x_train, y_train) + loss.backward() + optimizer.step() + + # if (i + 1) % 10 == 0: + # print(f"Epoch {i+1:0>3} - Loss: {loss.item()}\n") + +# visualize +y_pred = model.expectation({"t": x_train}) + +fig, ax = plt.subplots() +scatter(ax, x_train, y_train, label="Training points", marker="o", color="green") +plot(ax, x_train, y_pred_initial, label="Initial prediction") +plot(ax, x_train, y_pred, label="Final prediction") +plt.legend() +from docs import docsutils # markdown-exec: hide +print(docsutils.fig_to_html(fig)) # markdown-exec: hide +assert loss_fn(x_train, y_train) < 0.05 # markdown-exec: hide +``` diff --git a/docs/digital_analog_qc/analog-qubo.md b/docs/digital_analog_qc/analog-qubo.md new file mode 100644 index 00000000..301adac5 --- /dev/null +++ b/docs/digital_analog_qc/analog-qubo.md @@ -0,0 +1,195 @@ +In this notebook we solve a quadratic unconstrained optimization problem with +`qadence` emulated analog interface using the QAOA variational algorithm. The +problem is detailed in the Pulser documentation +[here](https://pulser.readthedocs.io/en/stable/tutorials/qubo.html). + + +??? note "Construct QUBO register (defines `qubo_register_coords` function)" + Before we start we have to define a register that fits into our device. + ```python exec="on" source="material-block" session="qubo" + import torch + import numpy as np + from scipy.optimize import minimize + from scipy.spatial.distance import pdist, squareform + + from pulser.devices import Chadoq2 + + seed = 0 + np.random.seed(seed) + torch.manual_seed(seed) + + + def qubo_register_coords(Q): + """Compute coordinates for register.""" + bitstrings = [np.binary_repr(i, len(Q)) for i in range(len(Q) ** 2)] + costs = [] + # this takes exponential time with the dimension of the QUBO + for b in bitstrings: + z = np.array(list(b), dtype=int) + cost = z.T @ Q @ z + costs.append(cost) + zipped = zip(bitstrings, costs) + sort_zipped = sorted(zipped, key=lambda x: x[1]) + + def evaluate_mapping(new_coords, *args): + """Cost function to minimize. Ideally, the pairwise + distances are conserved""" + Q, shape = args + new_coords = np.reshape(new_coords, shape) + new_Q = squareform(Chadoq2.interaction_coeff / pdist(new_coords) ** 6) + return np.linalg.norm(new_Q - Q) + + shape = (len(Q), 2) + costs = [] + np.random.seed(0) + x0 = np.random.random(shape).flatten() + res = minimize( + evaluate_mapping, + x0, + args=(Q, shape), + method="Nelder-Mead", + tol=1e-6, + options={"maxiter": 200000, "maxfev": None}, + ) + return [(x, y) for (x, y) in np.reshape(res.x, (len(Q), 2))] + ``` + + +## Define and solve QUBO + +```python exec="on" source="material-block" session="qubo" +import matplotlib.pyplot as plt +import numpy as np +import torch + +from qadence import add_interaction, chain +from qadence import QuantumModel, QuantumCircuit, AnalogRZ, AnalogRX, Register + +seed = 0 +np.random.seed(seed) +torch.manual_seed(seed) +``` + +The QUBO is defined by weighted connections `Q` and a cost function. + +```python exec="on" source="material-block" session="qubo" +def cost_colouring(bitstring, Q): + z = np.array(list(bitstring), dtype=int) + cost = z.T @ Q @ z + return cost + + +def cost_fn(counter, Q): + cost = sum(counter[key] * cost_colouring(key, Q) for key in counter) + return cost / sum(counter.values()) # Divide by total samples + + +Q = np.array( + [ + [-10.0, 19.7365809, 19.7365809, 5.42015853, 5.42015853], + [19.7365809, -10.0, 20.67626392, 0.17675796, 0.85604541], + [19.7365809, 20.67626392, -10.0, 0.85604541, 0.17675796], + [5.42015853, 0.17675796, 0.85604541, -10.0, 0.32306662], + [5.42015853, 0.85604541, 0.17675796, 0.32306662, -10.0], + ] +) +``` + +Build a register from graph extracted from the QUBO exactly +as you would do with Pulser. +```python exec="on" source="material-block" session="qubo" +reg = Register.from_coordinates(qubo_register_coords(Q)) +``` + +The analog circuit is composed of two global rotations per layer. The first +rotation corresponds to the mixing Hamiltonian and the second one to the +embedding Hamiltonian. Subsequently we add the Ising interaction term to +emulate the analog circuit. This uses a principal quantum number n=70 for the +Rydberg level under the hood. +```python exec="on" source="material-block" result="json" session="qubo" +from qadence.transpile.emulate import ising_interaction + +LAYERS = 2 +block = chain(*[AnalogRX(f"t{i}") * AnalogRZ(f"s{i}") for i in range(LAYERS)]) + +emulated = add_interaction( + reg, block, interaction=lambda r, ps: ising_interaction(r, ps, rydberg_level=70) +) +print(emulated) +``` + +Sample the model to get the initial solution. +```python exec="on" source="material-block" session="qubo" +model = QuantumModel(QuantumCircuit(reg, emulated), backend="pyqtorch", diff_mode='gpsr') +initial_counts = model.sample({}, n_shots=1000)[0] +``` + +The loss function is defined by averaging over the evaluated bitstrings. +```python exec="on" source="material-block" session="qubo" +def loss(param, *args): + Q = args[0] + param = torch.tensor(param) + model.reset_vparams(param) + C = model.sample({}, n_shots=1000)[0] + return cost_fn(C, Q) +``` +Here we use a gradient-free optimization loop for reaching the optimal solution. +```python exec="on" source="material-block" result="json" session="qubo" +# +for i in range(20): + try: + res = minimize( + loss, + args=Q, + x0=np.random.uniform(1, 10, size=2 * LAYERS), + method="COBYLA", + tol=1e-8, + options={"maxiter": 20}, + ) + except Exception: + pass + +# sample the optimal solution +model.reset_vparams(res.x) +optimal_count_dict = model.sample({}, n_shots=1000)[0] +print(optimal_count_dict) +``` + +```python exec="on" source="material-block" html="1" session="qubo" +fig, axs = plt.subplots(1, 2, figsize=(12, 4)) + +# known solutions to the QUBO +solution_bitstrings=["01011", "00111"] + +n_to_show = 20 +xs, ys = zip(*sorted( + initial_counts.items(), + key=lambda item: item[1], + reverse=True +)) +colors = ["r" if x in solution_bitstrings else "g" for x in xs] + +axs[0].set_xlabel("bitstrings") +axs[0].set_ylabel("counts") +axs[0].bar(xs[:n_to_show], ys[:n_to_show], width=0.5, color=colors) +axs[0].tick_params(axis="x", labelrotation=90) +axs[0].set_title("Initial solution") + +xs, ys = zip(*sorted(optimal_count_dict.items(), + key=lambda item: item[1], + reverse=True +)) +# xs = list(xs) # markdown-exec: hide +# assert (xs[0] == "01011" and xs[1] == "00111") or (xs[1] == "01011" and xs[0] == "00111"), print(f"{xs=}") # markdown-exec: hide + +colors = ["r" if x in solution_bitstrings else "g" for x in xs] + +axs[1].set_xlabel("bitstrings") +axs[1].set_ylabel("counts") +axs[1].bar(xs[:n_to_show], ys[:n_to_show], width=0.5, color=colors) +axs[1].tick_params(axis="x", labelrotation=90) +axs[1].set_title("Optimal solution") +plt.tight_layout() +from docs import docsutils # markdown-exec: hide +print(docsutils.fig_to_html(fig)) # markdown-exec: hide +``` diff --git a/docs/digital_analog_qc/daqc-basics.md b/docs/digital_analog_qc/daqc-basics.md new file mode 100644 index 00000000..b7e61450 --- /dev/null +++ b/docs/digital_analog_qc/daqc-basics.md @@ -0,0 +1,30 @@ +# Digital-Analog Quantum Computation + +_**Digital-analog quantum computation**_ (DAQC) is a universal quantum computing +paradigm [^1]. The main ingredients of a DAQC program are: + +- Fast single-qubit operations (digital). +- Multi-partite entangling operations acting on all qubits (analog). + +Analog operations are typically assumed to follow device-specific interacting qubit Hamiltonians, such as the Ising Hamiltonian [^2]. The most common realization of the DAQC paradigm is on neutral atoms quantum computing platforms. + +## Digital-Analog Emulation + +Qadence simplifies the execution of DAQC programs on neutral-atom devices +by providing a simplified interface for adding interaction and interfacing +with pulse-level programming in `pulser`[^3]. + + +## DAQC Transform + +Furthermore, essential to digital-analog computation is the ability to represent an arbitrary Hamiltonian +with the evolution of a fixed and device-amenable Hamiltonian. Such a transform was described in the +DAQC[^2] paper for ZZ interactions, which is natively implemented in Qadence. + +## References + +[^1]: [Dodd et al., Universal quantum computation and simulation using any entangling Hamiltonian and local unitaries, PRA 65, 040301 (2002).](https://arxiv.org/abs/quant-ph/0106064) + +[^2]: [Parra-Rodriguez et al., Digital-Analog Quantum Computation, PRA 101, 022305 (2020).](https://arxiv.org/abs/1812.03637) + +[^3]: [Pulser: An open-source package for the design of pulse sequences in programmable neutral-atom arrays](https://pulser.readthedocs.io/en/stable/) diff --git a/docs/digital_analog_qc/daqc-cnot.md b/docs/digital_analog_qc/daqc-cnot.md new file mode 100644 index 00000000..a3e3db38 --- /dev/null +++ b/docs/digital_analog_qc/daqc-cnot.md @@ -0,0 +1,265 @@ +# DAQC Transform + +Digital-analog quantum computing focuses on using simple digital gates combined with more complex and device-dependent analog interactions to represent quantum programs. Such techniques have been shown to be universal for quantum computation [^1]. However, while this approach may have advantages when adapting quantum programs to real devices, known quantum algorithms are very often expressed in a fully digital paradigm. As such, it is also important to have concrete ways to transform from one paradigm to another. + +In this tutorial we will exemplify this transformation starting with the representation of a simple digital CNOT using the universality of the Ising Hamiltonian [^2]. + +## CNOT with CPHASE + +Let's look at a single example of how the digital-analog transformation can be used to perform a CNOT on two qubits inside a register of globally interacting qubits. + +First, note that the CNOT can be decomposed with two Hadamard and a CPHASE gate with $\phi=\pi$: + + +```python exec="on" source="material-block" result="json" session="daqc-cnot" +import torch +import qadence as qd + +from qadence.draw import display +from qadence import X, I, Z, H, N, CPHASE, CNOT, HamEvo +from qadence.draw import html_string # markdown-exec: hide + +n_qubits = 2 + +# CNOT gate +cnot_gate = CNOT(0, 1) + +# CNOT decomposed +phi = torch.pi +cnot_decomp = qd.chain(H(1), CPHASE(0, 1, phi), H(1)) + +init_state = qd.product_state("10") + +print(qd.sample(n_qubits, block = cnot_gate, state = init_state, n_shots = 100)) +print(qd.sample(n_qubits, block = cnot_decomp, state = init_state, n_shots = 100)) +``` + +The CPHASE gate is fully diagonal, and can be implemented by exponentiating an Ising-like Hamiltonian, or *generator*, + +$$\text{CPHASE}(i,j,\phi)=\text{exp}\left(-i\phi \mathcal{H}_\text{CP}(i, j)\right)$$ + +$$\begin{aligned} +\mathcal{H}_\text{CP}&=-\frac{1}{4}(I_i-Z_i)(I_j-Z_j)\\ +&=-N_iN_j +\end{aligned}$$ + +where we used the number operator $N_i = \frac{1}{2}(I_i-Z_i)$, leading to an Ising-like interaction $N_iN_j$ that is common in neutral-atom systems. Let's rebuild the CNOT using this evolution. + +```python exec="on" source="material-block" session="daqc-cnot" +# Hamiltonian for the CPHASE gate +h_cphase = (-1.0) * qd.kron(N(0), N(1)) + +# Exponentiating the Hamiltonian +cphase_evo = HamEvo(h_cphase, phi) + +# Check that we have the CPHASE gate: +cphase_matrix = qd.block_to_tensor(CPHASE(0, 1, phi)) +cphase_evo_matrix = qd.block_to_tensor(cphase_evo) + +assert torch.allclose(cphase_matrix, cphase_evo_matrix) +``` + +Now that we have checked the generator of the CPHASE gate, we can use it to apply the CNOT: + + +```python exec="on" source="material-block" result="json" session="daqc-cnot" +# CNOT with Hamiltonian Evolution +cnot_evo = qd.chain( + H(1), + cphase_evo, + H(1) +) + +init_state = qd.product_state("10") + +print(qd.sample(n_qubits, block = cnot_gate, state = init_state, n_shots = 100)) +print(qd.sample(n_qubits, block = cnot_evo, state = init_state, n_shots = 100)) +``` + +Thus, a CNOT gate can be applied by combining a few single-qubit gates together with a 2-qubit Ising interaction between the control and the target qubit. This is important because it now allows us to exemplify the usage of the Ising transform proposed in the DAQC paper [^2]. In the paper, the transform is described for $ZZ$ interactions. In `qadence` it works both with $ZZ$ and $NN$ interactions. + +## CNOT in an interacting system of 3 qubits + +Consider a simple experimental setup with $n=3$ interacting qubits in a triangular grid. For simplicity let's consider that all qubits interact with each other with an Ising ($NN$) interaction of constant strength $g_\text{int}$. The Hamiltonian for the system can be written by summing this interaction over all pairs: + +$$\mathcal{H}_\text{sys}=\sum_{i=0}^{n}\sum_{j=0}^{i-1}g_\text{int}N_iN_j,$$ + +which in this case leads to only three interaction terms, + +$$\mathcal{H}_\text{sys}=g_\text{int}(N_0N_1+N_1N_2+N_0N_2)$$ + +This generator can be easily built: + + +```python exec="on" source="material-block" result="json" session="daqc-cnot" +n_qubits = 3 + +g_int = 1.0 + +interaction_list = [] +for i in range(n_qubits): + for j in range(i): + interaction_list.append(g_int * qd.kron(N(i), N(j))) + +h_sys = qd.add(*interaction_list) + +print(h_sys) +``` + +Now let's consider that the experimental system is fixed, and we cannot isolate the qubits from each other. All we can do is the following: + +- Turn on or off the global system Hamiltonian. +- Perform single-qubit rotations on individual qubits. + +How can we perform a CNOT on two specific qubits of our choice? + +To perform a *fully digital* CNOT we would need to isolate the control and target qubit from the third one and have those interact to implement the gate directly. While this may be relatively simple for a 3-qubit system, the experimental burden becomes much greater when we start going into the dozens of qubits. + +However, with the digital-analog paradigm that is not the case! In fact, we can represent the two qubit Ising interaction required for the CNOT by combining the global system Hamiltonian with a specific set of single-qubit rotations. The full details of this transformation are described in the DAQC paper [^2], and it is available in `qadence` by calling the `daqc_transform` function. + +The `daqc_transform` function will essentially return a program that represents the evolution of an Hamiltonian $H_\text{target}$ (*target Hamiltonian*) for a specified time $t_f$ by using only the evolution of an Hamiltonian $H_\text{build}$ (*build Hamiltonian*) for specific intervals of time together with specific single-qubit $X$ rotations. Currently, in `qadence` it is available for resource and target Hamiltonians composed only of $ZZ$ or $NN$ interactions. The generators are parsed by the `daqc_transform` function, the appropriate type is automatically determined, and the appropriate single-qubit detunings and global phases are applied. + +Let's exemplify it for our CNOT problem: + + +```python exec="on" source="material-block" html="1" result="json" session="daqc-cnot" +# The target operation +i = 0 # Control +j = 1 # Target +k = 2 # The extra qubit + +# CNOT on control and target, Identity on the extra qubit +cnot_target = qd.kron(CNOT(i, j), I(k)) + +# The two-qubit Ising (NN) interaction for the CPHASE +h_int = (-1.0) * qd.kron(N(i), N(j)) + +# Transforming the two-qubit Ising interaction using only our system Hamiltonian +transformed_ising = qd.daqc_transform( + n_qubits = 3, # Total number of qubits in the transformation + gen_target = h_int, # The target Ising generator + t_f = torch.pi, # The target evolution time + gen_build = h_sys, # The building block Ising generator to be used + strategy = "sDAQC", # Currently only sDAQC is implemented + ignore_global_phases = False # Global phases from mapping between Z and N +) + +# display(transformed_ising) +print(html_string(transformed_ising)) # markdown-exec: hide +``` + +The circuit above actually only uses two evolutions of the global Hamiltonian. In the displayed circuit also see other instances of `HamEvo` which account for global-phases and single-qubit detunings related to the mapping between the $Z$ and $N$ operator. Optionally, the application of the global phases can also be ignored, as shown in the input of `daqc_transform`. This will not create exactly the same state or operator matrix in tensor form, but in practice they will be equivalent. + +In general, the mapping of a $n$-qubit Ising Hamiltonian will require at most $n(n-1)$ evolutions. The transformed circuit performs these evolutions for specific times that are computed from the solution of a linear system of equations involving the set of interactions in the target and build Hamiltonians. + +In this case the mapping is exact, since we used the *step-wise* DAQC technique (sDAQC). In *banged* DAQC (bDAQC) the mapping is not exact, but is easier to implement on a physical device with always-on interactions such as neutral-atom systems. Currently, only the sDAQC technique is available in `qadence`. + +Just as before, we can check that using the transformed Ising circuit we exactly recover the CPHASE gate: + + +```python exec="on" source="material-block" session="daqc-cnot" +# CPHASE on (i, j), Identity on third qubit: +cphase_matrix = qd.block_to_tensor(qd.kron(CPHASE(i, j, phi), I(k))) + +# CPHASE using the transformed circuit: +cphase_evo_matrix = qd.block_to_tensor(transformed_ising) + +# Will fail if global phases are ignored: +assert torch.allclose(cphase_matrix, cphase_evo_matrix) +``` + +And we can now build the CNOT gate: + +```python exec="on" source="material-block" result="json" session="daqc-cnot" +cnot_daqc = qd.chain( + H(j), + transformed_ising, + H(j) +) + +# And finally run the CNOT on a specific 3-qubit initial state: +init_state = qd.product_state("101") + +# Check we get an equivalent wavefunction (will still pass if global phases are ignored) +wf_cnot = qd.run(n_qubits, block = cnot_target, state = init_state) +wf_daqc = qd.run(n_qubits, block = cnot_daqc, state = init_state) +assert qd.equivalent_state(wf_cnot, wf_daqc) + +# Visualize the CNOT bit-flip: +print(qd.sample(n_qubits, block = cnot_target, state = init_state, n_shots = 100)) +print(qd.sample(n_qubits, block = cnot_daqc, state = init_state, n_shots = 100)) +``` + +And we are done! We have effectively performed a CNOT operation on our desired target qubits by using only the global interaction of the system as the building block Hamiltonian, together with single-qubit rotations. Going through the trouble of decomposing a single digital gate into its Ising Hamiltonian is certainly not very practical, but it serves as a proof of principle for the potential of this technique to represent universal quantum computation. In the next example, we will see it applied to the digital-analog Quantum Fourier Transform. + +## Technical details on the DAQC transformation + +- The mapping between target generator and final circuit is performed by solving a linear system of size $n(n-1)$ where $n$ is the number of qubits, so it can be computed *efficiently* (i.e., with a polynomial cost in the number of qubits). +- The linear system to be solved is actually not invertible for $n=4$ qubits. This is very specific edge case requiring a workaround, that is currently not yet implemented. +- As mentioned, the final circuit has at most $n(n-1)$ slices, so there is at most a polynomial overhead in circuit depth. + +Finally, and most important to its usage: + +- The target Hamiltonian should be *sufficiently* represented in the building block Hamiltonian. + +To illustrate this point, consider the following target and build Hamiltonians: + +```python exec="on" source="material-block" session="daqc-cnot" +# Interaction between qubits 0 and 1 +gen_target = 1.0 * (Z(0) @ Z(1)) + +# Fixed interaction between qubits 1 and 2, and customizable between 0 and 1 +def gen_build(g_int): + return g_int * (Z(0) @ Z(1)) + 1.0 * (Z(1) @ Z(2)) +``` + +And now we perform the DAQC transform by setting `g_int = 1.0`, matching the target Hamiltonian: + +```python exec="on" source="material-block" html="1" result="json" session="daqc-cnot" +transformed_ising = qd.daqc_transform( + n_qubits = 3, + gen_target = gen_target, + t_f = 1.0, + gen_build = gen_build(g_int = 1.0), +) + +# display(transformed_ising) +print(html_string(transformed_ising)) # markdown-exec: hide +``` + +And we get the transformed circuit. What if our build Hamiltonian has a very weak interaction between qubits 0 and 1? + +```python exec="on" source="material-block" html="1" result="json" session="daqc-cnot" +transformed_ising = qd.daqc_transform( + n_qubits = 3, + gen_target = gen_target, + t_f = 1.0, + gen_build = gen_build(g_int = 0.001), +) + +# display(transformed_ising) +print(html_string(transformed_ising)) # markdown-exec: hide +``` + +As we can see, to represent the same interaction between 0 and 1, the slices using the build Hamiltonian need to evolve for much longer, since the target interaction is not sufficiently represented in the building block Hamiltonian. + +In the limit where that interaction is not present at all, the transform will not work: + + +```python exec="on" source="material-block" result="json" session="daqc-cnot" +try: + transformed_ising = qd.daqc_transform( + n_qubits = 3, + gen_target = gen_target, + t_f = 1.0, + gen_build = gen_build(g_int = 0.0), + ) +except ValueError as error: + print("Error:", error) +``` + +## References + +[^1]: [Dodd et al., Universal quantum computation and simulation using any entangling Hamiltonian and local unitaries, PRA 65, 040301 (2002).](https://arxiv.org/abs/quant-ph/0106064) + +[^2]: [Parra-Rodriguez et al., Digital-Analog Quantum Computation, PRA 101, 022305 (2020).](https://arxiv.org/abs/1812.03637) diff --git a/docs/digital_analog_qc/daqc-qft.md b/docs/digital_analog_qc/daqc-qft.md new file mode 100644 index 00000000..5b6936ef --- /dev/null +++ b/docs/digital_analog_qc/daqc-qft.md @@ -0,0 +1,270 @@ +# Digital-Analog QFT (Advanced) + +Following the work in the DAQC paper [^1], the authors also proposed an algorithm using this technique to perform the well-known Quantum Fourier Transform [^2]. In this tutorial we will go over how the Ising transform used in the DAQC technique can be used to recreate the results for the DA-QFT. + +## The (standard) digital QFT + +The standard Quantum Fourier Transform can be easily built in `qadence` by calling the `qft` function. It accepts three arguments: + +- `reverse_in` (default `False`): reverses the order of the input qubits +- `swaps_out` (default `False`): swaps the qubit states at the output +- `inverse` (default `False`): performs the inverse QFT + + +```python exec="on" source="material-block" html="1" result="json" session="daqc-cnot" +import torch +import qadence as qd + +from qadence.draw import display +from qadence import X, I, Z, H, CPHASE, CNOT, HamEvo +from qadence.draw import html_string # markdown-exec: hide + +n_qubits = 4 + +qft_circuit = qd.qft(n_qubits) + +# display(qft_circuit) +print(html_string(qft_circuit)) # markdown-exec: hide +``` + +Most importantly, the circuit has a layered structure. The QFT for $n$ qubits has a total of $n$ layers, and each layer starts with a Hadamard gate on the first qubit and then builds a ladder of `CPHASE` gates. Let's see how we can easily build a function to replicate this circuit. + +```python exec="on" source="material-block" session="daqc-cnot" +def qft_layer(n_qubits, layer_ix): + qubit_range = range(layer_ix + 1, n_qubits) + # CPHASE ladder + cphases = [] + for j in qubit_range: + angle = torch.pi / (2 ** (j - layer_ix)) + cphases.append(CPHASE(j, layer_ix, angle)) + # Return Hadamard followed by CPHASEs + return qd.chain(H(layer_ix), *cphases) +``` + +With the layer function we can easily write the full QFT: + +```python exec="on" source="material-block" html="1" result="json" session="daqc-cnot" +def qft_digital(n_qubits): + return qd.chain(qft_layer(n_qubits, i) for i in range(n_qubits)) + +qft_circuit = qft_digital(4) + +# display(qft_circuit) +print(html_string(qft_circuit)) # markdown-exec: hide +``` + +## Decomposing the CPHASE ladder + +As we already saw in the [previous DAQC tutorial](daqc-cnot.md), the CPHASE gate has a well-known decomposition into an Ising Hamiltonian. For the CNOT example, we used the decomposition into $NN$ interactions. However, here we will use the decomposition into $ZZ$ interactions to be consistent with the description in the original DA-QFT paper [^2]. The decomposition is the following: + +$$\text{CPHASE}(i,j,\phi)=\text{exp}\left(-i\phi H_\text{CP}(i, j)\right)$$ + +$$\begin{aligned} +H_\text{CP}&=-\frac{1}{4}(I_i-Z_i)(I_j-Z_j)\\ +&=-\frac{1}{4}(I_iI_j-Z_i-Z_j)-\frac{1}{4}Z_iZ_j +\end{aligned}$$ + +where the terms in $(I_iI_j-Z_i-Z_j)$ represents single-qubit rotations, while the interaction is given by the Ising term $Z_iZ_j$. + +Just as we did for the CNOT, to build the DA-QFT we need to write the CPHASE ladder as an Ising Hamiltonian. To do so, we again write the Hamiltonian consisting of the single-qubit rotations from all CPHASEs in the layer, as well as the Hamiltonian for the two-qubit Ising interactions so that we can then use the DAQC transformation. The full mathematical details for this are written in the paper [^2], and below we write the necessary code for it, using the same notation as in the paper, including indices running from 1 to N. + + +```python exec="on" source="material-block" session="daqc-cnot" +# The angle of the CPHASE used in the single-qubit rotations: +def theta(k): + """Eq. (16) from [^2].""" + return torch.pi / (2 ** (k + 1)) + +# The angle of the CPHASE used in the two-qubit interactions: +def alpha(c, k, m): + """Eq. (16) from [^2].""" + return torch.pi / (2 ** (k - m + 2)) if c == m else 0.0 +``` + +The first two functions represent the angles of the various `CPHASE` gates that will be used to build the qubit Hamiltonians representing each QFT layer. In the `alpha` function we include an implicit kronecker delta between the indices `m` and `c`, following the conventions and equations written in the paper [^2]. This is simply because when building the Hamiltonian the paper sums through all possible $n(n-1)$ interacting pairs, but only the pairs that are connected by a `CPHASE` in each QFT layer should have a non-zero interaction. + + +```python exec="on" source="material-block" session="daqc-cnot" +# Building the generator for the single-qubit rotations +def build_sqg_gen(n_qubits, m): + """Generator in Eq. (13) from [^2] without the Hadamard.""" + k_sqg_range = range(2, n_qubits - m + 2) + sqg_gen_list = [] + for k in k_sqg_range: + sqg_gen = qd.kron(I(j) for j in range(n_qubits)) - Z(k+m-2) - Z(m-1) + sqg_gen_list.append(theta(k) * sqg_gen) + return sqg_gen_list + +# Building the generator for the two-qubit interactions +def build_tqg_gen(n_qubits, m): + """Generator in Eq. (14) from [^2].""" + k_tqg_range = range(2, n_qubits + 1) + tqg_gen_list = [] + for k in k_tqg_range: + for c in range(1, k): + tqg_gen = qd.kron(Z(c-1), Z(k-1)) + tqg_gen_list.append(alpha(c, k, m) * tqg_gen) + return tqg_gen_list +``` + +There's a lot to process in the above functions, and it might be worth taking some time to go through them with the help of the description in [^2]. + +Let's convince ourselves that they are doing what they are supposed to: perform one layer of the QFT using a decomposition of the CPHASE gates into an Ising Hamiltonian. We start by defining the function that will produce a given QFT layer: + + +```python exec="on" source="material-block" session="daqc-cnot" +def qft_layer_decomposed(n_qubits, layer_ix): + m = layer_ix + 1 # Paper index convention + + # Step 1: + # List of generator terms for the single-qubit rotations + sqg_gen_list = build_sqg_gen(n_qubits, m) + # Exponentiate the generator for single-qubit rotations: + sq_rotations = HamEvo(qd.add(*sqg_gen_list), -1.0) + + # Step 2: + # List of generator for the two-qubit interactions + ising_gen_list = build_tqg_gen(n_qubits, m) + # Exponentiating the Ising interactions: + ising_cphase = HamEvo(qd.add(*ising_gen_list), -1.0) + + # Add the explicit Hadamard to start followed by the Hamiltonian evolutions + if len(sqg_gen_list) > 0: + return qd.chain(H(layer_ix), sq_rotations, ising_cphase) + else: + # If the generator lists are empty returns just the Hadamard of the final layer + return H(layer_ix) +``` + +And now we build a layer of the QFT for both the digital and the decomposed case and check that they match: + +```python exec="on" source="material-block" session="daqc-cnot" +n_qubits = 3 +layer_ix = 0 + +# Building the layer with the digital QFT: +digital_layer_block = qft_layer(n_qubits, layer_ix) + +# Building the layer with the Ising decomposition: +decomposed_layer_block = qft_layer_decomposed(n_qubits, layer_ix) + +# Check that we get the same block in matrix form: +block_digital_matrix = qd.block_to_tensor(digital_layer_block) +block_decomposed_matrix = qd.block_to_tensor(decomposed_layer_block) + +assert torch.allclose(block_digital_matrix, block_decomposed_matrix) +``` + +## Performing the DAQC transformation + +We now have all the ingredients to build the Digital-Analog QFT: + +- In the [previous DAQC tutorial](daqc-cnot.md) we have learned about transforming an arbitrary Ising Hamiltonian into a program executing only a fixed, system-specific one. +- In this tutorial we have so far learned how to "extract" the arbitrary Ising Hamiltonian being used in each QFT layer. + +All that is left for us to do is to specify our system Hamiltonian, apply the DAQC transform, and build the Digital-Analog QFT layer function. + +For simplicity, we will once again consider an all-to-all Ising Hamiltonian with a constant interaction strength, but this step generalizes so any other Hamiltonian (given the limitations already discussed in the [previous DAQC tutorial](daqc-cnot.md)). + +```python exec="on" source="material-block" session="daqc-cnot" +def h_sys(n_qubits, g_int = 1.0): + interaction_list = [] + for i in range(n_qubits): + for j in range(i): + interaction_list.append(g_int * qd.kron(Z(i), Z(j))) + return qd.add(*interaction_list) +``` + +Now, all we have to do is re-write the qft layer function but replace Step 2. with the transformed evolution: + +```python exec="on" source="material-block" session="daqc-cnot" +def qft_layer_DAQC(n_qubits, layer_ix): + m = layer_ix + 1 # Paper index convention + + # Step 1: + # List of generator terms for the single-qubit rotations + sqg_gen_list = build_sqg_gen(n_qubits, m) + # Exponentiate the generator for single-qubit rotations: + sq_rotations = HamEvo(qd.add(*sqg_gen_list), -1.0) + + # Step 2: + # List of generator for the two-qubit interactions + ising_gen_list = build_tqg_gen(n_qubits, m) + # Transforming the target generator with DAQC: + gen_target = qd.add(*ising_gen_list) + + transformed_ising = qd.daqc_transform( + n_qubits = n_qubits, # Total number of qubits in the transformation + gen_target = gen_target, # The target Ising generator + t_f = -1.0, # The target evolution time + gen_build = h_sys(n_qubits), # The building block Ising generator to be used + ) + + # Add the explicit Hadamard to start followed by the Hamiltonian evolutions + if len(sqg_gen_list) > 0: + return qd.chain(H(layer_ix), sq_rotations, transformed_ising) + else: + # If the generator lists are empty returns just the Hadamard of the final layer + return H(layer_ix) +``` + +And finally, to convince ourselves that the results are correct, let's build the full DA-QFT and compare it with the digital version: + +```python exec="on" source="material-block" html="1" session="daqc-cnot" +def qft_digital_analog(n_qubits): + return qd.chain(qft_layer_DAQC(n_qubits, i) for i in range(n_qubits)) + +n_qubits = 3 + +digital_qft_block = qft_digital(n_qubits) + +daqc_qft_block = qft_digital_analog(n_qubits) + +# Check that we get the same block in matrix form: +block_digital_matrix = qd.block_to_tensor(digital_qft_block) +block_daqc_matrix = qd.block_to_tensor(daqc_qft_block) + +assert torch.allclose(block_digital_matrix, block_daqc_matrix) +``` + +And we can now display the program for the DA-QFT: + +```python exec="on" source="material-block" html="1" result="json" session="daqc-cnot" + +# display(daqc_qft_block) +print(html_string(daqc_qft_block)) # markdown-exec: hide +``` + +## The DA-QFT in `qadence`: + +The digital-analog QFT is available directly by using the `strategy` argument in the QFT: + +```python exec="on" source="material-block" html="1" result="json" session="daqc-cnot" +n_qubits = 3 + +qft_circuit = qd.qft(n_qubits, strategy = qd.Strategy.SDAQC) + +# display(qft_circuit) +print(html_string(qft_circuit)) # markdown-exec: hide +``` + +Just like with the `daqc_transform`, we can pass a different build Hamiltonian to it for the analog blocks, including one composed of $NN$ interactions: + +```python exec="on" source="material-block" html="1" result="json" session="daqc-cnot" +from qadence import hamiltonian_factory, Interaction + +n_qubits = 3 + +gen_build = hamiltonian_factory(n_qubits, interaction = Interaction.NN) + +qft_circuit = qd.qft(n_qubits, strategy = qd.Strategy.SDAQC, gen_build = gen_build) + +# display(qft_circuit) +print(html_string(qft_circuit)) # markdown-exec: hide +``` + +## References + +[^1]: [Parra-Rodriguez et al., Digital-Analog Quantum Computation. PRA 101, 022305 (2020).](https://arxiv.org/abs/1812.03637) + +[^2]: [Martin, Ana, et al. Digital-analog quantum algorithm for the quantum Fourier transform. Phys. Rev. Research 2.1, 013012 (2020).](https://arxiv.org/abs/1906.07635) diff --git a/docs/digital_analog_qc/pulser-basic.md b/docs/digital_analog_qc/pulser-basic.md new file mode 100644 index 00000000..80b4350b --- /dev/null +++ b/docs/digital_analog_qc/pulser-basic.md @@ -0,0 +1,275 @@ +Qadence offers a direct interface with Pulser[^1], a pulse-level programming interface +specifically designed for neutral atom quantum computers. + +When simulating pulse sequences written using Pulser, the underlying Hamiltonian it +constructs is equivalent to a DAQC computing paradigm with the following interaction +Hamiltonian (see [digital-analog emulation](analog-basics.md) for more details): + +$$ +\mathcal{H}_{int} = \sum_{inoheading + +!!! warning "Large Logo" + Put a large verion of the logo herec. + +Qadence is a Python package that provides a simple interface to build _**digital-analog quantum +programs**_ with tunable interaction defined on _**arbitrary qubit register layouts**_. + +## Feature highlights + +* A [block-based system](tutorials/getting_started.md) for composing _**complex digital-analog + programs**_ in a flexible and extensible manner. Heavily inspired by + [`Yao.jl`](https://github.com/QuantumBFS/Yao.jl) and functional programming concepts. + +* A [simple interface](digital_analog_qc/analog-basics.md) to work with _**interacting qubit systems**_ + using [arbitrary qubit registers](tutorials/register.md). + +* Intuitive, [expression-based system](tutorials/parameters.md) built on top of `sympy` to construct + _**parametric quantum programs**_. + +* [Higher-order generalized parameter shift](link to psr tutorial) rules for _**differentiating + arbitrary quantum operations**_ on real hardware. + +* Out-of-the-box automatic differentiability of quantum programs using [https://pytorch.org](https://pytorch.org) + +* `QuantumModel`s to make `QuantumCircuit`s differentiable and runnable on a variety of different + backends like state vector simulators, tensor network emulators and real devices. + +Documentation can be found here: [https://pasqal-qadence.readthedocs-hosted.com/en/latest](https://pasqal-qadence.readthedocs-hosted.com/en/latest). + +## Remarks +Quadence uses torch.float64 as the default datatype for tensors (torch.complex128 for complex tensors). + +## Examples + +### Bell state + +Sample from the [Bell state](https://en.wikipedia.org/wiki/Bell_state) in one line. + +```python exec="on" source="material-block" result="json" +import torch # markdown-exec: hide +torch.manual_seed(0) # markdown-exec: hide +from qadence import CNOT, H, chain, sample + +xs = sample(chain(H(0), CNOT(0,1)), n_shots=100) +print(xs) # markdown-exec: hide +from qadence.divergences import js_divergence # markdown-exec: hide +from collections import Counter # markdown-exec: hide +js = js_divergence(xs[0], Counter({"00":50, "11":50})) # markdown-exec: hide +assert js < 0.005 # markdown-exec: hide +``` + + +### Perfect state transfer + +We can construct a system that admits perfect state transfer between the two edge qubits in a +line of qubits at time $t=\frac{\pi}{\sqrt 2}$. +```python exec="on" source="material-block" result="json" +import torch +from qadence import X, Y, HamEvo, Register, product_state, sample, add + +def interaction(i, j): + return 0.5 * (X(i) @ X(j) + Y(i) @ Y(j)) + +# initial state with left-most qubit in the 1 state +init_state = product_state("100") + +# register with qubits in a line +reg = Register.line(n_qubits=3) + +# a line hamiltonian +hamiltonian = add(interaction(*edge) for edge in reg.edges) +# which is the same as: +# hamiltonian = interaction(0, 1) + interaction(1, 2) + +# define a hamiltonian evolution over t +t = torch.pi/(2**0.5) +evolution = HamEvo(hamiltonian, t) + +samples = sample(reg, evolution, state=init_state, n_shots=1) +print(f"{samples = }") # markdown-exec: hide +from collections import Counter # markdown-exec: hide +assert samples[0] == Counter({"001": 1}) # markdown-exec: hide +``` + + +### Digital-analog emulation + +Just as easily we can simulate an Ising hamiltonian that includes an interaction term based on the +distance of two qubits. To learn more about digital-analog quantum computing see the +[digital-analog section](/digital_analog_qc/analog-basics.md). +```python exec="on" source="material-block" result="json" +from torch import pi +from qadence import Register, AnalogRX, sample + +# global, analog RX block +block = AnalogRX(pi) + +# two qubits far apart (practically non-interacting) +reg = Register.from_coordinates([(0,0), (0,15)]) +samples = sample(reg, block) +print(f"distance = 15: {samples = }") # markdown-exec: hide +from collections import Counter # markdown-exec: hide +from qadence.divergences import js_divergence # markdown-exec: hide +js = js_divergence(samples[0], Counter({"11": 100})) # markdown-exec: hide +assert js < 0.01 # markdown-exec: hide + +# two qubits close together (interacting!) +reg = Register.from_coordinates([(0,0), (0,5)]) +samples = sample(reg, AnalogRX(pi)) +print(f"distance = 5: {samples = }") # markdown-exec: hide +js = js_divergence(samples[0], Counter({"01":33, "10":33, "00":33, "11":1})) # markdown-exec: hide +assert js < 0.05 # markdown-exec: hide``` +``` + + +## Further Resources +For a more comprehensive introduction and advanced topics, we suggest you to +look at the following tutorials: + +* [Description of quantum state conventions.](tutorials/state_conventions.md) +* [Basic tutorial](tutorials/getting_started.md) with a lot of detailed information +* Building [digital-analog](digital_analog_qc/analog-basics.md) quantum programs with interacting qubits +* [The sharp bits](tutorials/parameters.md) of creating parametric programs and observables +* [Advanced features](advanced_tutorials) like the low-level backend interface +* Building custom [`QuantumModel`](advanced_tutorials/custom-models.md)s + +## Installation guide + +Qadence can be install with `pip` as follows: + +```bash +export TOKEN_USERNAME=MYUSERNAME +export TOKEN_PASSWORD=THEPASSWORD + +pip install --extra-index-url "https://${TOKEN_USERNAME}:${TOKEN_PASSWORD}@gitlab.pasqal.com/api/v4/projects/190/packages/pypi/simple" qadence[pulser,visualization] +``` + +where the token username and password can be generated on the +[Gitlab UI](https://gitlab.pasqal.com/-/profile/personal_access_tokens). Remember to give registry read/write permissions to the generated token. + +The default backend for qadence is pyqtorch (a differentiable state vector simulator). +You can install one or all of the following additional backends and the circuit visualization library using the following extras: + +* `braket`: install the Amazon Braket quantum backend +* `emu-c`: install the Pasqal circuit tensor network emulator EMU-C +* `pulser`: install the Pulser backend. Pulser is a framework for composing, simulating and executing pulse sequences for neutral-atom quantum devices. +* `visualization`: install the library necessary to visualize quantum circuits. + +!!! warning + In order to correctly install the "visualization" extra, you need to have `graphviz` installed + in your system. This depends on the operating system you are using: + + ```bash + # on Ubuntu + sudo apt install graphviz + + # on MacOS + brew install graphviz + + # via conda + conda install python-graphviz + ``` +--- diff --git a/docs/javascripts/mathjax.js b/docs/javascripts/mathjax.js new file mode 100644 index 00000000..fd764a73 --- /dev/null +++ b/docs/javascripts/mathjax.js @@ -0,0 +1,16 @@ +window.MathJax = { + tex: { + inlineMath: [["\\(", "\\)"]], + displayMath: [["\\[", "\\]"]], + processEscapes: true, + processEnvironments: true + }, + options: { + ignoreHtmlClass: ".*|", + processHtmlClass: "arithmatex" + } +}; + +document$.subscribe(() => { + MathJax.typesetPromise() +}) diff --git a/docs/models.md b/docs/models.md new file mode 100644 index 00000000..0c7e4e49 --- /dev/null +++ b/docs/models.md @@ -0,0 +1,3 @@ +::: qadence.models.quantum_model + +::: qadence.models.qnn diff --git a/docs/qadence/blocks.md b/docs/qadence/blocks.md new file mode 100644 index 00000000..15a44185 --- /dev/null +++ b/docs/qadence/blocks.md @@ -0,0 +1,44 @@ +`qadence` offers a block-based system to construct quantum circuits in a flexible manner. + +::: qadence.blocks.abstract + +## Primitive blocks + +::: qadence.blocks.primitive + + +## Analog blocks + +To learn how to use analog blocks and how to mix digital & analog blocks, check out the +[digital-analog section](../digital_analog_qc/analog-basics.md) of the documentation. + +Examples on how to use digital-analog blocks can be found in the +*examples folder of the qadence repo: + +- Fit a simple sinus: `examples/digital-analog/fit-sin.py` +- Solve a QUBO: `examples/digital-analog/qubo.py` + +::: qadence.blocks.analog + +## Composite blocks + +::: qadence.blocks.utils.chain + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.blocks.utils.kron + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.blocks.utils.add + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.blocks.composite + +## Converting blocks to matrices + +::: qadence.blocks.block_to_tensor diff --git a/docs/qadence/constructors.md b/docs/qadence/constructors.md new file mode 100644 index 00000000..3d935c36 --- /dev/null +++ b/docs/qadence/constructors.md @@ -0,0 +1,17 @@ +# Constructors for common quantum circuits + +### ::: qadence.constructors.feature_maps + +### ::: qadence.constructors.ansatze + +### ::: qadence.constructors.hamiltonians + +### ::: qadence.constructors.qft + +## The DAQC Transform + +### ::: qadence.constructors.daqc.daqc + +## Some utility functions + +### ::: qadence.constructors.utils diff --git a/docs/qadence/execution.md b/docs/qadence/execution.md new file mode 100644 index 00000000..ac684349 --- /dev/null +++ b/docs/qadence/execution.md @@ -0,0 +1,2 @@ + +::: qadence.execution diff --git a/docs/qadence/ml_tools.md b/docs/qadence/ml_tools.md new file mode 100644 index 00000000..0c5ee50b --- /dev/null +++ b/docs/qadence/ml_tools.md @@ -0,0 +1,13 @@ +## ML Tools + +This module implements gradient-free and gradient-based training loops for torch Modules and QuantumModel. + +### ::: qadence.ml_tools.config + +### ::: qadence.ml_tools.parameters + +### ::: qadence.ml_tools.optimize_step + +### ::: qadence.ml_tools.train_grad + +### ::: qadence.ml_tools.train_no_grad diff --git a/docs/qadence/operations.md b/docs/qadence/operations.md new file mode 100644 index 00000000..61aea5e9 --- /dev/null +++ b/docs/qadence/operations.md @@ -0,0 +1,155 @@ + +Operations are common [`PrimitiveBlocks`][qadence.blocks.primitive.PrimitiveBlock], these are often +called *gates* elsewhere. + +## Constant blocks + +::: qadence.operations.X + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.Y + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.Z + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.I + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.H + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.S + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.SDagger + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.SWAP + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.T + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.TDagger + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.CNOT + options: + show_root_heading: true + show_root_full_path: false + +!!! warning "CY gate not implemented" + +::: qadence.operations.CZ + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.CPHASE + options: + show_root_heading: true + show_root_full_path: false + +--- + +## Parametrized blocks + +::: qadence.operations.RX + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.RY + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.RZ + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.CRX + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.CRY + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.CRZ + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.PHASE + options: + show_root_heading: true + show_root_full_path: false + +--- + +## Hamiltonian Evolution + +::: qadence.operations.HamEvo + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.AnalogSWAP + options: + show_root_heading: true + show_root_full_path: false +!!! warning "AnalogSWAP should be turned into a proper analog block" + +--- + +## Analog blocks + +::: qadence.operations.AnalogRX + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.AnalogRY + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.AnalogRZ + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.AnalogRot + options: + show_root_heading: true + show_root_full_path: false + +::: qadence.operations.wait + options: + show_root_heading: true + show_root_full_path: false diff --git a/docs/qadence/parameters.md b/docs/qadence/parameters.md new file mode 100644 index 00000000..8b71f389 --- /dev/null +++ b/docs/qadence/parameters.md @@ -0,0 +1,8 @@ +## Parameters + +### ::: qadence.parameters + + +## Parameter embedding + +::: qadence.blocks.embedding diff --git a/docs/qadence/quantumcircuit.md b/docs/qadence/quantumcircuit.md new file mode 100644 index 00000000..ad14cac3 --- /dev/null +++ b/docs/qadence/quantumcircuit.md @@ -0,0 +1,5 @@ +## QuantumCircuit + +The abstract `QuantumCircuit` is the key object in Qadence, as it is what can be executed. + +### ::: qadence.circuit diff --git a/docs/qadence/register.md b/docs/qadence/register.md new file mode 100644 index 00000000..a01484a3 --- /dev/null +++ b/docs/qadence/register.md @@ -0,0 +1,3 @@ +## Quantum Registers + +### ::: qadence.register diff --git a/docs/qadence/serialization.md b/docs/qadence/serialization.md new file mode 100644 index 00000000..a0a193c5 --- /dev/null +++ b/docs/qadence/serialization.md @@ -0,0 +1,3 @@ +## Serialization + +### ::: qadence.serialization diff --git a/docs/qadence/states.md b/docs/qadence/states.md new file mode 100644 index 00000000..980305c1 --- /dev/null +++ b/docs/qadence/states.md @@ -0,0 +1,3 @@ +## State Preparation Routines + +### ::: qadence.states diff --git a/docs/qadence/transpile.md b/docs/qadence/transpile.md new file mode 100644 index 00000000..4999ba43 --- /dev/null +++ b/docs/qadence/transpile.md @@ -0,0 +1,9 @@ +Contains functions that operate on blocks and circuits to `transpile` them to new blocks/circuits. + +::: qadence.transpile.transpile + +::: qadence.transpile.block + +::: qadence.transpile.circuit + +::: qadence.transpile.emulate diff --git a/docs/qadence/types.md b/docs/qadence/types.md new file mode 100644 index 00000000..f6592f12 --- /dev/null +++ b/docs/qadence/types.md @@ -0,0 +1,3 @@ +## Qadence Types + +### ::: qadence.types diff --git a/docs/qml/index.md b/docs/qml/index.md new file mode 100644 index 00000000..6a873df7 --- /dev/null +++ b/docs/qml/index.md @@ -0,0 +1,92 @@ +Variational algorithms on noisy devices and quantum machine learning (QML) [^1] in particular are +the target applications for Qadence. For this purpose, the +library offers both flexible symbolic expressions for the +quantum circuit parameters via `sympy` (see [here](../tutorials/parameters.md) for more +details) and native automatic differentiation via integration with +[PyTorch](https://pytorch.org/) deep learning framework. + +Qadence symbolic parameter interface allows to create +arbitrary feature maps to encode classical data into quantum circuits +with an arbitrary non-linear function embedding for the input values: + +```python exec="on" source="material-block" html="1" result="json" session="qml" +import qadence as qd +from qadence.operations import * +import torch +from sympy import acos + +n_qubits = 4 + +fp = qd.FeatureParameter("phi") +feature_map = qd.kron(RX(i, 2 * acos(fp)) for i in range(n_qubits)) + +# the key in the dictionary must correspond to +# the name of the assigned to the feature parameter +inputs = {"phi": torch.rand(3)} +samples = qd.sample(feature_map, values=inputs) +print(samples) +``` + +The [`constructors.feature_map`][qadence.constructors.feature_map] module provides +convenience functions to build commonly used feature maps where the input parameter +is encoded in the single-qubit gates rotation angle. + +Furthermore, Qadence is natively integrated with PyTorch automatic differentiation engine thus +Qadence quantum models can be used seamlessly in a PyTorch workflow. + +Let's create a quantum neural network model using the feature map just defined, a +digital-analog variational ansaztz and a simple observable $X(0) \otimes X(1)$. We +use the convenience `QNN` quantum model abstraction. + +```python exec="on" source="material-block" result="json" session="qml" +ansatz = qd.hea(n_qubits, strategy="sDAQC") +circuit = qd.QuantumCircuit(n_qubits, feature_map, ansatz) +observable = qd.kron(X(0), X(1)) + +model = qd.QNN(circuit, observable) + +# NOTE: the `QNN` is a torch.nn.Module +assert isinstance(model, torch.nn.Module) +``` + +Differentiation works the same way as any other PyTorch module: + +```python exec="on" source="material-block" html="1" result="json" session="qml" +values = {"phi": torch.rand(10, requires_grad=True)} + +# the forward pass of the quantum model returns the expectation +# value of the input observable +out = model(values) +print(f"Quantum model output: {out}") + +# you can compute the gradient with respect to inputs using +# PyTorch autograd differentiation engine +dout = torch.autograd.grad(out, values["phi"], torch.ones_like(out), create_graph=True)[0] +print(f"First-order derivative w.r.t. the feature parameter: {dout}") + +# you can also call directly a backward pass to compute derivatives with respect +# to the variational parameters and use it for implementing variational +# optimization +out.sum().backward() +``` + +To run QML on real devices, Qadence offers generalized parameter shift rules (GPSR) [^2] +for arbitrary quantum operations which can be selected when constructing the +`QNN` model: + +```python exec="on" source="material-block" html="1" result="json" session="qml" +model = qd.QNN(circuit, observable, diff_mode="gpsr") +out = model(values) + +dout = torch.autograd.grad(out, values["phi"], torch.ones_like(out), create_graph=True)[0] +print(f"First-order derivative w.r.t. the feature parameter: {dout}") +``` + +See [here](../advanced_tutorials/differentiability.md) for more details on how the parameter +shift rules implementation works in Qadence. + +## References + +[^1] Schuld, Petruccione, Machine learning on Quantum Computers, Springer Nature (2021) + +[^2]: [Kyriienko et al., General quantum circuit differentiation rules](https://arxiv.org/abs/2108.01218) diff --git a/docs/qml/qaoa.md b/docs/qml/qaoa.md new file mode 100644 index 00000000..9700ffcf --- /dev/null +++ b/docs/qml/qaoa.md @@ -0,0 +1,170 @@ +In this tutorial, we show how to solve the maximum cut (MaxCut) combinatorial +optimization problem on a graph using the Quantum Approximate Optimization +Algorithm (QAOA[^1]), introduced in 2014. This showcases the flexibility of +Qadence for implementing variational algorithms without classical input +data. + +Given an arbitrary graph, the MaxCut problem consists in finding a cut +partitioning the nodes into two sets, such that the number edges that are the +cut is maximized. This is a very common combinatorial problem, the interested +reader can refer to this introduction. +Let's first generate a random graph using the `networkx` library. + +```python exec="on" source="material-block" html="1" session="qaoa" +import numpy as np +import networkx as nx +import matplotlib.pyplot as plt + +# ensure reproducibility +seed = 10 +np.random.seed(seed) + +n_nodes = 8 +graph = nx.gnp_random_graph(n_nodes, 0.5) + +plt.clf() # markdown-exec: hide +nx.draw(graph) +from docs import docsutils # markdown-exec: hide +print(docsutils.fig_to_html(plt.gcf())) # markdown-exec: hide +``` + +The goal of the MaxCut algorithm is to maximize the following cost function: + +$$ +\mathcal{C}(p) = \sum_{\alpha}^m \mathcal{C}_{\alpha}(p) +$$ + +where $p$ is the given partition of the graph, $\alpha$ is an index over the edges and $\mathcal{C}_{\alpha}(p)$ is written such that if the nodes connected by the $\alpha$ edge are in the same set, it returns $0$, otherwise it returns $1$. + +## The QAOA quantum circuit + +Let's see how to solve this problem using a parametrized quantum circuit. The +QAOA algorithm requires a circuit with two main components: + +* the cost component is a circuit generated by a diagonal Hamiltonian which + encodes the cost function described above into a quantum circuit. +* the mixing component is a simple set of single qubit rotations with adjustable + angles which are tuned during the classical optimization loop + +First, construct the generators associated with the edges of the given graph. These +will be used both in the definition of the loss function of our problem and in +constructing the quantum circuit. + +```python exec="on" source="material-block" session="qaoa" +from qadence import kron, Z + +zz_ops = [kron(Z(edge[0]), Z(edge[1])) for edge in graph.edges()] +``` + +Let's now define the QAOA quantum circuits with the cost and mixing components. +```python exec="on" source="material-block" html="1" session="qaoa" +from qadence import Zero, I, HamEvo, tag, chain, QuantumCircuit, RX + +n_qubits = graph.number_of_nodes() +n_layers = 2 + +cost_ham = Zero() +for op in zz_ops: + cost_ham += 0.5 * op +cost_ham = 0.5 * kron(I(i) for i in range(n_qubits)) - cost_ham + +layers = [] +for layer in range(n_layers): + + # cost layer with digital decomposition + cost_layer = HamEvo(cost_ham, f"g{layer}").digital_decomposition() + cost_layer = tag(cost_layer, "cost") + + # mixing layer with single qubit rotations + mixing_layer = kron(RX(i, f"b{layer}{i}") for i in range(n_qubits)) + mixing_layer = tag(mixing_layer, "mixing") + + # putting all together in a single ChainBlock + layers.append(chain(cost_layer, mixing_layer)) + +final_b = chain(*layers) + +circuit = QuantumCircuit(n_qubits, final_b) +from qadence.draw import html_string # markdown-exec: hide +print(html_string(circuit)) # markdown-exec: hide +``` +Here we used the `digital_decomposition()` method provided by Qadence for +obtaining the set of gates corresponding to the Hamiltonian evolution operation +in the cost layer. + +## Train the QAOA circuit to solve MaxCut + +Now that we have the circuit, we can create the associated Qadence `QuantumModel` +and train it using standard gradient based optimization. Notice that we give the +full list of edge generators since the loss function to be minimized reads: + +$$ +\mathcal{L} = \sum_{i,j}^{N_{\mathcal{E}}} \frac{1}{2} \left(1 - \langle \psi | \sigma_i^z \sigma_j^z | \psi \rangle \right) +$$ + +where $\psi(\beta, \gamma)$ is the wavefunction obtained by propagating the QAQA +quantum circuit and the sum runs over the edges of the graph $N_{\mathcal{E}}$. + +```python exec="on" source="material-block" result="json" session="qaoa" +import torch +from qadence import QuantumModel + +model = QuantumModel(circuit, backend="pyqtorch", observable=zz_ops, diff_mode='gpsr') + +_ = torch.manual_seed(seed) + +def loss_function(_model: QuantumModel): + expval_ops = model.expectation().squeeze() + # this corresponds to the MaxCut cost by definition + # with negative sign in front to perform maximization + expval = 0.0 + for val in expval_ops: + expval += 0.5 * (1 - val) + return -1.0 * expval + +# initialize the parameters to random values +model.reset_vparams(torch.rand(model.num_vparams)) +initial_loss = loss_function(model) +print(f"Initial loss: {initial_loss}") + +# train the model +n_epochs = 100 +lr = 1.0 + +optimizer = torch.optim.Adagrad(model.parameters(), lr=lr) + +for i in range(n_epochs): + optimizer.zero_grad() + loss = loss_function(model) + loss.backward() + optimizer.step() + if (i+1) % (n_epochs // 10) == 0: + print(f"MaxCut cost at iteration {i+1}: {-loss.item()}") +``` +## Results + +Given the optimized model, we need now to sample the resulting quantum state to +recover the bitstring with the highest probability which corresponds to the maximum +cut of the graph. +```python exec="on" source="material-block" html="1" session="qaoa" +samples = model.sample(n_shots=100)[0] +most_frequent = max(samples, key=samples.get) + +print(f"Most frequently sampled bitstring corresponding to the maximum cut: {most_frequent}") + +# let's now draw the cut obtained with the QAOA procedure +colors = [] +labels = {} +for node, b in zip(graph.nodes(), most_frequent): + colors.append("green") if int(b) == 0 else colors.append("red") + labels[node] = "A" if int(b) == 0 else "B" + +plt.clf() # markdown-exec: hide +nx.draw_networkx(graph, node_color=colors, with_labels=True, labels=labels) +from docs import docsutils # markdown-exec: hide +print(docsutils.fig_to_html(plt.gcf())) # markdown-exec: hide +``` + +## References + +[^1]: [Farhi et al.](https://arxiv.org/abs/1411.4028) - A Quantum Approximate Optimization Algorithm diff --git a/docs/qml/qcl.md b/docs/qml/qcl.md new file mode 100644 index 00000000..1206c5ac --- /dev/null +++ b/docs/qml/qcl.md @@ -0,0 +1,141 @@ +In this tutorial, we show how to apply `qadence` for solving a basic quantum +machine learning application: fitting a simple function with the +quantum circuit learning (QCL) algorithm. + +Quantum circuit learning [^1] is a supervised quantum machine learning algorithm that uses +parametrized quantum neural networks to learn the behavior of an arbitrary +mathematical function starting from some training data extracted from it. We +choose the function + +For this tutorial, we show how to fit the $sin(x)$ function in the domain $[-1, 1]$. + +Let's start with defining training and test data. + +```python exec="on" source="material-block" session="qcl" result="json" +from typing import Callable + +import torch + +# make sure all tensors are kept on the same device +# only available from PyTorch 2.0 +device = "cuda" if torch.cuda.is_available() else "cpu" +torch.set_default_device(device) + +# notice that the domain does not include 1 and -1 +# this avoids a singularity in the rotation angles when +# when encoding the domain points into the quantum circuit +# with a non-linear transformation (see below) +def qcl_training_data( + domain: tuple = (-0.99, 0.99), n_points: int = 100 +) -> tuple[torch.Tensor, torch.Tensor]: + + start, end = domain + + x_rand, _ = torch.sort(torch.DoubleTensor(n_points).uniform_(start, end)) + y_rand = torch.sin(x_rand) + + return x_rand, y_rand + +test_frac = 0.25 +x, y = qcl_training_data() +n_test = int(len(x) * test_frac) +x_train, y_train = x[0:n_test-len(x)], y[0:n_test-len(x)] +x_test, y_test = x[n_test-len(x):], y[n_test-len(x):] +``` + +## Train the QCL model + +Qadence provides the [`QNN`][qadence.models.qnn.QNN] convenience constructor to build a quantum neural network. +The `QNN` class needs a circuit and a list of observables; both the number of feature parameters and the number +of observables in the list must be equal to the number of desired outputs of the quantum neural network. + +As observable, we use the total qubit magnetization leveraging a convenience constructor provided by `qadence`: + +$$ +\hat{O} = \sum_i^N \hat{\sigma}_i^z +$$ + +```python exec="on" source="material-block" session="qcl" result="json" +import sympy +import qadence as qd +from qadence.operations import RX + +n_qubits = 8 + +# create a simple feature map with a non-linear parameter transformation +feature_param = qd.FeatureParameter("phi") +feature_map = qd.kron(RX(i, feature_param) for i in range(n_qubits)) +featre_map = qd.tag(feature_map, "feature_map") + +# create a digital-analog variational ansatz using Qadence convenience constructors +ansatz = qd.hea(n_qubits, depth=n_qubits, strategy=qd.Strategy.SDAQC) +ansatz = qd.tag(ansatz, "ansatz") + +# total magnetization observable +observable = qd.total_magnetization(n_qubits) + +circuit = qd.QuantumCircuit(n_qubits, feature_map, ansatz) +model = qd.QNN(circuit, [observable]) +expval = model(values=torch.rand(10)) +print(expval) +``` + +The QCL algorithm uses the output of the quantum neural network as a tunable +function approximator. We can use standard PyTorch code for training the QNN +using a mean-square error loss, the Adam optimizer and also train on the GPU +if any is available: + +```python exec="on" source="material-block" session="qcl" result="json" + +# train the model +n_epochs = 200 +lr = 0.5 + +input_values = {"phi": x_train} +mse_loss = torch.nn.MSELoss() # standard PyTorch loss function +optimizer = torch.optim.Adam(model.parameters(), lr=lr) # standard PyTorch Adam optimizer + +print(f"Initial loss: {mse_loss(model(input_values), y_train)}") + +y_pred_initial = model({"phi": x_test}) + +running_loss = 0.0 +for i in range(n_epochs): + + optimizer.zero_grad() + + loss = mse_loss(model(input_values), y_train) + loss.backward() + optimizer.step() + + if (i+1) % 20 == 0: + print(f"Epoch {i+1} - Loss: {loss.item()}") +``` + +The quantum model is now trained on the training data points. Let's see how well it fits the +function on the test set. + +```python exec="on" source="material-block" session="qcl" result="json" +import matplotlib.pyplot as plt + +y_pred = model({"phi": x_test}) + +# convert all the results to numpy arrays for plotting +x_train_np = x_train.cpu().detach().numpy().flatten() +y_train_np = y_train.cpu().detach().numpy().flatten() +x_test_np = x_test.cpu().detach().numpy().flatten() +y_pred_initial_np = y_pred_initial.cpu().detach().numpy().flatten() +y_pred_np = y_pred.cpu().detach().numpy().flatten() + +fig, _ = plt.subplots() +plt.scatter(x_train_np, y_train_np, label="Training points", marker="o", color="orange") +plt.plot(x_test_np, y_pred_initial_np, label="Initial prediction", color="green", alpha=0.5) +plt.plot(x_test_np, y_pred_np, label="Final prediction") +plt.legend() +from docs import docsutils as du # markdown-exec: hide +print(du.fig_to_html(fig)) # markdown-exec: hide +``` + +## References + +[^1]: [Mitarai et al., Quantum Circuit Learning](https://arxiv.org/abs/1803.00745) diff --git a/docs/tutorials/backends.md b/docs/tutorials/backends.md new file mode 100644 index 00000000..78604455 --- /dev/null +++ b/docs/tutorials/backends.md @@ -0,0 +1,211 @@ +Backends in Qadence are what make an abstract quantum circuit executable on different kinds of +emulators and hardware **and** they make our circuits +[differentiable](https://en.wikipedia.org/wiki/Automatic_differentiation). Under the hood they are +what the `QuantumModel`s use. + +In order to use the different backends you do not have to know anything about their implementation +details. Qadence conveniently lets you specify the backend you want to run on in the `QuantumModel`. +Some backends do not support all operations, for example the Braket backend cannot execute analog +blocks, but Qadence will throw descriptive errors when you try to execute unsupported blocks. + +## Execution backends + +[_**PyQTorch**_](https://github.com/pasqal-io/PyQ): An efficient, large-scale emulator designed for +quantum machine learning, seamlessly integrated with the popular PyTorch deep learning framework for automatic differentiability. +Implementation details: [`PyQTorchBackend`][qadence.backends.pyqtorch.backend.Backend]. + +[_**Pulser**_](https://pulser.readthedocs.io/en/stable/): Library for pulse-level/analog control of +neutral atom devices. Emulator via QuTiP. + +[_**Braket**_](https://github.com/aws/amazon-braket-sdk-python): A Python SDK for interacting with +quantum devices on Amazon Braket. Currently, only the devices with the digital interface of Amazon Braket +are supported and execution is performed using the local simulator. Execution on remote simulators and +quantum processing units will be available soon. + +_**More**_: In the premium version of Qadence we provide even more backends such as a tensor network +emulator. For more info write us at: [`info@pasqal.com`](mailto:info@pasqal.com). + +## Differentiation backends + +[`DifferentiableBackend`][qadence.backends.pytorch_wrapper.DifferentiableBackend] is the class +that takes care of applying the different differentiation modes. +In your scripts you only have to provide a `diff_mode` in the `QuantumModel` via + +You can make any circuit differentiable using efficient and general parameter shift rules (PSRs). +See [link](...) for more information on differentiability and PSR. +```python +QuantumModel(..., diff_mode="gpsr") +``` + + +??? note "Set up a circuit with feature parameters (defines the `circuit` function used below)." + ```python exec="on" source="material-block" session="diff-backend" + import sympy + from qadence import Parameter, RX, RZ, CNOT, QuantumCircuit, chain + + def circuit(n_qubits: int): + x = Parameter("x", trainable=False) + y = Parameter("y", trainable=False) + fm = chain( + RX(0, 3 * x), + RX(0, x), + RZ(1, sympy.exp(y)), + RX(0, 3.14), + RZ(1, "theta") + ) + ansatz = CNOT(0, 1) + block = chain(fm, ansatz) + return QuantumCircuit(2, block) + ``` + +!!! note "Make any circuit differentiable via PSR diff mode." + ```python exec="on" source="material-block" result="json" session="diff-backend" + import torch + from qadence import QuantumModel, Z + + circuit = circuit(n_qubits=2) + observable = Z(0) + + # you can freely choose any backend with diff_mode="psr" + # diff_mode="ad" will only work with natively differentiable backends. + model = QuantumModel(circuit, observable, backend="pyqtorch", diff_mode="gpsr") + + # get some values for the feature parameters + values = {"x": (x := torch.tensor([0.5], requires_grad=True)), "y": torch.tensor([0.1])} + + # compute expectation + e = model.expectation(values) + + # differentiate it! + g = torch.autograd.grad(e, x, torch.ones_like(e)) + print(f"{g = }") # markdown-exec: hide + ``` + + +## Low-level `Backend` Interface + +Every backend in `qadence` inherits from the abstract `Backend` class: +[`Backend`](../backends/backend.md). + +All backends implement these methods: + +- [`run`][qadence.backend.Backend.run]: Propagate the initial state according to the quantum circuit and return the final wavefunction object. +- [`sample`][qadence.backend.Backend.sample]: Sample from a circuit. +- [`expectation`][qadence.backend.Backend.expectation]: Computes the expectation of a circuit given + an observable. +- [`convert`][qadence.backend.Backend.convert]: Convert the abstract `QuantumCircuit` object to + its backend-native representation including a backend specific parameter embedding function. + +The quantum backends are purely functional objects which take as input the values of the circuit +parameters and return the desired output. In order to use a backend directly, you need to supply +*embedded* parameters as they are returned by the backend specific embedding function. + +To demonstrate how to use a backend directly we will construct a simple `QuantumCircuit` and run it +on the Braket backend. + +```python exec="on" source="material-block" session="low-level-braket" +from qadence import QuantumCircuit, FeatureParameter, RX, RZ, CNOT, hea, chain + +# construct a featuremap +x = FeatureParameter("x") +z = FeatureParameter("y") +fm = chain(RX(0, 3 * x), RZ(1, z), CNOT(0, 1)) + +# circuit with hardware-efficient ansatz +circuit = QuantumCircuit(3, fm, hea(3,1)) +``` + +The abstract `QuantumCircuit` can now be converted to its native representation via the Braket +backend. + +```python exec="on" source="material-block" result="json" session="low-level-braket" +from qadence import backend_factory + +# use only Braket without differentiable backend by supplying `diff_mode=None`: +backend = backend_factory("braket", diff_mode=None) + +# the `Converted` object +# (contains a `ConvertedCircuit` wiht the original and native representation) +conv = backend.convert(circuit) +print(f"{conv.circuit.original = }") +print(f"{conv.circuit.native = }") +``` + +Additionally `Converted` contains all fixed and variational parameters, as well as an embedding +function which accepts feature parameters to construct a dictionary of *circuit native parameters*. These are needed since each backend uses a different representation of the circuit parameters under the hood: + +```python exec="on" source="material-block" result="json" session="low-level-braket" +import torch + +# contains fixed parameters and variational (from the HEA) +conv.params +print("conv.params = {") # markdown-exec: hide +for k, v in conv.params.items(): print(f" {k}: {v}") # markdown-exec: hide +print("}") # markdown-exec: hide + +inputs = {"x": torch.tensor([1., 1.]), "y":torch.tensor([2., 2.])} + +# get all circuit parameters (including feature params) +embedded = conv.embedding_fn(conv.params, inputs) +print("embedded = {") # markdown-exec: hide +for k, v in embedded.items(): print(f" {k}: {v}") # markdown-exec: hide +print("}") # markdown-exec: hide +``` + +Note that above the keys of the parameters have changed, because they now address the keys on the +Braket device. A more readable embedding is the embedding of the PyQTorch backend: +```python exec="on" source="material-block" result="json" session="low-level-braket" +pyq_backend = backend_factory("pyqtorch", diff_mode="ad") + +# the `Converted` object +# (contains a `ConvertedCircuit` wiht the original and native representation) +pyq_conv = pyq_backend.convert(circuit) +embedded = pyq_conv.embedding_fn(pyq_conv.params, inputs) +print("embedded = {") # markdown-exec: hide +for k, v in embedded.items(): print(f" {k}: {v}") # markdown-exec: hide +print("}") # markdown-exec: hide +``` + +With the embedded parameters we can call the methods we know from the `QuantumModel` like +`backend.run`: +```python exec="on" source="material-block" result="json" session="low-level-braket" +embedded = conv.embedding_fn(conv.params, inputs) +samples = backend.run(conv.circuit, embedded) +print(f"{samples = }") +``` + +### Even lower-level: Use the backend representation directly + +If you have to do things that are not currently supported by `qadence` but only by a specific backend +itself, you can always _**work directly with the native circuit**_. +For example, we can couple `qadence` directly with Braket noise features which are not exposed directly by Qadence. +```python exec="on" source="material-block" session="low-level-braket" +from braket.circuits import Noise + +# get the native Braket circuit with the given parameters +inputs = {"x": torch.rand(1), "y":torch.rand(1)} +embedded = conv.embedding_fn(conv.params, inputs) +native = backend.assign_parameters(conv.circuit, embedded) + +# define a noise channel +noise = Noise.Depolarizing(probability=0.1) + +# add noise to every gate in the circuit +native.apply_gate_noise(noise) +``` + +The density matrix simulator is needed in Braket to run this noisy circuit. Let's do the rest of the +example using Braket directly. +```python exec="on" source="material-block" result="json" session="low-level-braket" +from braket.devices import LocalSimulator + +device = LocalSimulator("braket_dm") +result = device.run(native, shots=1000).result().measurement_counts +print(result) +``` +```python exec="on" source="material-block" result="json" session="low-level-braket" +print(conv.circuit.native.diagram()) +``` +```python exec="on" source="material-block" result="json" session="low-level-braket" +print(native.diagram()) +``` diff --git a/docs/tutorials/getting_started.md b/docs/tutorials/getting_started.md new file mode 100644 index 00000000..24ae3e2e --- /dev/null +++ b/docs/tutorials/getting_started.md @@ -0,0 +1,250 @@ +Quantum programs in Qadence are constructed via a block-system, which makes it easily possible to +compose small, *primitive* blocks to obtain larger, *composite* blocks. This approach is very +different from how other frameworks (like Qiskit) construct circuits which follow an object-oriented +approach. + +## [`PrimitiveBlock`][qadence.blocks.primitive.PrimitiveBlock] + +A `PrimitiveBlock` is a basic operation such as a digital gate or an analog +time-evolution block. This is the only concrete element of the block system +and the program can always be decomposed into a list of `PrimitiveBlock`s. + +Two examples of primitive blocks are the `X` and the `CNOT` gates: + +```python exec="on" source="material-block" html="1" +from qadence import RX + +# a rotation gate on qubit 0 +rx0 = RX(0, 0.5) +from qadence.draw import html_string # markdown-exec: hide +from qadence import chain # markdown-exec: hide +print(html_string(chain(rx0), size="2,2")) # markdown-exec: hide +``` +```python exec="on" source="material-block" html="1" +from qadence import CNOT + +# a CNOT gate with control=0 and target=1 +c01 = CNOT(0, 1) +from qadence.draw import html_string # markdown-exec: hide +from qadence import chain # markdown-exec: hide +print(html_string(chain(c01), size="2,2")) # markdown-exec: hide +``` + +You can find a list of all instances of primitive blocks (also referred to as *operations*) +[here](/qadence/operations.md). + + +## [`CompositeBlock`][qadence.blocks.composite.CompositeBlock] + +Larger programs can be constructed from three operations: +[`chain`][qadence.blocks.utils.chain], +[`kron`][qadence.blocks.utils.kron], and +[`add`][qadence.blocks.utils.add]. + +[**`chain`**][qadence.blocks.utils.chain]ing blocks applies a set of sub-blocks in series, i.e. one +after the other on the *same or different qubit support*. A `ChainBlock` is akin to applying a +matrix product of the sub-blocks which is why it can also be used via the `*`-operator. +```python exec="on" source="material-block" html="1" session="i-xx" +from qadence import X, chain + +i = chain(X(0), X(0)) +from qadence.draw import html_string # markdown-exec: hide +print(html_string(i, size="2,2")) # markdown-exec: hide +``` +```python exec="on" source="material-block" html="1" session="i-xx" +xx = X(0) * X(1) +from qadence.draw import html_string # markdown-exec: hide +print(html_string(xx, size="2,2")) # markdown-exec: hide +``` + +??? note "Get the matrix of a block" + You can always translate a block to its matrix representation. Note that the returned tensor + contains a batch dimension because of parametric blocks. + ```python exec="on" source="material-block" result="json" session="i-xx" + print("X(0) * X(0)") + print(i.tensor()) + print("\n") # markdown-exec: hide + print("X(0) * X(1)") + print(xx.tensor()) + ``` + +In order to stack blocks (i.e. apply them simultaneously) you can use +[**`kron`**][qadence.blocks.utils.kron]. A `KronBlock` applies a set of sub-blocks simultaneously on +*different qubit support*. This is akin to applying a tensor product of the sub-blocks. +```python exec="on" source="material-block" html="1" session="i-xx" +from qadence import X, kron + +xx = kron(X(0), X(1)) +# equivalent to X(0) @ X(1) +from qadence.draw import html_string # markdown-exec: hide +print(html_string(xx, size="2,2")) # markdown-exec: hide +``` +"But this is the same as `chain`ing!", you may say. And yes, for the digital case `kron` and `chain` +have the same meaning apart from how they influence the plot of your block. However, Qadence also +supports *analog* blocks, which need this concept of sequential/simultaneous blocks. To learn more +about analog blocks check the [digital-analog](/digital_analog_qc/analog-basics) section. + +Finally, we have [**`add`**][qadence.blocks.utils.add]. This simply sums the corresponding matrix of +each sub-block. `AddBlock`'s can also be used to construct Pauli operators. + +!!! warning + Notice that `AddBlock`s can give rise to non-unitary blocks and thus might not be + executed by all backends but only by certain simulators. + +```python exec="on" source="material-block" result="json" +from qadence import X, Z + +xz = X(0) + Z(0) +print(xz.tensor()) +``` + +Finally, a slightly more complicated example. +```python exec="on" source="material-block" html="1" session="getting_started" +from qadence import X, Y, CNOT, kron, chain, tag + +xy = chain(X(0), Y(1)) +tag(xy, "subblock") + +composite_block = kron(xy, CNOT(3,4)) +final_block = chain(composite_block, composite_block) + +# tag the block with a human-readable name +tag(final_block, "my_block") +from qadence.draw import html_string # markdown-exec: hide +print(html_string(final_block, size="4,4")) # markdown-exec: hide +``` + +## Program execution + +### Quick, one-off execution +To quickly run quantum operations and access wavefunctions, samples or expectation values of +observables, one can use the convenience functions `run`, `sample` and `expectation`. +More fine-grained control and better performance is provided via the `QuantumModel`. + +??? note "The quick and dirty way" + Define a simple quantum program and perform some quantum operations on it: + ```python exec="on" source="material-block" result="json" session="index" + from qadence import chain, add, H, Z, run, sample, expectation + + n_qubits = 2 + block = chain(H(0), H(1)) + + # compute wavefunction with the `pyqtorch` backend + # check the documentation for other available backends! + wf = run(block) + print(f"{wf = }") # markdown-exec: hide + + # sample the resulting wavefunction with a given number of shots + xs = sample(block, n_shots=1000) + print(f"{xs = }") # markdown-exec: hide + + # compute an expectation based on an observable + obs = add(Z(i) for i in range(n_qubits)) + ex = expectation(block, obs) + print(f"{ex = }") # markdown-exec: hide + ``` + +### Proper execution via `QuantumCircuit` and `QuantumModel` + +Quantum programs in qadence are constructed in two steps: + +1. Define a `QuantumCircuit` which ties together a block and a register to a well-defined circuit. +2. Define a `QuantumModel` which takes care of compiling and executing the circuit. + +#### 1. [`QuantumCircuit`][qadence.circuit.QuantumCircuit]s + +The `QuantumCircuit` is one of the central classes in Qadence. For example, to specify the `Register` +to run your block on you use a `QuantumCircuit` (under the hood the functions above were already +using `QuantumCircuits` with a `Register` that fits the qubit support of the given block). + +The `QuantumCircuit` ties a block together with a register. + +```python exec="on" source="material-block" result="json" +from qadence import QuantumCircuit, Register, H, chain + +# NOTE: we run a block which supports two qubits +# on a register with three qubits +reg = Register(3) +circ = QuantumCircuit(reg, chain(H(0), H(1))) +print(circ) # markdown-exec: hide +``` + +!!! note "`Register`s" + Registers can also be constructed e.g. from qubit coordinates to create arbitrary register + layouts, but more on that in the [digital-analog](/digital_analog_qc/analog-basics.md) section. + + +#### 2. [`QuantumModel`](/tutorials/quantumodels)s + +`QuantumModel`s are another central class in Qadence's library. Blocks and circuits are completely abstract +objects that have nothing to do with the actual hardware/simulator that they are running on. This is +where the `QuantumModel` comes in. It contains a [`Backend`](/tutorials/backend.md) and a +compiled version of your abstract circuit (constructed by the backend). + +The `QuantumModel` is also what makes our circuit *differentiable* (either via automatic +differentiation, or on hardware via parameter shift rule). + +```python exec="on" source="material-block" result="json" +from qadence import QuantumCircuit, QuantumModel, Register, H, chain + +reg = Register(3) +circ = QuantumCircuit(reg, chain(H(0), H(1))) +model = QuantumModel(circ, backend="pyqtorch", diff_mode='ad') + +xs = model.sample(n_shots=100) +print(f"{xs = }") +``` + +For more details on how to use `QuantumModel`s, see [here](/tutorials/quantummodels). + + +## State initialization + +!!! warning "moved here from another page; improve?" + #### Quantum state preparation + + Qadence offers some convenience routines for preparing the initial quantum state. + These routines are divided into two approaches: + * generate the initial state as a dense matrix (routines with `_state` postfix). + This only works for backends which support state vectors as inputs, currently + only PyQ. + * generate the initial state from a suitable quantum circuit (routines with + `_block` postfix). This is available for every backend and it should be added + in front of the desired quantum circuit to simulate. + + Let's illustrate the usage of the state preparation routine. For more details, + please refer to the [API reference](/qadence/index). + + ```python exec="on" source="material-block" result="json" session="seralize" + from qadence import random_state, product_state, is_normalized, StateGeneratorType + + # random initial state + # the default `type` is StateGeneratorType.HaarMeasureFast + state = random_state(n_qubits=2, type=StateGeneratorType.RANDOM_ROTATIONS) + print(f"Random initial state generated with rotations:\n {state.detach().numpy().flatten()}") + + # check the normalization + assert is_normalized(state) + + # product state from a given bitstring + # remember that qadence follows the big endian convention + state = product_state("01") + print(f"Product state corresponding to bitstring '10':\n {state.detach().numpy().flatten()}") + ``` + + + Now we see how to generate the product state corresponding to the one above with + a suitable quantum circuit. + ```python + from qadence import product_block, tag, QuantumCircuit + + state_prep_b = product_block("10") + display(state_prep_b) + + # let's now prepare a circuit + state_prep_b = product_block("1000") + tag(state_prep_b, "prep") + qc_with_state_prep = QuantumCircuit(4, state_prep_b, fourier_b, hea_b) + + display(qc_with_state_prep) + ``` diff --git a/docs/tutorials/hamiltonians.md b/docs/tutorials/hamiltonians.md new file mode 100644 index 00000000..ecae7250 --- /dev/null +++ b/docs/tutorials/hamiltonians.md @@ -0,0 +1,121 @@ +# Constructing arbitrary Hamiltonians + +A big part of working with digital-analog quantum computing is handling large analog blocks, which represent a set of interacting qubits under some interaction Hamiltonian. In `qadence` we can use the [`hamiltonian_factory`](../qadence/constructors.md) function to create arbitrary Hamiltonian blocks to be used as generators of `HamEvo` or as observables to be measured. + +## Arbitrary all-to-all Hamiltonians + +Arbitrary all-to-all interaction Hamiltonians can be easily created by passing the number of qubits in the first argument. The type of `interaction` can be chosen from the available ones in the [`Interaction`](../qadence/types.md) enum. Alternatively, the strings `"ZZ", "NN", "XY", "XYZ"` can also be used. + +```python exec="on" source="material-block" result="json" session="hamiltonians" +from qadence import hamiltonian_factory +from qadence import N, X, Y, Z +from qadence import Interaction + +n_qubits = 3 + +hamilt = hamiltonian_factory(n_qubits, interaction = Interaction.ZZ) + +print(hamilt) +``` + +Single-qubit terms can also be added by passing the respective operator directly to the `detuning` argument. For example, the total magnetization is commonly used as an observable to be measured: + +```python exec="on" source="material-block" result="json" session="hamiltonians" +total_mag = hamiltonian_factory(n_qubits, detuning = Z) +print(total_mag) # markdown-exec: hide +``` + +For further customization, arbitrary coefficients can be passed as arrays to the `interaction_strength` and `detuning_strength` arguments. + +```python exec="on" source="material-block" result="json" session="hamiltonians" +n_qubits = 3 + +hamilt = hamiltonian_factory( + n_qubits, + interaction = Interaction.ZZ, + detuning = Z, + interaction_strength = [0.5, 0.2, 0.1], + detuning_strength = [0.1, 0.5, -0.3] + ) +print(hamilt) # markdown-exec: hide +``` + +To get random interaction coefficients between -1 and 1, you can ommit `interaction_strength` and `detuning_strength` and simply pass `random_strength = True`. + +Note that for passing interaction strengths as an array, you should order them in the same order obtained from the `edge` property of a Qadence [`Register`](register.md): + +```python exec="on" source="material-block" result="json" session="hamiltonians" +from qadence import Register + +print(Register(n_qubits).edges) +``` + +For one more example, let's create a transverse-field Ising model, + +```python exec="on" source="material-block" session="hamiltonians" +n_qubits = 4 +n_edges = int(0.5 * n_qubits * (n_qubits - 1)) + +z_terms = [1.0] * n_qubits +zz_terms = [2.0] * n_edges + +zz_ham = hamiltonian_factory( + n_qubits, + interaction = Interaction.ZZ, + detuning = Z, + interaction_strength = zz_terms, + detuning_strength = z_terms + ) + +x_terms = [-1.0] * n_qubits +x_ham = hamiltonian_factory(n_qubits, detuning = X, detuning_strength = x_terms) + +transverse_ising = zz_ham + x_ham +``` + + +## Changing the Hamiltonian topology + +We can also create arbitrary interaction topologies using the Qadence [`Register`](register.md). To do so, simply pass the register with the desired topology as the first argument. + +```python exec="on" source="material-block" result="json" session="hamiltonians" +from qadence import Register + +reg = Register.square(qubits_side = 2) + +square_hamilt = hamiltonian_factory(reg, interaction = Interaction.NN) +print(square_hamilt) # markdown-exec: hide +``` + +If you wish to add specific coefficients to the Hamiltonian, you can either pass them as shown earlier, or add them to the register beforehand using the `"strength"` key. + +```python exec="on" source="material-block" result="json" session="hamiltonians" + +reg = Register.square(qubits_side = 2) + +for i, edge in enumerate(reg.edges): + reg.edges[edge]["strength"] = (0.5 * i) ** 2 + +square_hamilt = hamiltonian_factory(reg, interaction = Interaction.NN) +print(square_hamilt) # markdown-exec: hide +``` + +Alternatively, if your register already has saved interaction or detuning strengths but you wish to override them in the Hamiltonian creation, you can use `force_update = True`. + +## Adding variational parameters + +Finally, we can also easily create fully parameterized Hamiltonians by passing a string to the strength arguments. Below we create a fully parametric neutral-atom Hamiltonian, + +```python exec="on" source="material-block" result="json" session="hamiltonians" +n_qubits = 3 + +nn_ham = hamiltonian_factory( + n_qubits, + interaction = Interaction.NN, + detuning = N, + interaction_strength = "c", + detuning_strength = "d" + ) + +print(nn_ham) # markdown-exec: hide +``` diff --git a/docs/tutorials/overlap.md b/docs/tutorials/overlap.md new file mode 100644 index 00000000..c4aabf6d --- /dev/null +++ b/docs/tutorials/overlap.md @@ -0,0 +1,77 @@ +`qadence` offers some convenience functions for computing the overlap between the +wavefunctions generated by two quantum circuits. We define the overlap between +the wavefunction generated by the circuits $U$ and $W$ as: + +$$ +S = |\langle \psi_U | \psi_W \rangle|^2 \;\; \textrm{where} \; \psi_U = U|\psi_0\rangle +$$ + +Let's jump right in and see how to compute the overlap between two very simple parametric circuits +consisting of a single `RX` rotation on different qubits. We expect the overlap to be +non-zero only when the rotation angle is different from $\pi$ for both rotations: + +```python exec="on" source="material-block" result="json" session="overlap" +import torch +import numpy as np +from qadence import Overlap, OverlapMethod, QuantumCircuit, H, RX, X, FeatureParameter, hea + + +# let's create two quantum circuits +# with a single qubit rotation on two random qubits +n_qubits = 4 +qubits = np.random.choice(list(range(n_qubits)), n_qubits, replace=True) + +phi = FeatureParameter("phi") +circuit_bra = QuantumCircuit(n_qubits, RX(qubits[0], phi)) + +psi = FeatureParameter("psi") +circuit_ket = QuantumCircuit(n_qubits, RX(qubits[1], psi)) + +# values for the feature parameters +values_bra = {"phi": torch.Tensor([torch.pi / 2, torch.pi])} +values_ket = {"psi": torch.Tensor([torch.pi / 2, torch.pi])} + +# calculate overlap by assigning values to the given bra and ket circuits +ovrlp = Overlap(circuit_bra, circuit_ket) +ovrlp = ovrlp(bra_param_values=values_bra, ket_param_values=values_ket) + +print("Overlap with exact method:\n", ovrlp) +``` + +The `Overlap` class above inherits from `QuantumModel` and its forward method +computes the overlap given input parameter values. By default, +the overlap is computed exactly by performing the dot product of the wavefunction propagated +from the bra and ket circuits. + +However, one can use the `OverlapMethod` enumeration +to choose which kind of overlap to compute via the `overlap_method` argument of the +overlap constructor class. Currently, one can choose from: + +* `EXACT`: exact computation using the wavefunction matrix representation. Does not work with +on real devices since it assumes access to the full qubit system wavefunction. +* `COMPUTE_UNCOMPUTE`: exact or sampling-based computation using brak $U$ and ket $W^{\dagger}$ unitaries. +* `SWAP_TEST`: exact or sampling-based computation using the SWAP test method. +* `HADAMARD_TEST`: exact or sampling-based computation using the Hadamard test method. +* `JENSEN_SHANNON`: compute the overlap using the Jensen-Shannon divergence of the two +probability distributions obtained by sampling the propagated circuits. This will yield a different +result than the other methods. + +All methods (except for the `EXACT` method) take an optional `n_shots` argument which can be used +for performing shot-based calculations. + +!!! warning + If you select a finite number of shots, the overlap is not differentiable. Therefore, + it cannot be used as output of a quantum model if gradients are required. + +```python exec="on" source="material-block" result="json" session="overlap" +# calculate overlap with SWAP test +ovrlp = Overlap(circuit_bra, circuit_ket, method=OverlapMethod.SWAP_TEST) +ovrlp_ha = ovrlp(values_bra, values_ket) +print("Overlap with SWAP test:\n", ovrlp_ha) + +# calculate overlap with SWAP test +# using a finite number of shots +ovrlp = Overlap(circuit_bra, circuit_ket, method=OverlapMethod.SWAP_TEST) +ovrlp_ha = ovrlp(values_bra, values_ket, n_shots=10_000) +print("Overlap with SWAP test with finite number of shots:\n", ovrlp_ha) +``` diff --git a/docs/tutorials/parameters.md b/docs/tutorials/parameters.md new file mode 100644 index 00000000..2a71f293 --- /dev/null +++ b/docs/tutorials/parameters.md @@ -0,0 +1,285 @@ + +```python exec="on" html="1" +import torch +import sympy +from qadence import RX, RY, RZ, CNOT, Z, run, chain, kron, FeatureParameter, VariationalParameter + +phi = FeatureParameter("phi") +theta = VariationalParameter("theta") + +block = chain( + kron( + RX(0, phi/theta), + RY(1, theta*2), + RZ(2, sympy.cos(phi)), + ), + kron( + RX(0, phi), + RY(1, theta), + RZ(2, phi), + ), + kron( + RX(0, phi), + RY(1, theta), + RZ(2, phi), + ), + kron( + RX(0, phi + theta), + RY(1, theta**2), + RZ(2, sympy.cos(phi)), + ), + chain(CNOT(0,1), CNOT(1,2)) +) +block.tag = "rotations" + +obs = 2*kron(*map(Z, range(3))) +block = chain(block, obs) + +from qadence.draw import html_string # markdown-exec: hide +print(html_string(block)) # markdown-exec: hide +``` + + +## Parametrized blocks + +To parametrize a block simply by an angle `x` you can pass a string instead of +a fixed float to the gate constructor: + +```python exec="on" source="material-block" result="json" +import torch +from qadence import RX, run + +# fixed rotation +# block = RX(0, 2.0) + +# parametrised rotation +block = RX(0, "x") + +wf = run(block, values={"x": torch.tensor([1.0, 2.0])}) +print(wf) +``` +Above you can see that `run` returns a batch of states, one for every provided angle. +You can provide any sympy expression `expr: sympy.Basic` to a block, e.g. also one with multiple +free symbols. +```python exec="on" source="material-block" result="json" +import torch +from qadence import RX, Parameter, run + +x, y = Parameter("x"), Parameter("y") +block = RX(0, x+y) + +# to run the block, both parameters have to be given +values = {"x": torch.tensor([1.0, 2.0]), "y": torch.tensor([2.0, 1.0])} +wf = run(block, values=values) +print(wf) +``` + +Parameters are uniquely defined by their name, so you can repeat a parameter in a composite block to +assign the same parameter to different blocks. +```python exec="on" source="material-block" result="json" +import torch +from qadence import RX, RY, run, chain, kron + +block = chain( + kron(RX(0, "phi"), RY(1, "theta")), + kron(RX(0, "phi"), RY(1, "theta")), +) + +values = {"phi": torch.rand(3), "theta": torch.tensor(3)} +wf = run(block, values=values) +print(wf) +``` + +## Parametrized models + +In quantum models we distinguish between two kinds of parameters: + +* _**Feature**_ parameters are used for data input and encode data into the quantum state. +* _**Variational**_ parameters are trainable parameters in a variational ansatz. + +As a reminder, in `qadence` a [`QuantumModel`][qadence.models.quantum_model.QuantumModel] takes an +abstract quantum circuit and makes it differentiable with respect to variational and feature +parameters. + +Again, both variational and feature parameters are uniquely identified by their name. +```python exec="on" source="material-block" session="parametrized-models" +from qadence import VariationalParameter, FeatureParameter, Parameter + +p1 = VariationalParameter("theta") +p2 = FeatureParameter("phi") + +p1_dup = VariationalParameter("theta") +p2_dup = FeatureParameter("phi") + +assert p1 == p1_dup +assert p2 == p2_dup + +# feature parameters are non-trainable parameters - meaning +# they can be specified via input data. The FeatureParameter +# is therefore exactly the same as a non-trainable parameter +fp = FeatureParameter("x") +assert fp == Parameter("x", trainable=False) + +# variational parameters are trainable parameters +vp = VariationalParameter("y") +assert vp == Parameter("y", trainable=True) +``` + +Let's see them first in a quantum circuit. +```python exec="on" source="material-block" result="json" session="parametrized-models" +from qadence import QuantumCircuit, RX, RY, chain, kron + +block = chain( + kron(RX(0, p1), RY(1, p1)), + kron(RX(0, p2), RY(1, p2)), +) + +circuit = QuantumCircuit(2, block) + +print("Unique parameters in the circuit: ", circuit.unique_parameters) +``` + +In the circuit above, we define 4 parameters but only 2 unique names. Therefore, the number of +variational parameters picked up by the optimizer in the resulting quantum model will be just 1. The +`QuantumModel` class provides some convenience methods to deal with parameters. + +```python exec="on" source="material-block" result="json" session="parametrized-models" +from qadence import QuantumModel + +model = QuantumModel(circuit, backend="pyqtorch", diff_mode="ad") + +print(f"Number of variational parameters: {model.num_vparams}") +print(f"Current values of the variational parameters: {model.vparams}") +``` + +!!! note "Only provide feature parameters to the quantum model!" + In order to `run` the variational circuit we have to _**provide only feature parameters**_, because + the variational parameters are stored in the model itself. + ```python exec="on" source="material-block" result="json" session="parametrized-models" + import torch + + values = {"phi": torch.rand(3)} # theta does not appear here + wf = model.run(values) + print(wf) + ``` + +## Usage with standard constructors + +The unique parameter identification explained above is important when using built-in `qadence` block +constructors in the `qadence.constructors` such as feature maps and hardware +efficient ansatze. Let's see it in practice: + +```python exec="on" source="material-block" result="json" session="parametrized-constructors" +from qadence import QuantumCircuit, hea + +n_qubits = 4 +depth = 2 + +hea1 = hea(n_qubits=n_qubits, depth=depth) +circuit = QuantumCircuit(n_qubits, hea1) +n_params_one_hea = circuit.num_unique_parameters +print(f"Unique parameters with a single HEA: {n_params_one_hea}") +``` +```python exec="on" html="1" session="parametrized-constructors" +from qadence.draw import html_string +print(html_string(circuit)) +``` + +Let's now add another HEA defined in the same way as above and create a circuit +stacking the two HEAs. As you can see below, the number of unique parameters +(and thus what gets optimized in the variational procedure) is the same since +the parameters are defined under the hood with the same names. + +```python exec="on" source="material-block" result="json" session="parametrized-constructors" +hea2 = hea(n_qubits=n_qubits, depth=depth) + +circuit = QuantumCircuit(n_qubits, hea1, hea2) +n_params_two_heas = circuit.num_unique_parameters +print(f"Unique parameters with two stacked HEAs: {n_params_two_heas}") +``` +```python exec="on" html="1" session="parametrized-constructors" +from qadence.draw import html_string # markdown-exec: hide +print(html_string(circuit)) # markdown-exec: hide +``` + +!!! warning "Avoid non-unique names!" + The above is likely not the expected behavior when stacking two variational circuits + together since one usually wants all the parameters to be optimized. To ensure + this, assign a different parameter prefix for each HEA as follows. + ```python exec="on" source="material-block" result="json" session="parametrized-constructors" + hea1 = hea(n_qubits=n_qubits, depth=depth, param_prefix="p1") + hea2 = hea(n_qubits=n_qubits, depth=depth, param_prefix="p2") + + circuit = QuantumCircuit(n_qubits, hea1, hea2) + n_params_two_heas = circuit.num_unique_parameters + print(f"Unique parameters with two stacked HEAs: {n_params_two_heas}") + ``` + ```python exec="on" html="1" session="parametrized-constructors" + from qadence.draw import html_string # markdown-exec: hide + print(html_string(circuit)) # markdown-exec: hide + ``` + + +## Parametric observables + +In `qadence` one can define quantum observables with some (classical) optimizable parameters. This +can be very useful for improving the convergence of some QML calculations, particularly in the +context of differentiable quantum circuits. Let's see how to define a parametrized observable: + +```python exec="on" source="material-block" session="parametrized-constructors" +from qadence import VariationalParameter, Z, add, tag + +s = VariationalParameter("s") +observable = add(s * Z(i) for i in range(n_qubits)) +``` + +Create a quantum model with the parametric observable and check that the variational parameters of +the observable are among the ones of the model +```python exec="on" source="material-block" result="json" session="parametrized-constructors" +from qadence import QuantumModel, QuantumCircuit + +circuit = QuantumCircuit(n_qubits, hea(n_qubits, depth)) +model = QuantumModel(circuit, observable=observable, backend="pyqtorch", diff_mode="ad") +print(model.vparams) +``` + +We can perform one optimization step and check that the model parameters have +been updated including the observable coefficients +```python exec="on" source="material-block" result="json" session="parametrized-constructors" +import torch + +mse_loss = torch.nn.MSELoss() +optimizer = torch.optim.Adam(model.parameters()) + +# compute forward & backward pass +optimizer.zero_grad() +loss = mse_loss(model.expectation({}), torch.zeros(1)) +loss.backward() + +# update the parameters +optimizer.step() +print(model.vparams) +``` + +## Non-unitary circuits + +`qadence` allows to write arbitrary blocks which might also lead to non-unitary +quantum circuits. For example, let's define a non-unitary block as a sum on +Pauli operators with complex coefficients. + +Backends which support the execution on non-unitary circuits can execute the +circuit below. *Currently, only PyQTorch backend fully supports execution on +non-unitary circuits.* +```python exec="on" source="material-block" html="1" session="non-unitary" +from qadence import QuantumModel, QuantumCircuit, Z, X +c1 = 2.0 +c2 = 2.0 + 2.0j + +block = c1 * Z(0) + c2 * X(1) + c1 * c2 * (Z(2) + X(3)) +circuit = QuantumCircuit(4, block) +from qadence.draw import html_string # markdown-exec: hide +print(html_string(circuit)) # markdown-exec: hide + +model = QuantumModel(circuit, backend='pyqtorch', diff_mode='ad') +print(model.run({})) +``` diff --git a/docs/tutorials/quantummodels.md b/docs/tutorials/quantummodels.md new file mode 100644 index 00000000..65b6b2fb --- /dev/null +++ b/docs/tutorials/quantummodels.md @@ -0,0 +1,91 @@ +Quantum programs are executed via [`QuantumModel`][qadence.models.quantum_model.QuantumModel]s. +They serve three purposes: + +_**Execution**_: They define on which backend your program is using (i.e. which simulator or +which device), they compile your circuit to the native backend representation. + +_**Parameter handling**_: They conveniently handle the two types of parameters that qadence supports +(*feature* and *variational* parameters) and make sure they are embedded correctly in the given +backend. Details on parameters can be found in [this section](parameters.md). + +_**Differentiability**_: They make your program differentiable by defining what we call a +*differentiable backend*. There are currently two differentiable backends: the autodiff backend +which works with PyTorch-based simulators, and the parameter shift rule (PSR) based backend which +can make any program differentiable (even on hardware). + +!!! note "Backends" + Quantum models can execute on a number of different backends like simulators, or real hardware. + Commonly used backends are: The [*PyQTorch*](https://github.com/pasqal-io/PyQ) backend which + implements a state vector simulator, or the [*Pulser*](https://pulser.readthedocs.io/en/stable/) + backend (pulse sequences on programmable neutral atom arrays). For more information see + [backend tutorial](backends.md). + +The base `QuantumModel` exposes the following methods: + +* `QuantumModel.run()`: To extract the wavefunction after propagating the quantum + circuit. This works only for certain backends +* `QuantumModel.sample()`: Sample bitstring out of the quantum state generated by + the input circuit. This is available for all backends. +* `QuantumModel.expectaction()`: Compute the expectation value of an observable + +Every `QuantumModel` is an instance of a +[`torch.nn.Module`](https://pytorch.org/docs/stable/generated/torch.nn.Module.html) which means that +its `expectation` method is _**differentiable**_. + +Upon construction of the model a compiled version of the abstract `QuantumCircuit` is +created: +```python exec="on" source="material-block" result="json" session="quantum-model" +from qadence import QuantumCircuit, QuantumModel, RX, Z, chain + +# construct abstract circuit +# at this point we cannot run anything yet! +n_qubits = 2 +block = chain(RX(0, "x"), RX(1, "x")) +circuit = QuantumCircuit(n_qubits, block) +observable = Z(0) + + +# now we construct a QuantumModel which will compile +# the abstract circuit to the backend we specify +model = QuantumModel(circuit, observable, backend="pyqtorch", diff_mode='ad') + +# the converted circuit is a private attribute and should not +# manually be tampered with, but we can at least verify its there +print(model._circuit.native) + +from pyqtorch.modules import QuantumCircuit as PyQCircuit +assert isinstance(model._circuit.native, PyQCircuit) +``` + +Now we can compute the wavefunction, sample, or compute the expectation: +```python exec="on" source="material-block" result="json" session="quantum-model" +import torch + +values = {"x": torch.rand(3)} + +wf = model.run(values) +print(f"{wf=}") + +xs = model.sample(values, n_shots=100) +print(f"{xs=}") + +ex = model.expectation(values) +print(f"{ex=}") +``` + +You can also measure multiple observables by passing a list of blocks. +```python exec="on" source="material-block" result="json" session="quantum-model" +model = QuantumModel(circuit, [Z(0), Z(1)], backend="pyqtorch", diff_mode='ad') +ex = model.expectation(values) +print(ex) +``` + +### Quantum Neural Network (QNN) + +The `QNN` is a subclass of the `QuantumModel` geared towards quantum machine learning. See the [ML +Tools](/tutorials/ml_tools.md) section or the [`QNN`][qadence.models.QNN] for more detailed +information. + +!!! note "Parametrized Models" + For more information on parametrizing `QuantumModel`s refer to the [parametric program + tutorial](/tutorials/parameters.md#parametrized-models). diff --git a/docs/tutorials/register.md b/docs/tutorials/register.md new file mode 100644 index 00000000..c0d8a03f --- /dev/null +++ b/docs/tutorials/register.md @@ -0,0 +1,129 @@ +```python exec="on" html="1" +import numpy as np +import matplotlib.pyplot as plt +from qadence.register import LatticeTopology, Register + +argss = [ + (("line", 4), (-1,4), (-2,2)), + (("square", 3), (-2,2), (-2,2)), + (("circle", 8), (-1.5,1.5), (-1.5,1.5)), + (("rectangular_lattice", 2, 3), (-1,3), (-1.5,2.0)), + (("triangular_lattice", 2, 3), (-2,3), (-2,3)), + (("honeycomb_lattice", 2, 3), (-1,7), (-1,7)), + (("all_to_all", 7), (-1.3,1.3), (-1.3,1.3)), +] +# make sure that we are plotting all different constructors +assert len(argss) == len(LatticeTopology)-1 + +s = np.sqrt(len(argss)) +width, height = int(np.floor(s)), int(np.ceil(s)) +while width * height < len(argss): + height += 1 + +fig, axs = plt.subplots(width, height, figsize=(width*5.5, height*2.6)) +fig.suptitle("Predefined register topolgies") +axs = axs.flatten() +for i, (args, xl, yl) in enumerate(argss): + reg = Register.lattice(*args) + plt.sca(axs[i]) + reg.draw() + axs[i].set_title(f"{args[0]}") + axs[i].set(aspect="equal") + axs[i].set_xlim(*xl) + axs[i].set_ylim(*yl) +# make rest of plots invisible +for i in range(len(argss), len(axs)): + ax = axs[i] + ax.set_xticks([]) + ax.set_yticks([]) + ax.spines['top'].set_visible(False) + ax.spines['bottom'].set_visible(False) + ax.spines['left'].set_visible(False) + ax.spines['right'].set_visible(False) +plt.tight_layout() +from docs import docsutils # markdown-exec: hide +print(docsutils.fig_to_html(fig)) # markdown-exec: hide +``` + +To construct programs that work with interacting qubit systems the +[`Register`][qadence.register.Register] lets you construct arbitrary topologies of qubit registers. + +Qadence provides a few commonly used register lattices, such as `"line"` or `"rectangular_lattice"`. +The available topologies are shown in the plot above. + +## Building registers + +As an example, lets construct a honeycomb lattice and draw it: +```python exec="on" source="material-block" html="1" +from qadence import Register + +reg = Register.honeycomb_lattice(2, 3) +import matplotlib.pyplot as plt # markdown-exec: hide +plt.clf() # markdown-exec: hide +reg.draw() +from docs import docsutils # markdown-exec: hide +fig = plt.gcf() # markdown-exec: hide +fig.set_size_inches(3, 3) # markdown-exec: hide +print(docsutils.fig_to_html(plt.gcf())) # markdown-exec: hide +``` + +You can also construct arbitrarily shaped registers by manually providing coordinates. +Note that there are no edges defined in `Register`s that are constructed via `from_coordinates`. + +```python exec="on" source="material-block" html="1" +import numpy as np +from qadence import Register + +reg = Register.from_coordinates( + [(x, np.sin(x)) for x in np.linspace(0, 2*np.pi, 10)] +) + +import matplotlib.pyplot as plt # markdown-exec: hide +plt.clf() # markdown-exec: hide +reg.draw() +fig = plt.gcf() # markdown-exec: hide +fig.set_size_inches(4, 2) # markdown-exec: hide +plt.tight_layout() # markdown-exec: hide +from docs import docsutils # markdown-exec: hide +print(docsutils.fig_to_html(fig)) # markdown-exec: hide +``` + +!!! warning "Qubit coordinate units" + The coordinates of qubits in `qadence` are *dimensionless*, e.g. for the Pulser backend they are + converted to $\mu m$. + +## Usage + +In the digital computing paradigm, register topology is often disregarded in +simulations and an all-to-all qubit connectivity is assumed. This is of course not the case when +running on real devices. In the [digital-analog](/digital_analog_qc/index.md) computing paradigm, +we have to specify how qubits interact either by taking into account the distances between qubits, +or by manually defining edges in the register graph. + +### Abstract graphs + +We can ignore the register coordinates and only deal with the edges that are present in the +`Register.edges`. For instance, this is the case in the [perfect state +transfer](/#perfect-state-transfer) example. + +```python exec="on" source="material-block" result="json" session="reg-usage" +from qadence import Register + +reg = Register.rectangular_lattice(2,3) +print(f"{reg.nodes=}") +print(f"{reg.edges=}") +``` + +### Graphs with coordinates + +If interactions are based on the distance of the individual qubits in the register then instead of +the edges, we deal with `Register.coords` like in +[`add_interaction`][qadence.transpile.emulate.add_interaction]. + +```python exec="on" source="material-block" result="json" session="reg-usage" +print(f"{reg.coords=}") +``` + +You might have already seen the [simplest example](/#digital-analog-emulation) that makes +use of register coordinates. See the [digital-analog section](/digital_analog_qc/analog-basics) +for more details. diff --git a/docs/tutorials/serializ_and_prep.md b/docs/tutorials/serializ_and_prep.md new file mode 100644 index 00000000..de5d3a0c --- /dev/null +++ b/docs/tutorials/serializ_and_prep.md @@ -0,0 +1,79 @@ +!!! warning "Serialization" + either on a separate page or move to API? (I would prefer the latter I believe) + +```python exec="on" session="seralize" +from rich import print +from qadence.draw import html_string +display = lambda x: print(html_string(x)) +``` +Here you will learn about some convenience tools offered by Qadence for +constructing quantum programs, state preparation, and serialization of `qadence` objects. + + +## Serialize and deserialize quantum programs + +Qadence offers some convenience functions for serializing and deserializing any +quantum program. This can be very useful for storing quantum programs and +sending them over the network via an API. + +!!! note + Qadence currently uses a custom JSON serialization format. Support for QASM + format for digital quantum programs will come soon! + +Qadence serialization offers two sets of serialization functions which work with +all the main components of Qadence: +* `serialize/deserialize`: serialize and deserialize a Qadence object into a dictionary +* `save/load`: save and load a Qadence object to a file with one of the supported + formats. This is built on top of the `serialize`/`deserialize` routines. + Currently, these are `.json` and the PyTorch-compatible `.pt` format. + +Let's start with serialization into a dictionary. + +```python exec="on" source="material-block" session="seralize_2" +import torch +from qadence import QuantumCircuit, QuantumModel +from qadence import chain, total_magnetization, feature_map, hea +from qadence.serialization import serialize, deserialize +from qadence.serialization import serialize, deserialize + +n_qubits = 4 + +my_block = chain(feature_map(n_qubits, param="x"), hea(n_qubits, depth=2)) +obs = total_magnetization(n_qubits) + +# use the block defined above to create a quantum circuit +# serialize/deserialize it +qc = QuantumCircuit(n_qubits, my_block) +qc_dict = serialize(qc) +qc_deserialized = deserialize(qc_dict) +assert qc == qc_deserialized + +# you can also let's wrap it in a QuantumModel +# and also serialize it +qm = QuantumModel(qc, obs, diff_mode='ad') +qm_dict = serialize(qm) +qm_deserialized = deserialize(qm_dict) + +# check if the loaded QuantumModel returns the same expectation +values = {"x": torch.rand(10)} +assert torch.allclose(qm.expectation(values=values), qm_deserialized.expectation(values=values)) +``` + + +Finally, we can save the quantum circuit and the model with the two supported formats. + +```python exec="on" source="material-block" session="seralize_2" +from qadence.serialization import serialize, deserialize, save, load, SerializationFormat +qc_fname = "circuit" +save(qc, folder=".", file_name=qc_fname, format=SerializationFormat.PT) +loaded_qc = load(f"{qc_fname}.pt") +assert qc == loaded_qc + +qm_fname = "model" +save(qm, folder=".", file_name=qm_fname, format=SerializationFormat.JSON) +model = load(f"{qm_fname}.json") +assert isinstance(model, QuantumModel) +import os # markdown-exec: hide +os.remove(f"{qc_fname}.pt") # markdown-exec: hide +os.remove(f"{qm_fname}.json") # markdown-exec: hide +``` diff --git a/docs/tutorials/state_conventions.md b/docs/tutorials/state_conventions.md new file mode 100644 index 00000000..57027b14 --- /dev/null +++ b/docs/tutorials/state_conventions.md @@ -0,0 +1,145 @@ +# State Conventions + +Here we describe the state conventions used in `qadence` and give a few practical examples. + +## Qubit register order + +Qubit registers in quantum computing are often indexed in increasing or decreasing order. In `qadence` we use an increasing order. For example, for a register of 4 qubits we have: + +$$q_0 \otimes q_1 \otimes q_2 \otimes q_3$$ + +Or alternatively in bra-ket notation, + +$$|q_0, q_1, q_2, q_3\rangle$$ + +Furthermore, when displaying a quantum circuit, the qubits are ordered from top to bottom. + +## Basis state order + +Basis state ordering refers to how basis states are ordered when considering the conversion from bra-ket notation to the standard linear algebra basis. In `qadence` the basis states are ordered in the following manner: + +$$ +\begin{align} +|00\rangle = [1, 0, 0, 0]^T\\ +|01\rangle = [0, 1, 0, 0]^T\\ +|10\rangle = [0, 0, 1, 0]^T\\ +|11\rangle = [0, 0, 0, 1]^T +\end{align} +$$ + +## Endianness + +Endianness refers to the convention of how binary information is stored in a memory register. Tyically, in classical computers, it refers to the storage of *bytes*. However, in quantum computing information is mostly described in terms of single bits, or qubits. The most commonly used conventions are: + +- A **big-endian** system stores the **most significant bit** of a word at the smallest memory address. +- A **little-endian** system stores the **least significant bit** of a word at the smallest memory address. + +Given the register convention described for `qadence`, as an example, the integer $2$ written in binary as $10$ can be encoded in a qubit register in both big-endian as $|10\rangle$ or little-endian as $|01\rangle$. + +In general, the default convention for `qadence` is **big-endian**. + +## In practice + +In practical scenarios, the conventions regarding *register order*, *basis state order* and *endianness* are very much connected, and the same results can be obtained by fixing or varying any of them. In `qadence`, we assume that qubit ordering and basis state ordering is fixed, and allow an `endianness` argument that can be passed to control the expected result. We now describe a few examples: + +### Quantum states + +A simple and direct way to exemplify the endianness convention is the following: + +```python exec="on" source="material-block" result="json" session="end-0" +import qadence as qd + +state_big = qd.product_state("10", endianness = qd.Endianness.BIG) # or just "Big" +state_little = qd.product_state("10", endianness = qd.Endianness.LITTLE) # or just "Little" + +print(state_big) # The state |10>, the 3rd basis state. +print(state_little) # The state |01>, the 2nd basis state. +``` + +Here we took a bit word written as a Python string and used it to create the respective basis state following both conventions. However, note that we would actually get the same results by saying that we fixed the endianness convention as big-endian, thus creating the state $|10\rangle$ in both cases, but changed the basis state ordering. We could also make a similar argument for fixing both endianness and basis state ordering and simply changing the qubit index order. This is simply an illustration of how these concepts are connected. + +Another example where endianness will come directly into play is when *measuring* a register. A big or little endian measurement will choose the first or the last qubit, respectively, as the most significant bit. Let's see this in an example: + +```python exec="on" source="material-block" result="json" session="end-0" +# Create superposition state: |00> + |01> (normalized) +block = qd.I(0) @ qd.H(1) # Identity on qubit 0, Hadamard on qubit 1 + +# Generate bitword samples following both conventions +result_big = qd.sample(block, endianness = qd.Endianness.BIG) +result_little = qd.sample(block, endianness = qd.Endianness.LITTLE) + +print(result_big) # Samples "00" and "01" +print(result_little) # Samples "00" and "10" +``` + +In `qadence` we can also invert endianness of many objects with the same `invert_endianness` function: + +```python exec="on" source="material-block" result="json" session="end-0" +# Equivalent to sampling in little-endian. +print(qd.invert_endianness(result_big)) + +# Equivalent to a state created in little-endian +print(qd.invert_endianness(state_big)) +``` + +### Quantum operations + +When looking at quantum operations in matrix form, our usage of the term *endianness* slightly deviates from its absolute definition. To exemplify, we maybe consider the CNOT operation with `control = 0` and `target = 1`. This operation is often described with two different matrices: + +$$ +\text{CNOT(0, 1)} = +\begin{bmatrix} +1 & 0 & 0 & 0 \\ +0 & 1 & 0 & 0 \\ +0 & 0 & 0 & 1 \\ +0 & 0 & 1 & 0 \\ +\end{bmatrix} +\qquad +\text{or} +\qquad +\text{CNOT(0, 1)} = +\begin{bmatrix} +1 & 0 & 0 & 0 \\ +0 & 0 & 0 & 1 \\ +0 & 0 & 1 & 0 \\ +0 & 1 & 0 & 0 \\ +\end{bmatrix} +$$ + +The difference between these two matrices can be easily explained either by considering a different ordering of the qubit indices, or a different ordering of the basis states. In `qadence`, we can get both through the endianness argument: + +```python exec="on" source="material-block" result="json" session="end-0" +matrix_big = qd.block_to_tensor(qd.CNOT(0, 1), endianness = "Big") +print(matrix_big.detach()) +print("") # markdown-exec: hide +matrix_big = qd.block_to_tensor(qd.CNOT(0, 1), endianness = "Little") +print(matrix_big.detach()) +``` + +While the usage of the term here may not be fully accurate, it helps with keeping a consistent interface, and it still relates to the same general idea of qubit index ordering or which qubit is considered the most significant. + +## Backends + +An important part of having clear state conventions is that we need to make sure our results are consistent accross different computational backends, which may have their own conventions that we need to take into account. In `qadence` we take care of this automatically, such that by calling a certain operation for different backends we expect a result that is equivalent in qubit ordering. + +```python exec="on" source="material-block" result="json" session="end-0" +import warnings # markdown-exec: hide +warnings.filterwarnings("ignore") # markdown-exec: hide + +import qadence as qd +import torch + +# RX(pi/4) on qubit 1 +n_qubits = 2 +op = qd.RX(1, torch.pi/4) + +print("Same sampling order:") +print(qd.sample(n_qubits, op, endianness = "Big", backend = qd.BackendName.PYQTORCH)) +print(qd.sample(n_qubits, op, endianness = "Big" ,backend = qd.BackendName.BRAKET)) +print(qd.sample(n_qubits, op, endianness = "Big", backend = qd.BackendName.PULSER)) +print("") # markdown-exec: hide +print("Same wavefunction order:") +print(qd.run(n_qubits, op, endianness = "Big", backend = qd.BackendName.PYQTORCH)) +print(qd.run(n_qubits, op, endianness = "Big" ,backend = qd.BackendName.BRAKET)) +print(qd.run(n_qubits, op, endianness = "Big", backend = qd.BackendName.PULSER)) +``` diff --git a/examples/backends/README.md b/examples/backends/README.md new file mode 100644 index 00000000..8737d043 --- /dev/null +++ b/examples/backends/README.md @@ -0,0 +1 @@ +Here we show how to use the backends diff --git a/examples/backends/differentiable_backend.py b/examples/backends/differentiable_backend.py new file mode 100644 index 00000000..11a2b525 --- /dev/null +++ b/examples/backends/differentiable_backend.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import numpy as np +import sympy +import torch + +from qadence import ( + CNOT, + RX, + RY, + DifferentiableBackend, + Parameter, + QuantumCircuit, + chain, + total_magnetization, +) +from qadence.backends.pyqtorch.backend import Backend as PyQTorchBackend + +torch.manual_seed(42) + + +def circuit(n_qubits): + """Helper function to make an example circuit""" + + x = Parameter("x", trainable=False) + theta = Parameter("theta") + + fm = chain(RX(0, 3 * x), RY(1, sympy.exp(x)), RX(0, theta), RY(1, np.pi / 2)) + ansatz = CNOT(0, 1) + block = chain(fm, ansatz) + + circ = QuantumCircuit(n_qubits, block) + + return circ + + +if __name__ == "__main__": + torch.manual_seed(42) + n_qubits = 2 + batch_size = 5 + + # Making circuit with AD + circ = circuit(n_qubits) + observable = total_magnetization(n_qubits=n_qubits) + quantum_backend = PyQTorchBackend() + diff_backend = DifferentiableBackend(quantum_backend, diff_mode="ad") + diff_circ, diff_obs, embed, params = diff_backend.convert(circ, observable) + + # Running for some inputs + values = {"x": torch.rand(batch_size, requires_grad=True)} + wf = diff_backend.run(diff_circ, embed(params, values)) + expval = diff_backend.expectation(diff_circ, diff_obs, embed(params, values)) + dexpval_x = torch.autograd.grad( + expval, values["x"], torch.ones_like(expval), create_graph=True + )[0] + dexpval_xx = torch.autograd.grad( + dexpval_x, values["x"], torch.ones_like(dexpval_x), create_graph=True + )[0] + dexpval_xxtheta = torch.autograd.grad( + dexpval_xx, + list(params.values())[0], + torch.ones_like(dexpval_xx), + retain_graph=True, + )[0] + dexpval_theta = torch.autograd.grad(expval, list(params.values())[0], torch.ones_like(expval))[ + 0 + ] + + # Now running stuff for PSR + diff_backend = DifferentiableBackend(quantum_backend, diff_mode="gpsr") + expval = diff_backend.expectation(diff_circ, diff_obs, embed(params, values)) + dexpval_psr_x = torch.autograd.grad( + expval, values["x"], torch.ones_like(expval), create_graph=True + )[0] + dexpval_psr_xx = torch.autograd.grad( + dexpval_psr_x, values["x"], torch.ones_like(dexpval_psr_x), create_graph=True + )[0] + dexpval_psr_xxtheta = torch.autograd.grad( + dexpval_psr_xx, + list(params.values())[0], + torch.ones_like(dexpval_psr_xx), + retain_graph=True, + )[0] + dexpval_psr_theta = torch.autograd.grad( + expval, list(params.values())[0], torch.ones_like(expval) + )[0] + + print(f"Derivative with respect to 'x' with AD: {dexpval_x}") + print(f"Derivative with respect to 'x' with PSR: {dexpval_psr_x}") + print(f"Derivative with respect to 'xx' with AD: {dexpval_xx}") + print(f"Derivative with respect to 'xx' with PSR: {dexpval_psr_xx}") + print(f"Derivative with respect to 'xx, theta' with AD: {dexpval_xxtheta}") + print(f"Derivative with respect to 'xx, theta' with PSR: {dexpval_psr_xxtheta}") + print(f"Derivative with respect to 'theta' with ad: {dexpval_theta}") + print(f"Derivative with respect to 'theta' with PSR: {dexpval_psr_theta}") diff --git a/examples/backends/low_level/README.md b/examples/backends/low_level/README.md new file mode 100644 index 00000000..35e5a953 --- /dev/null +++ b/examples/backends/low_level/README.md @@ -0,0 +1,6 @@ +These examples show how to use the backends directly. That is, how to use +qadence to define the `QuantumCircuit` but have it executed directly in backend without +using the autodiff wrapper. + +Although it is straight-forward, this shouldn't be necessary for most usecases. +Please shoot us a quick message before using this approach for a project. diff --git a/examples/backends/low_level/braket_digital.py b/examples/backends/low_level/braket_digital.py new file mode 100644 index 00000000..ba5785cf --- /dev/null +++ b/examples/backends/low_level/braket_digital.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import numpy as np +import sympy +from braket.circuits import Noise +from braket.devices import LocalSimulator + +from qadence import ( + CNOT, + RX, + RZ, + Parameter, + QuantumCircuit, + backend_factory, + chain, + total_magnetization, +) +from qadence.backend import BackendName +from qadence.backends.pytorch_wrapper import DiffMode + +# def circuit(n_qubits): +# # make feature map with input parameters +# fm = chain(RX(0, 3 * x), RZ(1, z), CNOT(0, 1)) +# fm = set_trainable(fm, value=False) + +# # make trainable ansatz +# ansatz = [] +# for i, q in enumerate(range(n_qubits)): +# ansatz.append( +# chain( +# RX(q, f"theta_0{i}"), +# RZ(q, f"theta_1{i}"), +# RX(q, f"theta_2{i}"), +# ) +# ) +# ansatz = kron(ansatz[0], ansatz[1]) +# ansatz *= CNOT(0, 1) + +# block = chain(fm, ansatz) +# circ = QuantumCircuit(n_qubits=n_qubits, blocks=block) +# return circ + + +def circuit(n_qubits): + """Helper function to make an example circuit""" + + x = Parameter("x", trainable=False) + y = Parameter("y", trainable=False) + + fm = chain(RX(0, 3 * x), RZ(1, sympy.exp(y)), RX(0, np.pi / 2), RZ(1, "theta")) + ansatz = CNOT(0, 1) + block = chain(fm, ansatz) + + circ = QuantumCircuit(n_qubits, block) + return circ + + +if __name__ == "__main__": + import torch + + torch.manual_seed(10) + + n_qubits = 2 + circ = circuit(n_qubits) + + observable = total_magnetization(n_qubits=n_qubits) + braket_backend = backend_factory(backend=BackendName.BRAKET, diff_mode=DiffMode.GPSR) + + batch_size = 1 + values = { + "x": torch.rand(batch_size, requires_grad=True), + "y": torch.rand(batch_size, requires_grad=True), + } + + # you can unpack the conversion result or just use conv.circuit, etc. + conv = braket_backend.convert(circ, observable) + (braket_circuit, braket_observable, embed, params) = conv + + wf = braket_backend.run(braket_circuit, embed(params, values)) + expval = braket_backend.expectation(braket_circuit, braket_observable, embed(params, values)) + dexpval_braket = torch.autograd.grad( + expval, values["x"], torch.ones_like(expval), retain_graph=True + )[0] + + pyq_backend = backend_factory(backend=BackendName.PYQTORCH, diff_mode=DiffMode.AD) + conv = pyq_backend.convert(circ, observable) + + wf = pyq_backend.run(conv.circuit, conv.embedding_fn(conv.params, values)) + expval = pyq_backend.expectation( + conv.circuit, conv.observable, conv.embedding_fn(conv.params, values) + ) + dexpval_pyq = torch.autograd.grad( + expval, values["x"], torch.ones_like(expval), retain_graph=True + )[0] + + assert torch.allclose(dexpval_braket, dexpval_pyq, atol=1e-4, rtol=1e-4) + + # sample + samples = braket_backend.sample(braket_circuit, embed(params, values), n_shots=1000) + print(f"Samples: {samples}") + + ## use the backend with the low-level interface + + # retrieve parameters + params = embed(params, values) + + # use the native representation directly + native = braket_circuit.native + + # define a noise channel + noise = Noise.Depolarizing(probability=0.1) + + # add noise to every gate in the circuit + native.apply_gate_noise(noise) + + # use density matrix simulator for noise simulations + device = LocalSimulator("braket_dm") + native = braket_backend.assign_parameters(braket_circuit, params) + result = device.run(native, shots=1000).result().measurement_counts + print("With noise") + print(result) + print("Noisy circuit") + + # obtain the braket diagram + print(native.diagram()) diff --git a/examples/backends/low_level/overlap.py b/examples/backends/low_level/overlap.py new file mode 100644 index 00000000..d6e8bc10 --- /dev/null +++ b/examples/backends/low_level/overlap.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import numpy as np +import torch + +from qadence import ( + RX, + RY, + BackendName, + FeatureParameter, + H, + Overlap, + OverlapMethod, + QuantumCircuit, + QuantumModel, + VariationalParameter, + chain, + kron, + tag, +) + +n_qubits = 1 + +# prepare circuit for bras +param_bra = FeatureParameter("phi") +block_bra = kron(*[RX(qubit, param_bra) for qubit in range(n_qubits)]) +fm_bra = tag(block_bra, tag="feature-map-bra") +circuit_bra = QuantumCircuit(n_qubits, fm_bra) + +# prepare circuit for kets +param_ket = FeatureParameter("psi") +block_ket = kron(*[RX(qubit, param_ket) for qubit in range(n_qubits)]) +fm_ket = tag(block_ket, tag="feature-map-ket") +circuit_ket = QuantumCircuit(n_qubits, fm_ket) + +# values for circuits +values_bra = {"phi": torch.Tensor([np.pi, np.pi / 4, np.pi / 3])} +values_ket = {"psi": torch.Tensor([np.pi, np.pi / 2, np.pi / 5])} + +backend_name = BackendName.PYQTORCH + +# calculate overlap with exact method +ovrlp = Overlap(circuit_bra, circuit_ket, backend=backend_name, method=OverlapMethod.EXACT) +ovrlp_exact = ovrlp(values_bra, values_ket) +print("Exact overlap:\n", ovrlp_exact) + +# calculate overlap with shots +ovrlp = Overlap(circuit_bra, circuit_ket, backend=backend_name, method=OverlapMethod.JENSEN_SHANNON) +ovrlp_js = ovrlp(values_bra, values_ket, n_shots=10000) +print("Jensen-Shannon overlap:\n", ovrlp_js) + + +class LearnHadamard(QuantumModel): + def __init__( + self, + train_circuit: QuantumCircuit, + target_circuit: QuantumCircuit, + backend: BackendName = BackendName.PYQTORCH, + ): + super().__init__(circuit=train_circuit, backend=backend) + + self.overlap_fn = Overlap( + train_circuit, target_circuit, backend=backend, method=OverlapMethod.EXACT + ) + + def forward(self): + return self.overlap_fn() + + +phi = VariationalParameter("phi") +theta = VariationalParameter("theta") + +train_circuit = QuantumCircuit(1, chain(RX(0, phi), RY(0, theta))) +target_circuit = QuantumCircuit(1, H(0)) + +model = LearnHadamard(train_circuit, target_circuit) + + +# Applies the Hadamard on the 0 state +print("BEFORE TRAINING:") +print(model.overlap_fn.ket_model.run({}).detach()) +print(model.overlap_fn.run({}).detach()) +print() + +optimizer = torch.optim.Adam(model.parameters(), lr=0.25) +loss_criterion = torch.nn.MSELoss() +n_epochs = 1000 +loss_save = [] + +for i in range(n_epochs): + optimizer.zero_grad() + loss = loss_criterion(torch.tensor([[1.0]]), model()) + loss.backward() + optimizer.step() + loss_save.append(loss.item()) + + +# Applies the Hadamard on the 0 state +print("AFTER TRAINING:") +print(model.overlap_fn.ket_model.run({}).detach()) +print(model.overlap_fn.run({}).detach()) diff --git a/examples/backends/low_level/pyq.py b/examples/backends/low_level/pyq.py new file mode 100644 index 00000000..e5b2b87b --- /dev/null +++ b/examples/backends/low_level/pyq.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import numpy as np +import sympy +import torch + +from qadence import CNOT, RX, RZ, Parameter, QuantumCircuit, chain, total_magnetization +from qadence.backends.pyqtorch.backend import Backend as PyQTorchBackend + +torch.manual_seed(42) + + +def circuit(n_qubits): + """Helper function to make an example circuit""" + + x = Parameter("x", trainable=False) + y = Parameter("y", trainable=False) + theta = Parameter("theta") + + fm = chain(RX(0, 3 * x), RX(1, sympy.exp(y)), RX(0, theta), RZ(1, np.pi / 2)) + ansatz = CNOT(0, 1) + block = chain(fm, ansatz) + + circ = QuantumCircuit(n_qubits, block) + + return circ + + +if __name__ == "__main__": + torch.manual_seed(42) + n_qubits = 2 + batch_size = 5 + + # Making circuit with AD + circ = circuit(n_qubits) + observable = total_magnetization(n_qubits=n_qubits) + backend = PyQTorchBackend() + pyq_circ, pyq_obs, embed, params = backend.convert(circ, observable) + + batch_size = 5 + values = { + "x": torch.rand(batch_size, requires_grad=True), + "y": torch.rand(batch_size, requires_grad=True), + } + + wf = backend.run(pyq_circ, embed(params, values)) + samples = backend.sample(pyq_circ, embed(params, values)) + expval = backend.expectation(pyq_circ, pyq_obs, embed(params, values)) + dexpval_x = torch.autograd.grad( + expval, values["x"], torch.ones_like(expval), retain_graph=True + )[0] + dexpval_y = torch.autograd.grad( + expval, values["y"], torch.ones_like(expval), retain_graph=True + )[0] + + print(f"Statevector: {wf}") + print(f"Samples: {samples}") + print(f"Gradient w.r.t. 'x': {dexpval_x}") + print(f"Gradient w.r.t. 'y': {dexpval_y}") diff --git a/examples/digital-analog/fit-sin.py b/examples/digital-analog/fit-sin.py new file mode 100644 index 00000000..75cfc2b4 --- /dev/null +++ b/examples/digital-analog/fit-sin.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import sys +from timeit import timeit + +import matplotlib.pyplot as plt +import torch + +from qadence import ( + AnalogRX, + AnalogRZ, + FeatureParameter, + QuantumCircuit, + QuantumModel, + Register, + VariationalParameter, + Z, + add, + chain, + expectation, + wait, +) +from qadence.backends.pytorch_wrapper import DiffMode + +pi = torch.pi +SHOW_PLOTS = sys.argv[1] == "show" if len(sys.argv) == 2 else False + + +def plot(x, y, **kwargs): + xnp = x.detach().cpu().numpy().flatten() + ynp = y.detach().cpu().numpy().flatten() + return plt.plot(xnp, ynp, **kwargs) + + +def scatter(x, y, **kwargs): + xnp = x.detach().cpu().numpy().flatten() + ynp = y.detach().cpu().numpy().flatten() + return plt.scatter(xnp, ynp, **kwargs) + + +# two qubit register +reg = Register.from_coordinates([(0, 0), (0, 12)]) + +# analog ansatz with input parameter +t = FeatureParameter("t") + +block = chain( + AnalogRX(pi / 2), + AnalogRZ(t), + # NOTE: for a better fit, manually set delta + # AnalogRot(duration=1000 / (6 * torch.pi) * t, delta=6 * torch.pi), # RZ + wait(1000 * VariationalParameter("theta", value=0.5)), + AnalogRX(pi / 2), +) + +# observable +obs = add(Z(i) for i in range(reg.n_qubits)) + + +# define problem +x_train = torch.linspace(0, 6, steps=30) +y_train = -0.64 * torch.sin(x_train + 0.33) + 0.1 + +y_pred_initial = expectation(reg, block, obs, values={"t": x_train}) + + +# define quantum model; including digital-analog emulation +circ = QuantumCircuit(reg, block) +model = QuantumModel(circ, obs, diff_mode=DiffMode.GPSR) + +mse_loss = torch.nn.MSELoss() +optimizer = torch.optim.Adam(model.parameters(), lr=5e-2) + + +def loss_fn(x_train, y_train): + return mse_loss(model.expectation({"t": x_train}).squeeze(), y_train) + + +print(loss_fn(x_train, y_train)) +print(timeit(lambda: loss_fn(x_train, y_train), number=5)) + +# train +n_epochs = 200 + +for i in range(n_epochs): + optimizer.zero_grad() + + loss = loss_fn(x_train, y_train) + loss.backward() + optimizer.step() + + if (i + 1) % 10 == 0: + print(f"Epoch {i+1:0>3} - Loss: {loss.item()}") + +# visualize +y_pred = model.expectation({"t": x_train}) + +plt.figure() +scatter(x_train, y_train, label="Training points", marker="o", color="green") +plot(x_train, y_pred_initial, label="Initial prediction") +plot(x_train, y_pred, label="Final prediction") + + +plt.legend() +if SHOW_PLOTS: + plt.show() + +assert loss_fn(x_train, y_train) < 0.05 diff --git a/examples/digital-analog/qubo.py b/examples/digital-analog/qubo.py new file mode 100644 index 00000000..41f688d3 --- /dev/null +++ b/examples/digital-analog/qubo.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import random + +import matplotlib.pyplot as plt +import numpy as np +import torch +from pulser.devices import Chadoq2 +from scipy.optimize import minimize +from scipy.spatial.distance import pdist, squareform + +from qadence import ( + AnalogRX, + AnalogRZ, + QuantumCircuit, + QuantumModel, + Register, + add_interaction, + chain, +) +from qadence.transpile.emulate import ising_interaction + +SHOW_PLOTS = False +torch.manual_seed(0) +np.random.seed(0) +random.seed(0) + + +def qubo_register_coords(Q): + """Compute coordinates for register.""" + bitstrings = [np.binary_repr(i, len(Q)) for i in range(len(Q) ** 2)] + costs = [] + # this takes exponential time with the dimension of the QUBO + for b in bitstrings: + z = np.array(list(b), dtype=int) + cost = z.T @ Q @ z + costs.append(cost) + zipped = zip(bitstrings, costs) + sort_zipped = sorted(zipped, key=lambda x: x[1]) + print(sort_zipped[:3]) + + def evaluate_mapping(new_coords, *args): + """Cost function to minimize. Ideally, the pairwise + distances are conserved""" + Q, shape = args + new_coords = np.reshape(new_coords, shape) + new_Q = squareform(Chadoq2.interaction_coeff / pdist(new_coords) ** 6) + return np.linalg.norm(new_Q - Q) + + shape = (len(Q), 2) + costs = [] + np.random.seed(0) + x0 = np.random.random(shape).flatten() + res = minimize( + evaluate_mapping, + x0, + args=(Q, shape), + method="Nelder-Mead", + tol=1e-6, + options={"maxiter": 200000, "maxfev": None}, + ) + return [(x, y) for (x, y) in np.reshape(res.x, (len(Q), 2))] + + +def cost_colouring(bitstring, Q): + z = np.array(list(bitstring), dtype=int) + cost = z.T @ Q @ z + return cost + + +def cost(counter, Q): + cost = sum(counter[key] * cost_colouring(key, Q) for key in counter) + return cost / sum(counter.values()) # Divide by total samples + + +def plot_distribution(counter, solution_bitstrings=["01011", "00111"], ax=None): + if ax is None: + _, ax = plt.subplots(figsize=(12, 6)) + + xs, ys = zip(*sorted(counter.items(), key=lambda item: item[1], reverse=True)) + colors = ["r" if x in solution_bitstrings else "g" for x in xs] + + ax.set_xlabel("bitstrings") + ax.set_ylabel("counts") + ax.bar(xs, ys, width=0.5, color=colors) + ax.tick_params(axis="x", labelrotation=90) + return ax + + +fig, ax = plt.subplots(1, 2, figsize=(12, 4)) + +Q = np.array( + [ + [-10.0, 19.7365809, 19.7365809, 5.42015853, 5.42015853], + [19.7365809, -10.0, 20.67626392, 0.17675796, 0.85604541], + [19.7365809, 20.67626392, -10.0, 0.85604541, 0.17675796], + [5.42015853, 0.17675796, 0.85604541, -10.0, 0.32306662], + [5.42015853, 0.85604541, 0.17675796, 0.32306662, -10.0], + ] +) + + +LAYERS = 2 +reg = Register.from_coordinates(qubo_register_coords(Q)) +block = chain(*[AnalogRX(f"t{i}") * AnalogRZ(f"s{i}") for i in range(LAYERS)]) +emulated = add_interaction( + reg, block, interaction=lambda r, ps: ising_interaction(r, ps, rydberg_level=70) +) +model = QuantumModel(QuantumCircuit(reg, emulated), diff_mode="gpsr") +cnts = model.sample({}, n_shots=1000)[0] + +plot_distribution(cnts, ax=ax[0]) + + +def loss(param, *args): + Q = args[0] + param = torch.tensor(param) + model.reset_vparams(param) + C = model.sample({}, n_shots=1000)[0] + return cost(C, Q) + + +scores = [] +params = [] +for repetition in range(30): + try: + res = minimize( + loss, + args=Q, + x0=np.random.uniform(1, 10, size=2 * LAYERS), + method="Nelder-Mead", + tol=1e-5, + options={"maxiter": 20}, + ) + scores.append(res.fun) + params.append(res.x) + except Exception as e: + pass + +model.reset_vparams(params[np.argmin(scores)]) +optimal_count_dict = model.sample({}, n_shots=1000)[0] +plot_distribution(optimal_count_dict, ax=ax[1]) +plt.tight_layout() + +if SHOW_PLOTS: + plt.show() + +xs, _ = zip(*sorted(optimal_count_dict.items(), key=lambda item: item[1], reverse=True)) +assert (xs[0] == "01011" and xs[1] == "00111") or (xs[1] == "01011" and xs[0] == "00111"), f"{xs}" diff --git a/examples/draw.py b/examples/draw.py new file mode 100644 index 00000000..2a666a48 --- /dev/null +++ b/examples/draw.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import os +from sympy import cos, sin + +from qadence import * +from qadence.draw import display, savefig +from qadence.draw.themes import BaseTheme + + +class CustomTheme(BaseTheme): + background_color = "white" + color = "black" + fontname = "Comic Sans MS" + fontsize = "30" + primitive_node = {"fillcolor": "green", "color": "black"} + variational_parametric_node = {"fillcolor": "blue", "color": "black"} + fixed_parametric_node = {"fillcolor": "red", "color": "black"} + feature_parametric_node = {"fillcolor": "yellow", "color": "black"} + hamevo_cluster = {"fillcolor": "pink", "color": "black"} + add_cluster = {"fillcolor": "white", "color": "black"} + scale_cluster = {"fillcolor": "white", "color": "black"} + + +x = Parameter("x") +y = Parameter("y", trainable=False) + +constants = kron(tag(kron(X(0), Y(1), H(2)), "a"), tag(kron(Z(5), Z(6)), "z")) +constants.tag = "const" + +fixed = kron(RX(0, 0.511111), RY(1, 0.8), RZ(2, 0.9), CRZ(3, 4, 2.2), PHASE(6, 1.1)) +fixed.tag = "fixed" + +feat = kron(RX(0, y), RY(1, sin(y)), RZ(2, cos(y)), CRZ(3, 4, y**2), PHASE(6, y)) +feat.tag = "feat" + +vari = kron(RX(0, x**2), RY(1, sin(x)), CZ(3, 2), MCRY([4, 5], 6, "x")) +vari.tag = "vari" + +hamevo = HamEvo(kron(*map(Z, range(constants.n_qubits))), 10) + +b = chain( + constants, + fixed, + hamevo, + feat, + HamEvo(kron(*map(Z, range(constants.n_qubits))), 10), + vari, + add(*map(X, range(constants.n_qubits))), + 2.1 * kron(*map(X, range(constants.n_qubits))), + SWAP(0, 1), + kron(SWAP(0, 1), SWAP(3, 4)), +) +# d = make_diagram(b) +# d.show() + +circuit = QuantumCircuit(b.n_qubits, b) +# you can use the custom theme like this +# display(circuit, theme=CustomTheme()) + + +if os.environ.get("CI") == "true": + savefig(circuit, "test.png") +else: + display(circuit, theme="dark") + +# FIXME: this is not working yet because total_magnetization blocks completely mess up the +# graph layout for some reason :( +# o = total_magnetization(b.n_qubits) +# m = QuantumModel(c, o) +# d = make_diagram(m) +# d.show() diff --git a/examples/models/qnn.py b/examples/models/qnn.py new file mode 100644 index 00000000..43f1c2e3 --- /dev/null +++ b/examples/models/qnn.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +try: + import matplotlib.pyplot as plt +except ImportError: + plt = None +import numpy as np +import torch +from torch.autograd import grad + +from qadence import QNN, QuantumCircuit, chebyshev_feature_map, hea, total_magnetization +from qadence.transpile import set_trainable + +torch.manual_seed(42) +np.random.seed(42) + +do_plotting = False + + +# Equation we want to learn +def f(x): + return 3 * x**2 + 2 * x - 1 + + +# calculation of constant terms +# -> d2ydx2 = 6 +# -> dydx = 6 * x + 2 +# dy[0] = 2 +# y[0] = -1 + + +# Using torch derivatives directly +def d2y(ufa, x): + y = ufa(x) + dydx = grad(y, x, torch.ones_like(y), create_graph=True, retain_graph=True)[0] + d2ydx2 = grad(dydx, x, torch.ones_like(dydx), create_graph=True, retain_graph=True)[0] + return d2ydx2 - 6.0 + + +def dy0(ufa, x): + y = ufa(x) + dydx = grad(y, x, torch.ones_like(y), create_graph=True)[0] + return dydx - 2.0 + + +n_qubits = 5 +batch_size = 100 +x = torch.linspace(-0.5, 0.5, batch_size).reshape(batch_size, 1).requires_grad_() +x0 = torch.zeros((1, 1), requires_grad=True) +x1 = torch.zeros((1, 1), requires_grad=True) + +feature_map = set_trainable(chebyshev_feature_map(n_qubits=5), False) +ansatz = set_trainable(hea(n_qubits=5, depth=5, periodic=True)) +circ = QuantumCircuit(5, feature_map, ansatz) +ufa = QNN(circ, observable=total_magnetization(n_qubits=5)) + +x = torch.linspace(-0.5, 0.5, 100).reshape(-1, 1) +y = ufa(x) + +if do_plotting: + xn = x.detach().numpy().reshape(-1) + yn = y.detach().numpy().reshape(-1) + yt = f(x) + plt.plot(xn, yt, label="Truth") + plt.plot(xn, yn, label="Pred.") + plt.legend() + plt.show() diff --git a/examples/models/quantum_model.py b/examples/models/quantum_model.py new file mode 100644 index 00000000..539b42d1 --- /dev/null +++ b/examples/models/quantum_model.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import numpy as np +import sympy +import torch + +from qadence import ( + CNOT, + RX, + RZ, + Parameter, + QuantumCircuit, + QuantumModel, + chain, + total_magnetization, +) +from qadence.backend import BackendName +from qadence.backends.pytorch_wrapper import DiffMode + +torch.manual_seed(42) + + +def circuit(n_qubits): + x = Parameter("x", trainable=False) + y = Parameter("y", trainable=False) + + fm = chain(RX(0, 3 * x), RZ(1, sympy.exp(y)), RX(0, np.pi / 2), RZ(1, "theta")) + ansatz = CNOT(0, 1) + block = chain(fm, ansatz) + + return QuantumCircuit(n_qubits, block) + + +if __name__ == "__main__": + n_qubits = 2 + batch_size = 5 + + observable = total_magnetization(n_qubits) + model = QuantumModel( + circuit(n_qubits), + observable=observable, + backend=BackendName.PYQTORCH, + diff_mode=DiffMode.AD, + ) + print(list(model.parameters())) + nx = torch.rand(batch_size, requires_grad=True) + ny = torch.rand(batch_size, requires_grad=True) + values = {"x": nx, "y": ny} + + print(f"Expectation values: {model.expectation(values)}") + + # This works! + model.zero_grad() + loss = torch.mean(model.expectation(values)) + loss.backward() + + print("Gradients using autograd: \n") + print("Gradient in model: \n") + for key, param in model.named_parameters(): + print(f"{key}: {param.grad}") + + # This works too! + print("Gradient of inputs: \n") + print(torch.autograd.grad(torch.mean(model.expectation(values)), nx)) + print(torch.autograd.grad(torch.mean(model.expectation(values)), ny)) + + # Now using PSR + model = QuantumModel( + circuit(n_qubits), + observable=observable, + backend=BackendName.PYQTORCH, + diff_mode=DiffMode.GPSR, + ) + model.zero_grad() + loss = torch.mean(model.expectation(values)) + loss.backward() + + print("Gradients using PSR: \n") + print("Gradient in model: \n") + for key, param in model.named_parameters(): + print(f"{key}: {param.grad}") + + # This works too! + print("Gradient of inputs: \n") + print(torch.autograd.grad(torch.mean(model.expectation(values)), nx)) + print(torch.autograd.grad(torch.mean(model.expectation(values)), ny)) diff --git a/examples/quick_start.py b/examples/quick_start.py new file mode 100644 index 00000000..aa6659d9 --- /dev/null +++ b/examples/quick_start.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import torch + +# qadence has many submodules +from qadence.blocks import kron # block system +from qadence.circuit import QuantumCircuit # circuit to assemble quantum operations +from qadence.ml_tools import TrainConfig, train_with_grad # tools for ML simulations +from qadence.models import QuantumModel # quantum model for execution +from qadence.operations import RX, HamEvo, X, Y, Zero # quantum operations +from qadence.parameters import VariationalParameter # trainable parameters + +# all of the above can also be imported directly from the qadence namespace + +n_qubits = 4 +n_circ_params = n_qubits + +# define some variational parameters +circ_params = [VariationalParameter(f"theta{i}") for i in range(n_circ_params)] + +# block with single qubit rotations +rot_block = kron(RX(i, param) for i, param in enumerate(circ_params)) + +# block with Hamiltonian evolution +t_evo = 2.0 +generator = 0.25 * X(0) + 0.25 * X(1) + 0.5 * Y(2) + 0.5 * Y(3) +ent_block = HamEvo(generator, t_evo) + +# create an observable to measure with tunable coefficients +obs_params = [VariationalParameter(f"phi{i}") for i in range(n_qubits)] +obs = Zero() +for i in range(n_qubits): + obs += obs_params[i] * X(i) + +# create circuit and executable quantum model +circuit = QuantumCircuit(n_qubits, rot_block, ent_block) +model = QuantumModel(circuit, observable=obs, diff_mode="ad") + +samples = model.sample({}, n_shots=1000) +print(samples) # this returns a Counter instance + +# compute the expectation value of the observable +expval = model.expectation({}) +print(expval) + + +# define a loss function and train the model +# using qadence built-in ML tools +def loss_fn(model_: QuantumModel, _): + return model_.expectation({}).squeeze(), {} + + +optimizer = torch.optim.Adam(model.parameters(), lr=0.1) +config = TrainConfig(max_iter=100, checkpoint_every=10, print_every=10) +train_with_grad(model, None, optimizer, config, loss_fn=loss_fn) diff --git a/mkdocs.yml b/mkdocs.yml index d04a7eb2..16cc9a19 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,6 +60,7 @@ nav: - Development: - Architecture and Sharp Bits: development/architecture.md + - Drawing: development/draw.md - Contributing: development/contributing.md edit_uri: edit/main/docs/ diff --git a/qadence/backends/braket/backend.py b/qadence/backends/braket/backend.py index 9a2f6b07..5c7aa868 100644 --- a/qadence/backends/braket/backend.py +++ b/qadence/backends/braket/backend.py @@ -163,7 +163,8 @@ def expectation( res = [] observable = observable if isinstance(observable, list) else [observable] for wf in wfs: - res.extend([torch.vdot(wf, obs.native @ wf).real for obs in observable]) + res.append([torch.vdot(wf, obs.native @ wf).real for obs in observable]) + return torch.tensor(res) def assign_parameters( diff --git a/qadence/backends/gpsr.py b/qadence/backends/gpsr.py index fe94fcdc..6029c963 100644 --- a/qadence/backends/gpsr.py +++ b/qadence/backends/gpsr.py @@ -93,8 +93,9 @@ def multi_gap_psr( # calculate F vector and M matrix # (see: https://arxiv.org/pdf/2108.01218.pdf on p. 4 for definitions) - F = torch.empty(n_eqs, batch_size) + F = [] M = torch.empty((n_eqs, n_eqs)) + n_obs = 1 for i in range(n_eqs): # + shift shifted_params = param_dict.copy() @@ -106,16 +107,23 @@ def multi_gap_psr( shifted_params[param_name] = shifted_params[param_name] - shifts[i] f_minus = expectation_fn(shifted_params) - F[i] = f_plus - f_minus + F.append((f_plus - f_minus)) # calculate M matrix for j in range(n_eqs): M[i, j] = 4 * torch.sin(shifts[i] * spectral_gaps[j] / 2) + # get number of observables from expectation value tensor + if f_plus.numel() > 1: + n_obs = F[0].shape[1] + + # reshape F vector + F = torch.stack(F).reshape(n_eqs, -1) + # calculate R vector R = torch.linalg.solve(M, F) # calculate df/dx - dfdx = torch.sum(spectral_gaps[:, None] * R, dim=0) + dfdx = torch.sum(spectral_gaps[:, None] * R, dim=0).reshape(batch_size, n_obs) return dfdx diff --git a/qadence/backends/pulser/backend.py b/qadence/backends/pulser/backend.py index 8e71ec07..af94c6c2 100644 --- a/qadence/backends/pulser/backend.py +++ b/qadence/backends/pulser/backend.py @@ -229,7 +229,7 @@ def expectation( support = sorted(list(circuit.abstract.register.support)) res_list = [obs.native(state, param_values, qubit_support=support) for obs in observables] - res = torch.transpose(torch.stack(res_list), 0, 1).squeeze() + res = torch.transpose(torch.stack(res_list), 0, 1) res = res if len(res.shape) > 0 else res.reshape(1) return res.real diff --git a/qadence/backends/pyqtorch/backend.py b/qadence/backends/pyqtorch/backend.py index 8daffab4..e2d8100b 100644 --- a/qadence/backends/pyqtorch/backend.py +++ b/qadence/backends/pyqtorch/backend.py @@ -139,11 +139,10 @@ def _batched_expectation( unpyqify_state=False, ) observable = observable if isinstance(observable, list) else [observable] - res_list = [obs.native(state, param_values) for obs in observable] - - # return a tensor of shape `n_batches * n_obs` - res = torch.transpose(torch.stack(res_list), 0, 1).squeeze() - return res if len(res.shape) > 0 else res.reshape(1) + _expectation = torch.hstack( + [obs.native(state, param_values).reshape(-1, 1) for obs in observable] + ) + return _expectation def _looped_expectation( self, @@ -168,8 +167,7 @@ def _looped_expectation( exs = torch.cat([obs.native(wf, vals) for obs in observables], 0) list_expvals.append(exs) - # return a tensor of shape `n_batches * n_obs` - batch_expvals = torch.stack(list_expvals).squeeze() + batch_expvals = torch.vstack(list_expvals) return batch_expvals if len(batch_expvals.shape) > 0 else batch_expvals.reshape(1) def expectation( diff --git a/qadence/backends/pytorch_wrapper.py b/qadence/backends/pytorch_wrapper.py index 54b40975..4c0fca56 100644 --- a/qadence/backends/pytorch_wrapper.py +++ b/qadence/backends/pytorch_wrapper.py @@ -60,7 +60,11 @@ def expectation_fn(params: dict[str, Tensor]) -> Tensor: ) def vjp(psr: Callable, name: str) -> Tensor: - return grad_out * psr(expectation_fn, params, name) + """ + !!! warn + Sums over gradients corresponding to different observables. + """ + return (grad_out * psr(expectation_fn, params, name)).sum(dim=1) grads = [ vjp(psr, name) if needs_grad else None diff --git a/qadence/measurements/shadow.py b/qadence/measurements/shadow.py index 71038aa2..f49e7c56 100644 --- a/qadence/measurements/shadow.py +++ b/qadence/measurements/shadow.py @@ -298,7 +298,7 @@ def estimations( # current batch. batch_estimations.append(sum(pauli_term_estimations)) estimations.append(batch_estimations) - return torch.tensor(estimations, dtype=torch.get_default_dtype()) + return torch.transpose(torch.tensor(estimations, dtype=torch.get_default_dtype()), 1, 0) def compute_expectation( diff --git a/qadence/measurements/tomography.py b/qadence/measurements/tomography.py index f173ad9a..2a8aa57f 100644 --- a/qadence/measurements/tomography.py +++ b/qadence/measurements/tomography.py @@ -129,7 +129,7 @@ def compute_expectation( state: Tensor | None = None, backend_name: BackendName = BackendName.PYQTORCH, endianness: Endianness = Endianness.BIG, -) -> list[Tensor]: +) -> Tensor: """Basic tomography protocol with rotations Given a circuit and a list of observables, apply basic tomography protocol to estimate @@ -158,4 +158,4 @@ def compute_expectation( endianness=endianness, ) ) - return estimated_values + return torch.transpose(torch.vstack(estimated_values), 1, 0) diff --git a/readthedocs.yml b/readthedocs.yml index 352e920e..f68f10e4 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -3,6 +3,8 @@ build: os: "ubuntu-22.04" tools: python: "3.10" + apt_packages: + - graphviz commands: - pip install hatch - hatch -v run docs:build diff --git a/tests/backends/braket/test_quantum_braket.py b/tests/backends/braket/test_quantum_braket.py index 07df6672..8a49e96e 100644 --- a/tests/backends/braket/test_quantum_braket.py +++ b/tests/backends/braket/test_quantum_braket.py @@ -58,8 +58,7 @@ def test_expectation_value_list_of_obs(parametric_circuit: QuantumCircuit) -> No expval = bkd.expectation(bra_circ, bra_obs, embed(params, values)) assert isinstance(expval, torch.Tensor) - assert np.prod(expval.shape) == batch_size * n_obs - assert torch.unique(expval).size(0) == expval.size(0) + assert expval.shape == (batch_size, n_obs) @pytest.mark.parametrize( diff --git a/tests/backends/pyq/test_quantum_pyq.py b/tests/backends/pyq/test_quantum_pyq.py index e2876778..752815d5 100644 --- a/tests/backends/pyq/test_quantum_pyq.py +++ b/tests/backends/pyq/test_quantum_pyq.py @@ -130,14 +130,11 @@ def test_list_observables_with_batches(n_obs: int, loop_expectation: bool) -> No model = QuantumModel(circuit, observables, configuration={"loop_expectation": loop_expectation}) expval = model.expectation(values) - if n_obs == 1: - assert len(expval.shape) == 1 and expval.shape[0] == batch_size - else: - assert len(expval.shape) == 2 and expval.shape[0] == batch_size and expval.shape[1] == n_obs - factors = torch.linspace(1, n_obs, n_obs) - for i, e in enumerate(expval): - tmp = torch.div(e, factors * e[0]) - assert torch.allclose(tmp, torch.ones(n_obs)) + assert len(expval.shape) == 2 and expval.shape[0] == batch_size and expval.shape[1] == n_obs + factors = torch.linspace(1, n_obs, n_obs) + for i, e in enumerate(expval): + tmp = torch.div(e, factors * e[0]) + assert torch.allclose(tmp, torch.ones(n_obs)) @pytest.mark.parametrize("n_shots", [5, 10, 100, 1000, 10000]) diff --git a/tests/backends/test_gpsr.py b/tests/backends/test_gpsr.py index fb03f005..f48a59b9 100644 --- a/tests/backends/test_gpsr.py +++ b/tests/backends/test_gpsr.py @@ -140,18 +140,18 @@ def circuit_analog_rotation_gpsr(n_qubits: int) -> QuantumCircuit: @pytest.mark.parametrize( - ["n_qubits", "batch_size", "circuit_fn"], + ["n_qubits", "batch_size", "n_obs", "circuit_fn"], [ - (2, 5, circuit_psr), - (5, 10, circuit_psr), - (3, 5, circuit_gpsr), - (5, 10, circuit_gpsr), - (3, 1, circuit_hamevo_tensor_gpsr), - (3, 1, circuit_hamevo_block_gpsr), - (3, 1, circuit_analog_rotation_gpsr), + (2, 1, 2, circuit_psr), + (5, 10, 1, circuit_psr), + (3, 1, 4, circuit_gpsr), + (5, 10, 1, circuit_gpsr), + (3, 1, 1, circuit_hamevo_tensor_gpsr), + (3, 1, 1, circuit_hamevo_block_gpsr), + (3, 1, 1, circuit_analog_rotation_gpsr), ], ) -def test_expectation_psr(n_qubits: int, batch_size: int, circuit_fn: Callable) -> None: +def test_expectation_psr(n_qubits: int, batch_size: int, n_obs: int, circuit_fn: Callable) -> None: torch.manual_seed(42) np.random.seed(42) @@ -159,7 +159,7 @@ def test_expectation_psr(n_qubits: int, batch_size: int, circuit_fn: Callable) - circ = circuit_fn(n_qubits) obs = total_magnetization(n_qubits) quantum_backend = PyQBackend() - conv = quantum_backend.convert(circ, obs) + conv = quantum_backend.convert(circ, [obs for _ in range(n_obs)]) pyq_circ, pyq_obs, embedding_fn, params = conv diff_backend = DifferentiableBackend(quantum_backend, diff_mode=DiffMode.AD) @@ -169,6 +169,7 @@ def test_expectation_psr(n_qubits: int, batch_size: int, circuit_fn: Callable) - dexpval_x = torch.autograd.grad( expval, values["x"], torch.ones_like(expval), create_graph=True )[0] + dexpval_xx = torch.autograd.grad( dexpval_x, values["x"], torch.ones_like(dexpval_x), create_graph=True )[0] @@ -189,7 +190,7 @@ def test_expectation_psr(n_qubits: int, batch_size: int, circuit_fn: Callable) - # Now running stuff for (G)PSR quantum_backend.config._use_gate_params = True - conv = quantum_backend.convert(circ, obs) + conv = quantum_backend.convert(circ, [obs for _ in range(n_obs)]) pyq_circ, pyq_obs, embedding_fn, params = conv if circuit_fn == circuit_analog_rotation_gpsr: diff_backend = DifferentiableBackend( @@ -203,6 +204,7 @@ def test_expectation_psr(n_qubits: int, batch_size: int, circuit_fn: Callable) - dexpval_psr_x = torch.autograd.grad( expval, values["x"], torch.ones_like(expval), create_graph=True )[0] + dexpval_psr_xx = torch.autograd.grad( dexpval_psr_x, values["x"], torch.ones_like(dexpval_psr_x), create_graph=True )[0] diff --git a/tests/backends/test_pytorch_wrapper.py b/tests/backends/test_pytorch_wrapper.py index 8764f60b..417808ca 100644 --- a/tests/backends/test_pytorch_wrapper.py +++ b/tests/backends/test_pytorch_wrapper.py @@ -8,7 +8,7 @@ import torch from qadence.backends.api import backend_factory -from qadence.blocks import add, chain, kron +from qadence.blocks import AbstractBlock, add, chain, kron from qadence.circuit import QuantumCircuit from qadence.operations import CNOT, RX, RZ, Z from qadence.parameters import Parameter, VariationalParameter @@ -108,6 +108,18 @@ def test_embeddings() -> None: embed(params, {"x": torch.ones(batch_size)}) +@pytest.mark.parametrize( + "batch_size", + [ + 1, + pytest.param( + "2", + marks=pytest.mark.xfail( + reason="Batch_size and n_obs > 1 should be made consistent." # FIXME + ), + ), + ], +) @pytest.mark.parametrize( "diff_mode", [ @@ -118,16 +130,16 @@ def test_embeddings() -> None: ), ], ) -def test_expval_differentiation(diff_mode: str) -> None: +def test_expval_differentiation(batch_size: int, diff_mode: str) -> None: torch.manual_seed(42) n_qubits = 4 - observable = add(Z(i) * Parameter(f"o_{i}") for i in range(n_qubits)) + observable: list[AbstractBlock] = [add(Z(i) * Parameter(f"o_{i}") for i in range(n_qubits))] + n_obs = len(observable) circ = parametric_circuit(n_qubits) ad_backend = backend_factory(backend="pyqtorch", diff_mode=diff_mode) pyqtorch_circ, pyqtorch_obs, embeddings_fn, params = ad_backend.convert(circ, observable) - batch_size = 1 inputs_x = torch.rand(batch_size, requires_grad=True) inputs_y = torch.rand(batch_size, requires_grad=True) param_w = torch.rand(1, requires_grad=True) @@ -140,8 +152,8 @@ def func(x: torch.Tensor, y: torch.Tensor, w: torch.Tensor) -> torch.Tensor: return ad_backend.expectation(pyqtorch_circ, pyqtorch_obs, all_params) expval = func(inputs_x, inputs_y, param_w) - assert len(expval.size()) == 1 - assert expval.size()[0] == batch_size + # if expval.numel() > 1: + # assert expval.shape == (batch_size, n_obs) # FIXME: higher order torch.autograd.gradcheck(func, (inputs_x, inputs_y, param_w)) diff --git a/tests/models/test_qnn.py b/tests/models/test_qnn.py index 1bcd81ee..31a64d12 100644 --- a/tests/models/test_qnn.py +++ b/tests/models/test_qnn.py @@ -78,8 +78,8 @@ def test_input_nd(dim: int) -> None: res: torch.Tensor = qnn(a) assert qnn.out_features is not None and qnn.out_features == 1 - assert len(res.size()) == qnn.out_features - assert len(res) == batch_size + assert res.size()[1] == qnn.out_features + assert res.size()[0] == batch_size def test_qnn_expectation(n_qubits: int = 4) -> None: diff --git a/tests/models/test_quantum_model.py b/tests/models/test_quantum_model.py index fdbfe617..29186824 100644 --- a/tests/models/test_quantum_model.py +++ b/tests/models/test_quantum_model.py @@ -12,7 +12,7 @@ from metrics import ATOL_DICT, JS_ACCEPTANCE # type: ignore from qadence import BackendName, DiffMode, FeatureParameter, QuantumCircuit, VariationalParameter -from qadence.blocks import chain, kron +from qadence.blocks import AbstractBlock, chain, kron from qadence.constructors import hea, total_magnetization from qadence.divergences import js_divergence from qadence.ml_tools.utils import rand_featureparameters @@ -193,15 +193,19 @@ def test_correct_order(backend: BackendName) -> None: circ = QuantumCircuit(3, X(0)) obs = [Z(0) for _ in range(np.random.randint(1, 5))] + n_obs = len(obs) pyq_model = QuantumModel( circ, observable=obs, backend=BackendName.PYQTORCH, diff_mode=DiffMode.AD # type: ignore ) other_model = QuantumModel( circ, observable=obs, backend=backend, diff_mode=DiffMode.GPSR # type: ignore ) - assert other_model.expectation({})[0].item() == -1 - for pyq_res, other_res in zip(pyq_model.expectation({}), other_model.expectation({})): - assert pyq_res.item() == other_res.item() + + pyq_exp = pyq_model.expectation({}) + other_exp = other_model.expectation({}) + + assert pyq_exp.size() == other_exp.size() + assert torch.all(torch.isclose(pyq_exp, other_exp, atol=ATOL_DICT[BackendName.BRAKET])) def test_qc_obs_different_support_0() -> None: @@ -313,13 +317,20 @@ def test_qm_obs_single_feature_param() -> None: assert torch.all(torch.isclose(model_f.expectation({"x": torch.tensor([2.7])}), model_v_exp)) -@pytest.mark.parametrize("batch_size", [i for i in range(1, 11)]) -def test_qm_obs_batch_feature_param(batch_size: int) -> None: +@pytest.mark.parametrize("batch_size", [1, 2]) +@pytest.mark.parametrize( + "observables", + [[FeatureParameter("x") * Z(0)], [FeatureParameter("x") * Z(0) for i in range(2)]], +) +def test_qm_obs_batch_feature_param(batch_size: int, observables: list[AbstractBlock]) -> None: + n_obs = len(observables) random_batch = torch.rand(batch_size) batch_query_dict = {"x": random_batch} - cost_f = FeatureParameter("x") * Z(0) + expected_output = random_batch.unsqueeze(1).repeat(1, n_obs) + assert expected_output.shape == (batch_size, n_obs) model_f = QuantumModel( - QuantumCircuit(1, I(0)), cost_f, backend=BackendName.PYQTORCH, diff_mode=DiffMode.AD + QuantumCircuit(1, I(0)), observables, backend=BackendName.PYQTORCH, diff_mode=DiffMode.AD ) model_f_exp = model_f.expectation(batch_query_dict) - assert torch.all(torch.isclose(model_f_exp, random_batch)) + + assert torch.all(torch.isclose(model_f_exp, expected_output)) diff --git a/tests/qadence/test_measurements/test_tomography.py b/tests/qadence/test_measurements/test_tomography.py index aa8a8e34..171aa1c3 100644 --- a/tests/qadence/test_measurements/test_tomography.py +++ b/tests/qadence/test_measurements/test_tomography.py @@ -377,16 +377,13 @@ def test_basic_list_observables_tomography_for_quantum_model(circuit: QuantumCir estimated_values = model.expectation( inputs, protocol=Measurements(protocol=Measurements.TOMOGRAPHY, options=kwargs), - )[0] + ) pyqtorch_backend = backend_factory(BackendName.PYQTORCH, diff_mode=DiffMode.GPSR) - pyqtorch_bkd_res = [] - for obs in observable: - (conv_circ, conv_obs, embed, params) = pyqtorch_backend.convert(circuit, obs) - pyqtorch_expectation = pyqtorch_backend.expectation( - conv_circ, conv_obs, embed(params, inputs) - ) - pyqtorch_bkd_res.extend(pyqtorch_expectation) - assert torch.allclose(estimated_values, pyqtorch_bkd_res[0], atol=LOW_ACCEPTANCE) + (conv_circ, conv_obs, embed, params) = pyqtorch_backend.convert( + circuit, observable # type: ignore [arg-type] + ) + pyqtorch_expectation = pyqtorch_backend.expectation(conv_circ, conv_obs, embed(params, inputs)) + assert torch.allclose(estimated_values, pyqtorch_expectation, atol=LOW_ACCEPTANCE) theta1 = Parameter("theta1", trainable=False)