Your First Quantum Layer
This walkthrough mirrors the FirstQuantumLayers.ipynb notebook: we build and train
photonic layers that classify the Iris dataset. Along the way, we focus on three core
concepts you will reuse in every project:
Angle encoding via
CircuitBuilder. See Angle Encoding and Amplitude Encoding for more details on input encoding with MerLin.Output measurement strategies that turn photonic outcomes into classical tensors. See Measurement Strategy Guide.
Computation spaces controlling how the simulator truncates Fock states. See Computation Spaces.
Once the foundations are in place, we show how to reuse the same circuit through a
perceval.Experiment for detector-aware execution.
Set up the dataset
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from merlin import LexGrouping, MeasurementStrategy, QuantumLayer
from merlin.builder import CircuitBuilder
torch.manual_seed(0)
np.random.seed(0)
iris = load_iris()
X = iris.data.astype("float32")
y = iris.target.astype("int64")
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.25,
stratify=y,
random_state=42,
)
X_train = torch.tensor(X_train, dtype=torch.float32)
X_test = torch.tensor(X_test, dtype=torch.float32)
y_train = torch.tensor(y_train, dtype=torch.long)
y_test = torch.tensor(y_test, dtype=torch.long)
# Normalise features before encoding them as phases
mean = X_train.mean(dim=0, keepdim=True)
std = X_train.std(dim=0, keepdim=True).clamp_min(1e-6)
X_train = (X_train - mean) / std
X_test = (X_test - mean) / std
CircuitBuilder walkthrough
CircuitBuilder is the recommended way to author circuits for training. We
stack entangling layers, angle encoding, and additional rotations before handing the
result to QuantumLayer.
builder = CircuitBuilder(n_modes=6)
builder.add_entangling_layer(trainable=True, name="U1")
builder.add_angle_encoding(
modes=list(range(X_train.shape[1])), # one mode per Iris feature
name="input",
scale=np.pi,
)
builder.add_rotations(trainable=True, name="theta")
builder.add_superpositions(depth=1, trainable=True)
quantum_core = QuantumLayer(
input_size=X_train.shape[1],
builder=builder,
n_photons=3, # Equivalent to input_state=[1,1,1,0,0,0]
measurement_strategy=MeasurementStrategy.PROBABILITIES,
)
model = nn.Sequential(
quantum_core,
LexGrouping(quantum_core.output_size, 3),
nn.Linear(3, 3),
)
train_acc, test_acc = run_experiment(model, epochs=80, lr=0.05)
print(f"Train accuracy: {train_acc:.3f} – Test accuracy: {test_acc:.3f}")
Angle encoding highlights
add_angle_encodinggenerates input-driven phase shifters whose prefixes (name) must appear insideinput_parameterswhen you instantiate the layer manually.Scaling by
np.pikeeps rotations in a range compatible with Perceval’s phase conventions.Normalise your features to \([-1, 1]\) or \([0, 1]\) before feeding them into the layer so the implied rotations stay stable during training.
In this tutorial, we focus on angle encoding. Amplitude encoding is also available. More information can be found in the Angle Encoding and Amplitude Encoding documentation.
Exploring output measurement strategies
MerLin exposes three strategies: probabilities (default), per-mode expectations, and complex amplitudes (simulation only). Swap the strategy to pick the classical output that best matches the rest of your model.
strategies = {
"probabilities": MeasurementStrategy.PROBABILITIES,
"mode_expectations": MeasurementStrategy.MODE_EXPECTATIONS,
}
for label, strategy in strategies.items():
layer = QuantumLayer(
input_size=X_train.shape[1],
builder=builder,
n_photons=3,
measurement_strategy=strategy,
)
head = nn.Sequential(
layer,
nn.Linear(layer.output_size, 3),
)
train_acc, test_acc = run_experiment(head, epochs=60, lr=0.05)
print(f"{label}: train {train_acc:.3f} – test {test_acc:.3f}")
# Amplitudes provide complex tensors — convert them before handing off to nn.Linear
amp_layer = QuantumLayer(
input_size=X_train.shape[1],
builder=builder,
n_photons=3,
measurement_strategy=MeasurementStrategy.AMPLITUDES,
)
class ComplexToReal(nn.Module):
def forward(self, x):
# view_as_real -> (..., 2) with last dim holding [real, imag]
parts = torch.view_as_real(x)
return parts.flatten(start_dim=1)
amp_head = nn.Sequential(
amp_layer,
ComplexToReal(),
nn.Linear(2 * amp_layer.output_size, 3),
)
train_acc, test_acc = run_experiment(amp_head, epochs=60, lr=0.05)
print(f"amplitudes (with real/imag flattening): train {train_acc:.3f} – test {test_acc:.3f}")
Measurement strategy tips
PROBABILITIESreturns the Fock state probability distribution – Ideal for attaching dense classical heads, simple linear probings or grouping strategies.MODE_EXPECTATIONScompresses the outputs to one value per mode, reducing the number of classical weights you need downstream.AMPLITUDESyields tensors with complex values and is restricted to noiseless simulations without detectors. Convert them withtorch.view_as_realor flatten real/imaginary parts before feeding the data to classical layers.
More informations on measurement strategies can be found here: Measurement Strategy Guide.
Choosing a computation space
The computation_space parameter controls how Perceval truncates the Fock space. If
you omit it, MerLin falls back to ComputationSpace.FOCK (or UNBUNCHED when the
legacy no_bunching flag is active). Override the default when you need explicit
control:
from merlin import ComputationSpace
fock_layer = QuantumLayer(
input_size=X_train.shape[1],
builder=builder,
n_photons=3,
computation_space=ComputationSpace.FOCK, # Full Fock basis
)
unbunched_layer = QuantumLayer(
input_size=X_train.shape[1],
builder=builder,
n_photons=3,
computation_space=ComputationSpace.UNBUNCHED, # Forbid multiple photons per mode
)
dual_rail_layer = QuantumLayer(
input_size=X_train.shape[1],
builder=builder,
n_photons=3,
computation_space=ComputationSpace.DUAL_RAIL, # Pair modes to encode qubits
)
FOCKkeeps the entire combinatorial space of the declared photons.UNBUNCHEDassumes at most one photon per mode, reducing the state count when the circuit satisfies that constraint.DUAL_RAILmodels qubits as photon pairs, which is useful when interfacing with dual-rail encodings or threshold detectors.
Detector-aware execution with Experiments
Wrapping the circuit in a perceval.Experiment lets you attach detectors and
noise models without re-authoring the layer. The experiment becomes the single source
of truth for measurement semantics.
import perceval as pcvl
# Reuse the circuit produced by the builder
circuit = builder.circuit
experiment = pcvl.Experiment(circuit)
experiment.noise = pcvl.NoiseModel(brightness=0.95, transmittance=0.9)
experiment.detectors[0] = pcvl.Detector.threshold()
experiment.detectors[1] = pcvl.Detector.pnr()
experiment_layer = QuantumLayer(
input_size=X_train.shape[1],
experiment=experiment,
input_state=[1, 1, 1, 0, 0, 0],
input_parameters=["input"],
measurement_strategy=MeasurementStrategy.PROBABILITIES,
)
model_with_noise = nn.Sequential(
experiment_layer,
LexGrouping(experiment_layer.output_size, 3),
nn.Linear(3, 3),
)
train_acc, test_acc = run_experiment(model_with_noise, epochs=80, lr=0.05)
print(f"Experiment-backed layer – test accuracy: {test_acc:.3f}")
Experiment notes
Attaching detectors or photon-loss models disables
MeasurementStrategy.AMPLITUDESbecause amplitudes are no longer observable.input_parametersmust match the prefixes emitted byadd_angle_encoding("input"in this example).You can reuse the same experiment across multiple layers or kernel feature maps to keep detector settings consistent.
Next steps
Swap out
builder.add_superpositionsor introduce additional entangling layers to explore deeper circuits.Combine
LexGroupingorModGroupingmodules to tailor the classical feature count to your downstream model.Re-run the experiments with alternative computation spaces to benchmark accuracy vs. runtime trade-offs.
Take a look at the User Guide for more detailed explanations.