Kernel Methods with MerLin

This notebook illustrates how to build photonic feature maps and quantum kernels using MerLin. We cover the quickstart factory, custom circuits, and how to plug the resulting Gram matrices into classical machine-learning pipelines built around the Iris dataset.

Setup

We standardise the Iris features, split train/test partitions, and rely on scikit-learn to train classical models on the precomputed kernel matrices.

[1]:
import numpy as np
import matplotlib.pyplot as plt
import perceval as pcvl
import torch
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

from merlin import ComputationSpace
from merlin.algorithms.kernels import FeatureMap, FidelityKernel
from merlin.builder import CircuitBuilder

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

iris = load_iris()
X = iris.data.astype(np.float32)
y = iris.target

X = StandardScaler().fit_transform(X)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=12
)

X_train = torch.tensor(X_train)
X_test = torch.tensor(X_test)

Fidelity kernel in a few lines

FeatureMap.simple constructs a kernel-ready photonic circuit. Pass it to FidelityKernel to encode real inputs into a multi-mode Fock space, evaluate their overlaps, and return a positive-semidefinite Gram matrix.

[2]:
feature_map = FeatureMap.simple(input_size=4)  # n_modes = input_size + 1 = 5 by default
kernel = FidelityKernel(
    feature_map=feature_map,
    input_state=[1, 0, 1, 0, 1],  # alternating photons for 5 modes
    shots=0,  # exact probabilities
    computation_space=ComputationSpace.FOCK,
    dtype=torch.float32,
    device=torch.device("cpu"),
)

K_train = kernel(X_train)
K_test = kernel(X_test, X_train)

print("Train Gram shape:", K_train.shape)
print("Test Gram shape:", K_test.shape)
Train Gram shape: torch.Size([105, 105])
Test Gram shape: torch.Size([45, 105])

Use with scikit-learn

We can train any estimator that accepts precomputed kernels. Below we use an SVM to distinguish the three Iris species from the quantum kernel features.

[3]:
svc = SVC(kernel="precomputed")
svc.fit(K_train.detach().numpy(), y_train)
test_accuracy = svc.score(K_test.detach().numpy(), y_test)
print(f"SVM accuracy (precomputed kernel): {test_accuracy:.3f}")
SVM accuracy (precomputed kernel): 1.000

Decision regions on a 2D Iris feature slice

The classifier is trained on the four standardized Iris features, so the true decision frontier lives in four dimensions. To visualize a 2D slice, we vary petal length and petal width while keeping the other standardized features fixed at zero.

[4]:
x_feature_index = 2
y_feature_index = 3
slice_mesh_resolution = 50

X_train_numpy = X_train.detach().numpy()
x_feature_min = X_train_numpy[:, x_feature_index].min() - 0.5
x_feature_max = X_train_numpy[:, x_feature_index].max() + 0.5
y_feature_min = X_train_numpy[:, y_feature_index].min() - 0.5
y_feature_max = X_train_numpy[:, y_feature_index].max() + 0.5

xx, yy = np.meshgrid(
    np.linspace(x_feature_min, x_feature_max, slice_mesh_resolution),
    np.linspace(y_feature_min, y_feature_max, slice_mesh_resolution),
)
grid_points = np.zeros((xx.size, X_train_numpy.shape[1]), dtype=np.float32)
grid_points[:, x_feature_index] = xx.ravel()
grid_points[:, y_feature_index] = yy.ravel()

with torch.no_grad():
    K_grid = kernel(torch.tensor(grid_points), X_train)

decision_regions = svc.predict(K_grid.detach().numpy()).reshape(xx.shape)

fig, ax = plt.subplots(figsize=(7, 5))
region_levels = np.arange(len(iris.target_names) + 1) - 0.5
ax.contourf(xx, yy, decision_regions, levels=region_levels, cmap="Pastel2", alpha=0.8)

for class_id, species_name in enumerate(iris.target_names):
    class_mask = y_train == class_id
    ax.scatter(
        X_train_numpy[class_mask, x_feature_index],
        X_train_numpy[class_mask, y_feature_index],
        label=species_name,
        edgecolor="black",
        linewidth=0.6,
        s=45,
    )

ax.set_xlabel(f"Standardized {iris.feature_names[x_feature_index]}")
ax.set_ylabel(f"Standardized {iris.feature_names[y_feature_index]}")
ax.set_title("Quantum-kernel SVM decision regions on an Iris feature slice")
ax.legend(title="Species")
fig.tight_layout()
../../../../../../../../../Users/cassandrenotton/Documents/projects/QML_project/fork-merlin/merlin/docs/build/html/0.4/.doctrees/nbsphinx/notebooks_Kernels_8_0.png

Custom feature maps and experiments

For full control over the optical layout, detectors, and noise, build a perceval.Circuit and wrap it with FeatureMap. Fidelity kernels then evaluate overlaps between the resulting states.

[5]:
circuit = pcvl.Circuit(4)
circuit.add((0, 1), pcvl.BS())
circuit.add(0, pcvl.PS(pcvl.P("phi0")))
circuit.add(1, pcvl.PS(pcvl.P("phi1")))
circuit.add(2, pcvl.PS(pcvl.P("phi2")))
circuit.add(3, pcvl.PS(pcvl.P("phi3")))
circuit.add((2, 3), pcvl.BS())

experiment = pcvl.Experiment(circuit)
experiment.noise = pcvl.NoiseModel(brightness=0.93)

feature_map = FeatureMap(
    experiment=experiment,
    input_size=4,
    input_parameters="phi",
    dtype=torch.float32,
)

custom_kernel = FidelityKernel(
    feature_map=feature_map,
    input_state=[1, 0, 1, 0],
    computation_space=ComputationSpace.UNBUNCHED,
)

K_custom = custom_kernel(X_train[:20])
print("Custom kernel Gram shape:", K_custom.shape)
Custom kernel Gram shape: torch.Size([20, 20])

Declarative kernel circuits

CircuitBuilder offers a fluent API to assemble reusable feature maps programmatically. Build the circuit, wrap it with FeatureMap, then pass it to FidelityKernel.

[6]:
builder = CircuitBuilder(n_modes=6)
builder.add_superpositions(depth=1)
builder.add_angle_encoding(modes=[0, 1, 2, 3], name="input", scale=0.7)
builder.add_rotations(trainable=True, name="theta")
builder.add_superpositions(depth=1)

feature_map = FeatureMap(
    builder=builder,
    input_size=4,
    input_parameters=None,
    trainable_parameters=["theta"],
)

builder_kernel = FidelityKernel(
    feature_map=feature_map,
    input_state=[1, 1, 0, 0, 0, 0],
    shots=512,
    computation_space=ComputationSpace.FOCK,
)

K_builder = builder_kernel(X_train[:15])
print("Builder kernel Gram shape:", K_builder.shape)

Builder kernel Gram shape: torch.Size([15, 15])

Conclusion

  • Use FeatureMap.simple then pass to FidelityKernel for quick experiments with a default photonic layout.

  • Wrap custom perceval.Experiment objects with FeatureMap to reflect hardware noise and detector choices.

  • Use CircuitBuilder to script repeatable feature maps with entangling blocks and measurement strategies, then pass to FeatureMap and FidelityKernel.

  • The resulting Gram matrices integrate with classical ML libraries by choosing the precomputed kernel option.