Hello World: Quantum Machine Learning with Merlin (Cloud)

Welcome! This notebook demonstrates how to use Merlin to build a quantum reservoir algorithm to then run on a real QPU (or in this case, a simulator emulating it). We chose a reservoir algorithm since gradient is not propagated through quantum layers when using a processor.

To use the processor, follow the TODO comments.

1. Install and Import Dependencies

First, lets make sure all required packages are installed and import them.
If you haven’t installed Merlin yet, run:
pip install merlinquantum in your terminal or !pip install merlinquantum in your notebook.
[1]:
import torch
import torch.nn as nn
import merlin as ML
import perceval as pcvl
from merlin.datasets import iris

To run your experiments on a processor (real QPU or a simulator emulating it), we need to create a MerlinProcessor object.

[2]:
#(OPTIONAL) Save your token in a file called .env in the same directory as this notebook, with the following content:
# CLOUD_TOKEN=your_token_here

## TODO Uncomment to load the token
# from dotenv import load_dotenv
# load_dotenv()
# import os
# CLOUD_TOKEN = os.getenv("CLOUD_TOKEN")

Lets first load a perceval RemoteProcessor. It is the original way of accessing Quandela’s cloud. For Scaleway hosted plateforms and any future session-based providers, use pcvl.providers.scaleway instead of pcvl.RemoteProcessor.

Here we use sim:slos which is a noise-free simulator, use sim:ascella if you want a simultor which reproduces the noise of the qpu:ascella QPU.

[3]:
## TODO Uncomment to use the processor
# pcvl.RemoteConfig.set_token(CLOUD_TOKEN)
# rp = pcvl.RemoteProcessor("sim:slos")
[4]:
## TODO Uncomment to use the processor
# proc = ML.MerlinProcessor(
#     rp,
#     microbatch_size=32,        # batch chunk size per cloud call
#     timeout=3600.0,            # default wall-time per forward (seconds)
#     max_shots_per_call=None,   # optional cap per cloud call (see below)
#     chunk_concurrency=1,       # parallel chunk jobs within a quantum leaf
# )

2. Load and Prepare the Iris Dataset

We’ll use the classic Iris dataset, a simple and well-known benchmark for classification.
Let’s load the data and convert it to PyTorch tensors for training.
[5]:
train_features, train_labels, train_metadata = iris.get_data_train()
test_features, test_labels, test_metadata = iris.get_data_test()

# Convert data to PyTorch tensors
X_train = torch.FloatTensor(train_features)
y_train = torch.LongTensor(train_labels)
X_test = torch.FloatTensor(test_features)
y_test = torch.LongTensor(test_labels)

print(f"Training samples: {X_train.shape[0]}")
print(f"Test samples: {X_test.shape[0]}")
print(f"Features: {X_train.shape[1]}")
print(f"Classes: {len(torch.unique(y_train))}")
Training samples: 120
Test samples: 30
Features: 4
Classes: 3

iris

3. Define the Quantum reservoir Model

The model can be split into two parts:

  • The QuantumLayer implements the quantum reservoir.

  • The classical_out is the classical model that takes the reservoir’s output to classify the data.

To make sure that the model runs on the processor, we will need to call the processor’s forward method. We will also need to redefine the parameters method so that only the classical parameters are changed and not the reservoir’s (MerLin’s simple quantum layer creates trainable pytorch parameter by default).

[6]:
class HybridIrisClassifier(nn.Module):
    """
    Hybrid model for Iris classification:
    - Quantum reservoir processes the 4 features
    - Classical output layer for 3-class classification
    """
    def __init__(self):
        super(HybridIrisClassifier, self).__init__()

        # Quantum layer: processes the 4 features
        self.quantum = ML.QuantumLayer.simple(
            input_size=4,
        ).eval()
        # Classical output layer: quantum → 8 → 3
        self.classical_out = nn.Sequential(
            nn.Linear(self.quantum.output_size, 8),
            nn.ReLU(),
            nn.Dropout(0.1),
            nn.Linear(8, 3)
        )
        self.params=self.classical_out.parameters()

    def forward(self, x):
        #TODO Use the commented return if you want to use the reservoir algorithm with the processor.
        #return self.classical_out(proc.forward(self.quantum.eval(),x))
        return self.classical_out(self.quantum.eval()(x))

    def parameters(self):
        return self.params

This could be ran easily with the processor, but it takes a lot of time. Since the reservoir always return the same output (it is not trained and receives the same input), we can calculate all of the outputs of the reservoir and then reuse them. The next two code cells implement the same reservoir as earlier but in a more resource-efficient way.

Lets calculate all of the reservoir outputs and add them in a dictionary.

[7]:
reservoir=ML.QuantumLayer.simple(
            input_size=4,
        ).eval()

output_size=reservoir.output_size

