Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bug] Fix qubo tutorial, add tests for whitepaper snippets #635

Merged
merged 2 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 25 additions & 24 deletions docs/tutorials/digital_analog_qc/analog-qubo.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ directly solved using the pulse-level interface Pulser.
distances are conserved"""
Q, shape = args
new_coords = np.reshape(new_coords, shape)
interaction_coeff = device.rydberg_level
interaction_coeff = device.coeff_ising
new_Q = squareform(interaction_coeff / pdist(new_coords) ** 6)
return np.linalg.norm(new_Q - Q)

Expand All @@ -49,26 +49,18 @@ directly solved using the pulse-level interface Pulser.
)
return [(x, y) for (x, y) in np.reshape(res.x, (len(Q), 2))]
```
With the embedding routine under our belt, let's start by adding the required imports and
ensure the reproducibility of this tutorial.

With the embedding routine define above, we can translate a matrix defining a QUBO problem to a set of atom coordinates for the register. The QUBO problem is initially defined by a graph of weighted edges and a cost function to be optimized. The weighted edges are represented by a
real-valued symmetric matrix `Q` which is used throughout the tutorial.

```python exec="on" source="material-block" session="qubo"
import torch
from qadence import QuantumModel, QuantumCircuit, Register
from qadence import RydbergDevice, AnalogRX, AnalogRZ, chain
from qadence.ml_tools import Trainer, TrainConfig, num_parameters
import nevergrad as ng
import matplotlib.pyplot as plt
from qadence import QuantumModel

seed = 0
np.random.seed(seed)
torch.manual_seed(seed)
```

The QUBO problem is initially defined by a graph of weighted edges and a cost function to be optimized. The weighted edges are represented by a
real-valued symmetric matrix `Q` which is used throughout the tutorial.

```python exec="on" source="material-block" session="qubo"
# QUBO problem weights (real-value symmetric matrix)
Q = np.array(
[
Expand All @@ -80,29 +72,34 @@ Q = np.array(
]
)

# Loss function to guide the optimization routine
def loss(model: QuantumModel, *args) -> tuple[torch.Tensor, dict]:
to_arr_fn = lambda bitstring: np.array(list(bitstring), dtype=int)
cost_fn = lambda arr: arr.T @ Q @ arr
samples = model.sample({}, n_shots=1000)[0] # extract samples
samples = model.sample({}, n_shots=1000)[0]
cost_fn = sum(samples[key] * cost_fn(to_arr_fn(key)) for key in samples)
return torch.tensor(cost_fn / sum(samples.values())), {} # We return an optional metrics dict
return torch.tensor(cost_fn / sum(samples.values())), {}
```

The QAOA algorithm needs a variational quantum circuit with optimizable parameters.
For that purpose, we use a fully analog circuit composed of two global rotations per layer on
different axes of the Bloch sphere.
The first rotation corresponds to the mixing Hamiltonian and the second one to the
embedding Hamiltonian [^1]. In this setting, the embedding is realized
by the appropriate register coordinates and the resulting qubit interaction.
by the appropriate register coordinates and the resulting qubit interaction. Details on the analog
blocks used here can be found in the [analog basics tutorial](analog-basics.md).

??? note "Rydberg level"
The Rydberg level is set to *70*. We
The Rydberg level is set to 70. We
initialize the weighted register graph from the QUBO definition
similarly to what is done in the
[original tutorial](https://pulser.readthedocs.io/en/stable/tutorials/qubo.html),
and set the device specifications with the updated Rydberg level.

```python exec="on" source="material-block" result="json" session="qubo"
```python exec="on" source="material-block" session="qubo"
from qadence import QuantumCircuit, Register, RydbergDevice
from qadence import chain, AnalogRX, AnalogRZ

# Device specification and atomic register
device = RydbergDevice(rydberg_level=70)

Expand All @@ -116,8 +113,7 @@ block = chain(*[AnalogRX(f"t{i}") * AnalogRZ(f"s{i}") for i in range(layers)])
circuit = QuantumCircuit(reg, block)
```

By feeding the circuit to a `QuantumModel` we can check the initial
counts where no clear solution can be found:
By initializing the `QuantumModel` with this circuit we can check the initial counts where no clear solution can be found.

```python exec="on" source="material-block" result="json" session="qubo"
model = QuantumModel(circuit)
Expand All @@ -132,34 +128,39 @@ ML facilities to run gradient-free optimizations using the
[`nevergrad`](https://facebookresearch.github.io/nevergrad/) library.

```python exec="on" source="material-block" session="qubo"
from qadence.ml_tools import Trainer, TrainConfig, num_parameters
import nevergrad as ng

Trainer.set_use_grad(False)

config = TrainConfig(max_iter=100)

optimizer = ng.optimizers.NGOpt(
budget=config.max_iter, parametrization=num_parameters(model)
)

trainer = Trainer(model, optimizer, config, loss)

trainer.fit()

optimal_counts = model.sample({}, n_shots=1000)[0]
print(f"optimal_count = {optimal_counts}") # markdown-exec: hide
```

Finally, let's plot the solution. The expected bitstrings are marked in red.

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

# Known solutions to the QUBO problem.
solution_bitstrings = ["01011", "00111"]

def plot_distribution(C, ax, title):
C = dict(sorted(C.items(), key=lambda item: item[1], reverse=True))
indexes = solution_bitstrings # QUBO solutions
color_dict = {key: "r" if key in indexes else "g" for key in C}
color_dict = {key: "r" if key in solution_bitstrings else "b" for key in C}
ax.set_xlabel("bitstrings")
ax.set_ylabel("counts")
ax.set_xticks([i for i in range(len(C.keys()))], C.keys(), rotation=90)
ax.bar(list(C.keys())[:20], list(C.values())[:20])
ax.bar(C.keys(), C.values(), color=color_dict.values())
ax.set_title(title)

plt.tight_layout() # markdown-exec: hide
Expand Down
127 changes: 1 addition & 126 deletions docs/tutorials/qml/ml_tools/trainer.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,129 +548,4 @@ def train(

### 6.5. Gradient-free optimization using `Trainer`

Solving a QUBO using gradient free optimization based on `Nevergrad` optimizers and `Trainer`. This problem is further defined in [QUBO Tutorial](../../digital_analog_qc/analog-qubo.md)


We can achieve gradient free optimization by.
```
Trainer.set_use_grad(False)

# or

trainer.disable_grad_opt(ng_optimizer):
print("Gradient free opt")
```


```python exec="on" source="material-block" session="qubo"
import numpy as np
import numpy.typing as npt
from scipy.optimize import minimize
from scipy.spatial.distance import pdist, squareform
from qadence import RydbergDevice

import torch
from qadence import QuantumModel, QuantumCircuit, Register
from qadence import RydbergDevice, AnalogRX, AnalogRZ, chain
from qadence.ml_tools import Trainer, TrainConfig, num_parameters
import nevergrad as ng
import matplotlib.pyplot as plt

Trainer.set_use_grad(False)

seed = 0
np.random.seed(seed)
torch.manual_seed(seed)

def qubo_register_coords(Q: np.ndarray, device: RydbergDevice) -> list:
"""Compute coordinates for register."""

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)
interaction_coeff = device.rydberg_level
new_Q = squareform(interaction_coeff / pdist(new_coords) ** 6)
return np.linalg.norm(new_Q - Q)

shape = (len(Q), 2)
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))]


# QUBO problem weights (real-value symmetric matrix)
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],
])

# Device specification and atomic register
device = RydbergDevice(rydberg_level=70)

reg = Register.from_coordinates(
qubo_register_coords(Q, device), device_specs=device)

# Analog variational quantum circuit
layers = 2
block = chain(*[AnalogRX(f"t{i}") * AnalogRZ(f"s{i}") for i in range(layers)])
circuit = QuantumCircuit(reg, block)

model = QuantumModel(circuit)
initial_counts = model.sample({}, n_shots=1000)[0]

print(f"initial_counts = {initial_counts}") # markdown-exec: hide

def loss(model: QuantumModel, *args) -> tuple[torch.Tensor, dict]:
to_arr_fn = lambda bitstring: np.array(list(bitstring), dtype=int)
cost_fn = lambda arr: arr.T @ Q @ arr
samples = model.sample({}, n_shots=1000)[0] # extract samples
cost_fn = sum(samples[key] * cost_fn(to_arr_fn(key)) for key in samples)
return torch.tensor(cost_fn / sum(samples.values())), {} # We return an optional metrics dict



# Training
config = TrainConfig(max_iter=100)
optimizer = ng.optimizers.NGOpt(
budget=config.max_iter, parametrization=num_parameters(model)
)
trainer = Trainer(model, optimizer, config, loss)
trainer.fit()

optimal_counts = model.sample({}, n_shots=1000)[0]
print(f"optimal_count = {optimal_counts}") # markdown-exec: hide


# Known solutions to the QUBO problem.
solution_bitstrings = ["01011", "00111"]

def plot_distribution(C, ax, title):
C = dict(sorted(C.items(), key=lambda item: item[1], reverse=True))
indexes = solution_bitstrings # QUBO solutions
color_dict = {key: "r" if key in indexes else "g" for key in C}
ax.set_xlabel("bitstrings")
ax.set_ylabel("counts")
ax.set_xticks([i for i in range(len(C.keys()))], C.keys(), rotation=90)
ax.bar(list(C.keys())[:20], list(C.values())[:20])
ax.set_title(title)

plt.tight_layout() # markdown-exec: hide
fig, axs = plt.subplots(1, 2, figsize=(12, 4))
plot_distribution(initial_counts, axs[0], "Initial counts")
plot_distribution(optimal_counts, axs[1], "Optimal counts")
from docs import docsutils # markdown-exec: hide
print(docsutils.fig_to_html(fig)) # markdown-exec: hide
```
We can achieve gradient free optimization with `Trainer.set_use_grad(False)` or `trainer.disable_grad_opt(ng_optimizer)`. An example solving a QUBO using gradient free optimization based on `Nevergrad` optimizers and `Trainer` is shown in the [analog QUBO Tutorial](../../digital_analog_qc/analog-qubo.md).
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]

[tool.ruff]
select = ["E", "F", "I", "Q"]
extend-ignore = ["F841", "F403"]
extend-ignore = ["F841", "F403", "E731", "E741"]
line-length = 100

[tool.ruff.isort]
Expand Down
7 changes: 7 additions & 0 deletions qadence/analog/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from qadence.analog import AddressingPattern
from qadence.types import PI, DeviceType, Interaction

from .constants import C6_DICT


@dataclass(frozen=True, eq=True)
class RydbergDevice:
Expand Down Expand Up @@ -41,6 +43,11 @@ class RydbergDevice:
type: DeviceType = DeviceType.IDEALIZED
"""DeviceType.IDEALIZED or REALISTIC to convert to the Pulser backend."""

@property
def coeff_ising(self) -> float:
"""Value of C_6."""
return C6_DICT[self.rydberg_level]

def __post_init__(self) -> None:
# FIXME: Currently not supporting custom interaction functions.
if self.interaction not in [Interaction.NN, Interaction.XY]:
Expand Down
Loading
Loading