## TODO Uncomment to use the processor
# train_outputs= proc.forward(reservoir,X_train)
# train_reservoir_map={tuple(x.tolist()):output for x,output in zip(X_train,train_outputs)}
# test_outputs=proc.forward(reservoir,X_test)
# test_reservoir_map={tuple(x.tolist()):output for x,output in zip(X_test,test_outputs)}

## TODO Comment to use the processor
reservoir.eval()
with torch.no_grad():
    train_outputs= reservoir(X_train)
    train_reservoir_map={tuple(x.tolist()):output for x,output in zip(X_train,train_outputs)}
    test_outputs=reservoir(X_test)
    test_reservoir_map={tuple(x.tolist()):output for x,output in zip(X_test,test_outputs)}

reservoir_map = {**train_reservoir_map, **test_reservoir_map}
[8]:
for i,(key,value) in enumerate(reservoir_map.items()):
    if (i+1)%10==0:
        print(f"{key}: {value}")
(0.4166666567325592, 0.2916666567325592, 0.49152541160583496, 0.4583333432674408): tensor([0.0548, 0.0383, 0.0158, 0.1206, 0.1236, 0.1237, 0.1829, 0.2955, 0.0174,
        0.0275])
(0.02777777798473835, 0.4166666567325592, 0.050847455859184265, 0.0416666679084301): tensor([0.0543, 0.0166, 0.0099, 0.1593, 0.0816, 0.1695, 0.2712, 0.2126, 0.0166,
        0.0084])
(0.0555555559694767, 0.125, 0.050847455859184265, 0.0833333358168602): tensor([0.0710, 0.0345, 0.0051, 0.1244, 0.0994, 0.1651, 0.2338, 0.2322, 0.0166,
        0.0178])
(0.5833333134651184, 0.5, 0.5932203531265259, 0.5833333134651184): tensor([0.0451, 0.0315, 0.0208, 0.1404, 0.1213, 0.1154, 0.1735, 0.3038, 0.0235,
        0.0246])
(0.1944444477558136, 0.625, 0.10169491171836853, 0.2083333283662796): tensor([0.0496, 0.0135, 0.0101, 0.1724, 0.0759, 0.1510, 0.2544, 0.2357, 0.0297,
        0.0078])
(0.7222222089767456, 0.4583333432674408, 0.7457627058029175, 0.8333333134651184): tensor([0.0509, 0.0433, 0.0207, 0.1066, 0.1289, 0.0768, 0.1394, 0.3644, 0.0275,
        0.0415])
(0.6388888955116272, 0.4166666567325592, 0.5762711763381958, 0.5416666865348816): tensor([0.0519, 0.0432, 0.0153, 0.1499, 0.1145, 0.1289, 0.1625, 0.2860, 0.0238,
        0.0240])
(0.4722222089767456, 0.5833333134651184, 0.5932203531265259, 0.625): tensor([0.0384, 0.0197, 0.0291, 0.1143, 0.1278, 0.0961, 0.1874, 0.3324, 0.0231,
        0.0318])
(0.7222222089767456, 0.5, 0.7966101765632629, 0.9166666865348816): tensor([0.0486, 0.0393, 0.0248, 0.0929, 0.1335, 0.0615, 0.1355, 0.3866, 0.0269,
        0.0505])
(0.5277777910232544, 0.0833333358168602, 0.5932203531265259, 0.5833333134651184): tensor([0.0719, 0.0640, 0.0105, 0.0987, 0.1221, 0.1026, 0.1460, 0.3278, 0.0178,
        0.0385])
(0.3888888955116272, 0.375, 0.5423728823661804, 0.5): tensor([0.0468, 0.0293, 0.0233, 0.1113, 0.1311, 0.1130, 0.1901, 0.3093, 0.0163,
        0.0296])
(0.5555555820465088, 0.2083333283662796, 0.6610169410705566, 0.5833333134651184): tensor([0.0602, 0.0547, 0.0180, 0.1110, 0.1297, 0.1074, 0.1538, 0.3152, 0.0152,
        0.0349])
(0.7222222089767456, 0.4583333432674408, 0.694915235042572, 0.9166666865348816): tensor([0.0565, 0.0428, 0.0162, 0.0938, 0.1245, 0.0651, 0.1312, 0.3840, 0.0336,
        0.0523])
(0.25, 0.5833333134651184, 0.06779661029577255, 0.0416666679084301): tensor([0.0502, 0.0143, 0.0068, 0.2246, 0.0633, 0.1806, 0.2499, 0.1773, 0.0203,
        0.0127])

We can easily define the trainable classical model using this map.

[9]:
class HybridIrisClassifier(nn.Module):
    """
    Hybrid model for Iris classification:
    - Quantum reservoir processes the 4 features
    - Classical output layer for 3-class classification
    """
    def __init__(self,output_size:int=1):
        super(HybridIrisClassifier, self).__init__()
        self.output_size=output_size

        # Classical output layer: quantum → 8 → 3
        self.model = nn.Sequential(
            nn.Linear(output_size, 8),
            nn.ReLU(),
            nn.Dropout(0.1),
            nn.Linear(8, 3)
        )


    def forward(self, x:torch.Tensor):
        if x.dim()==1:
            x.unsqueeze(0)
        input_to_classical=torch.empty(x.shape[0],self.output_size)
        for i,input in enumerate(x):
            input_to_classical[i]=reservoir_map[tuple(input.tolist())]

        return self.model(input_to_classical)

4. Set the Training Parameters

You can adjust these parameters to see how they affect training and model performance.

[10]:
learning_rate = 0.01
number_of_epochs = 200

5. Train the Hybrid Model

Lets train the model like a normal pytorch module.

[11]:
import random, numpy as np, torch
def reset_seeds(s=0):
    random.seed(s); np.random.seed(s)
    torch.manual_seed(s); torch.cuda.manual_seed_all(s)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False

reset_seeds(123)
model = HybridIrisClassifier(output_size=output_size)
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
criterion = nn.CrossEntropyLoss()
model.train()

#Training loop
for epoch in range(number_of_epochs):
    optimizer.zero_grad()
    loss = criterion(model(X_train), y_train)
    loss.backward()
    optimizer.step()
    model.eval()
    with torch.no_grad():
        preds = model(X_test).argmax(dim=1)
        accuracy=(preds == y_test).float().mean().item()
    model.train()
    if (epoch+1)%20==0:
        print(f"Epoch {epoch+1} had a loss of {loss.item()} and a test accuracy of {accuracy}")
Epoch 20 had a loss of 1.0563591718673706 and a test accuracy of 0.20000000298023224
Epoch 40 had a loss of 0.9492233395576477 and a test accuracy of 0.7333333492279053
Epoch 60 had a loss of 0.8029465079307556 and a test accuracy of 0.8666666746139526
Epoch 80 had a loss of 0.68387371301651 and a test accuracy of 0.8999999761581421
Epoch 100 had a loss of 0.5803365707397461 and a test accuracy of 0.8999999761581421
Epoch 120 had a loss of 0.5110309720039368 and a test accuracy of 0.8999999761581421
Epoch 140 had a loss of 0.45490148663520813 and a test accuracy of 0.8999999761581421
Epoch 160 had a loss of 0.4709847867488861 and a test accuracy of 0.8999999761581421
Epoch 180 had a loss of 0.42200127243995667 and a test accuracy of 0.8999999761581421
Epoch 200 had a loss of 0.35478246212005615 and a test accuracy of 0.8999999761581421

6. Evaluate the Model

After training, let’s evaluate our model on the test set and print the accuracy.

[12]:
# Evaluate on test set
model.eval()
with torch.no_grad():
    test_outputs = model(X_test)
    predictions = torch.argmax(test_outputs, dim=1)
    accuracy = (predictions == y_test).float().mean().item()
    print(f"Test accuracy: {accuracy:.4f}")
Test accuracy: 0.9000
[13]:
number_of_runs = 10
accuracies = []

for i in range(number_of_runs):
    reset_seeds(i+123)
    model = HybridIrisClassifier(output_size=output_size)
    optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
    criterion = nn.CrossEntropyLoss()

    #Training loop
    model.train()
    for epoch in range(number_of_epochs):
        optimizer.zero_grad()
        loss = criterion(model(X_train), y_train)
        loss.backward()
        optimizer.step()

        model.eval()
        with torch.no_grad():
            preds = model(X_test).argmax(dim=1)
            accuracy=(preds == y_test).float().mean().item()
        model.train()
        #print(f"Epoch {epoch+1} had a loss of {loss.item()} and a test accuracy of {accuracy}")

    #Final evaluation of the model
    model.eval()
    with torch.no_grad():
        preds = model(X_test).argmax(dim=1)
        accuracies.append((preds == y_test).float().mean().item())

avg = torch.tensor(accuracies).mean().item()
std = torch.tensor(accuracies).std(unbiased=True).item()
print(f"Average accuracy: {avg:.4f} ± {std:.4f}")

Average accuracy: 0.9133 ± 0.0172

Conclusion

Congratulations! You’ve trained and evaluated a hybrid quantum-classical neural network using Merlin.
Feel free to experiment with the model architecture, quantum parameters, or try other datasets!

Even though MerLin is built as a simulation-first package, it is still possible to run and optimize quantum layers with the processor. Although, a gradient-free optimizer such as COBYLA should be used.

Also, if you want a better performing reservoir based on the litterature, you can try to reproduce the Quantum optical reservoir computing powered by boson sampling’s resevoir. It is good exercice to familiarize yourself with MerLin and learn about PCA if you are not from a machine learning background.