Angle Encoding and Amplitude Encoding
This guide shows how to use angle encoding and amplitude encoding with
Merlin’s QuantumLayer. You’ll find when to use each,
how to build circuits with CircuitBuilder or native
Perceval, and complete, runnable snippets.
Prerequisites
Python, PyTorch, and Merlin installed.
Basic familiarity with Merlin’s
QuantumLayer.Optional: Perceval for custom circuits and experiments.
Conceptual overview
Angle encoding maps a real feature vector into circuit parameters (e.g., phase shifter angles). The circuit unitary depends on your data. Data is encoded at specific points in the circuit using phase shifters.
Amplitude encoding feeds a quantum state directly to the layer as input. Instead of turning features into angles, you supply the input quantum state’s amplitudes at the beginning of the circuit. The preferred way to do this is with a
StateVector.
Angle Encoding
When to use
Use angle encoding for classical-quantum pipelines: feature maps, kernels, or hybrid neural networks where your inputs are real-valued tensors.
With CircuitBuilder
CircuitBuilder provides a declarative way to add an
angle-encoding stage into your photonic circuit.
Build a circuit with angle encoding:
import numpy as np
from merlin.builder import CircuitBuilder
# 1) Declare a circuit with 6 modes
builder = CircuitBuilder(n_modes=6)
# 2) Put trainable rotations (phase shifters) on every mode
builder.add_rotations(modes=[0, 1, 2, 3, 4, 5], trainable=True)
# 3) Add an angle-encoding layer; the 'name' will prefix the input parameters
builder.add_angle_encoding(
modes=[0, 1, 2, 3, 4, 5],
name="input",
scale=np.pi # optional global scaling of features -> angles
)
# 4) Entangle some modes (e.g., MZI block between modes 0 and 5)
builder.add_entangling_layer(modes=[0, 5], trainable=True, model="mzi")
# 5) Add superposition/BS layers (increase expressivity)
builder.add_superpositions(modes=[0, 1, 2, 3, 4, 5], trainable=True, depth=2)
Wrap it as a QuantumLayer and run a forward pass:
import torch
from merlin.algorithms import QuantumLayer
from merlin.core.state_vector import StateVector
layer = QuantumLayer(
input_size=6, # number of classical features per sample
builder=builder, # the declarative circuit
input_state=StateVector.from_basic_state([1, 0, 1, 0, 1, 0]), # 3 photons in 6 modes
)
x = torch.rand((4, 6)) # batch of 4 samples
probs = layer(x) # default .probs() measurement
Parameter names and prefixes
The add_angle_encoding() call registers
parameters prefixed by name (e.g., "input"). Internally,
QuantumLayer will consume your real-valued input
tensor and map each feature to the corresponding prefixed angle(s).
Tips and constraints
Modes vs. features: By construction you typically shouldn’t encode more independent features than available modes in the encoding step.
Scaling and combinations: You can use
scale=...to rescale inputs before turning them into angles. If you create multiple encoding stages with different names (prefixes), the layer can split the input tensor across them.Kernels: For quantum kernels, consider
FeatureMapandFidelityKernelif you need a reusable feature map object.
Angle encoding using QuantumLayer.simple
If you want a quick start without designing the circuit:
import torch
from merlin.algorithms import QuantumLayer
layer = QuantumLayer.simple(
input_size=6, # number of classical features
output_size=10 # output dimensionality
)
x = torch.rand((2, 6))
y = layer(x) # probability vector of size `output_size`
Angle encoding with Perceval circuits
For full control, create a Perceval circuit, then expose input-parameter prefix(es) that the layer will map features to:
import perceval as pcvl
from merlin.algorithms import QuantumLayer
from merlin.core.state_vector import StateVector
# Build a 6-mode Perceval circuit
circuit = pcvl.Circuit(6)
# Example: add user-named input phase shifters (prefix 'input')
for mode in range(6):
circuit.add(mode, pcvl.PS(pcvl.P(f"input{mode}")))
circuit.add(mode, pcvl.PS(pcvl.P(f"theta{mode}")))
# (Add interferometers, MZIs, etc. as you like)
# ...
layer = QuantumLayer(
input_size=6,
circuit=circuit,
input_state=StateVector.from_basic_state([1, 0, 1, 0, 1, 0]),
input_parameters=["input"], # map features -> parameters named 'input*'
trainable_parameters=["theta"] # example trainable prefix used elsewhere in your circuit
)
import torch
x = torch.rand((1, 6))
probs = layer(x)
Amplitude Encoding
When to use
Choose amplitude encoding when you want to:
Map classical feature vectors directly into Fock-space amplitudes. Your real-valued data becomes the quantum state itself and the circuit acts as a learned unitary transformation on it. Use
StateVector.from_tensor()to wrap the data.Inject a prepared quantum state into the circuit — for example, a state produced by an upstream simulator or another photonic block. In this case you can provide complex-valued data directly.
How it works
Amplitude encoding replaces the input quantum state of the circuit at runtime with a state whose amplitudes come from your data. The circuit then acts as a learned unitary transformation on that state.
The workflow is:
Start with a feature vector of length d (the Fock basis size). This can be real-valued classical data or complex-valued quantum-state amplitudes.
Wrap it via
StateVector.from_tensor(), which attaches the Fock metadata and auto-promotes real data to complex.Pass the
StateVectortoforward()— the layer detects the type and activates amplitude encoding automatically.
No special constructor flags are needed. The encoding mode is inferred purely
from the type of the first argument to forward():
# |
Input type |
Behaviour |
|---|---|---|
1 |
(preferred)
|
Automatically activates amplitude encoding. The layer extracts the
dense complex tensor, validates its dimension against the layer basis,
and propagates it through the circuit.
|
2 |
Complex |
A single complex-dtype tensor is treated identically to a
StateVector’sunderlying tensor. Useful when you manage tensors directly without
wrapping them.
|
3 |
Real
torch.Tensor+
amplitude_encoding=True |
Legacy path — deprecated (will be removed in 0.4).
The constructor flag forces amplitude interpretation on a real-valued
tensor. Migrate to path 1 or 2.
|
Warning
Deprecated since version 0.3: The amplitude_encoding=True constructor parameter is deprecated and will
be removed in 0.4. Pass a StateVector
or a complex torch.Tensor to forward() instead.
Setup
Just make sure:
n_photonsis set (so the layer knows the Hilbert space dimension).input_sizeandinput_parametersare not set (they are for angle encoding only).
import perceval as pcvl
from merlin.algorithms import QuantumLayer
from merlin.measurement import MeasurementStrategy
from merlin.core.computation_space import ComputationSpace
circuit = pcvl.Circuit(4)
# ... populate with beam splitters, phase shifters, etc. ...
layer = QuantumLayer(
circuit=circuit,
n_photons=2,
measurement_strategy=MeasurementStrategy.probs(ComputationSpace.FOCK),
)
Input dimensions
The amplitude vector must have exactly d components, where d is the Fock-space basis size:
Check the expected dimension and basis ordering with:
print(len(layer.output_keys)) # 10 for (4 modes, 2 photons)
print(layer.output_keys[:3]) # e.g. [(2,0,0,0), (1,1,0,0), (1,0,1,0)]
Encoding classical data with from_tensor
The primary amplitude encoding path wraps a classical feature vector as a
StateVector via
from_tensor(). This method accepts
real or complex tensors — real data is automatically promoted to complex
internally — and validates that the last dimension matches the Fock basis size.
Single sample:
import torch
from merlin.core.state_vector import StateVector
# Classical feature vector of size d = 10 (for 4 modes, 2 photons)
features = torch.randn(10)
# Wrap as StateVector — real-to-complex promotion happens automatically
sv = StateVector.from_tensor(features, n_modes=4, n_photons=2)
# Pass to the layer — amplitude encoding is detected from the type
output = layer(sv)
Tip
from_tensor() handles real-to-complex promotion for you.
The layer lazily normalizes amplitudes before computation, but explicitly
normalizing upstream (e.g. via nn.functional.normalize) can improve
numerical stability during training.
Using a complex tensor directly
If you prefer to manage raw tensors without the StateVector wrapper,
passing a complex torch.Tensor to forward() also triggers amplitude
encoding:
import torch
amps = torch.randn(10, dtype=torch.complex64)
output = layer(amps) # complex dtype triggers amplitude encoding
Standardising other inputs as StateVector
The constructors from_basic_state(),
from_perceval(), and the + operator are not forms
of amplitude encoding — they do not map classical data into amplitudes.
Their purpose is to give every quantum-state input a single, uniform type
(StateVector) so the layer’s dispatch
logic does not need special cases for lists, pcvl.BasicState, or
pcvl.StateVector.
From a known Fock state:
from merlin.core.state_vector import StateVector
sv = StateVector.from_basic_state([1, 0, 1, 0])
output = layer(sv)
From a superposition:
sv = (
StateVector.from_basic_state([1, 0, 1, 0])
+ StateVector.from_basic_state([0, 1, 0, 1])
)
output = layer(sv) # (|1,0,1,0⟩ + |0,1,0,1⟩) / √2
From a Perceval state:
import perceval as pcvl
from merlin.core.state_vector import StateVector
pcvl_sv = (
pcvl.StateVector(pcvl.BasicState([1, 0, 1, 0]))
+ pcvl.StateVector(pcvl.BasicState([0, 1, 0, 1]))
)
sv = StateVector.from_perceval(pcvl_sv)
output = layer(sv)
In every case the layer processes the StateVector through the same
code path — the only difference is where the amplitudes came from.
Restrictions
Only one
StateVectormay be passed perforward()call.StateVectorandtorch.Tensorinputs cannot be mixed in the same call.Batched (2-D)
StateVectorinputs are supported. Pass a 2-D tensor of shape(batch_size, d)tofrom_tensor()and the layer processes the whole batch in a singleforward()call.With
MeasurementStrategy.amplitudes()the layer bypasses detectors and noise;shotsmust be unset or zero.If you need to combine a custom quantum input with classical angle-encoded features, set the quantum state via the
input_stateconstructor parameter and pass the classical features as a real tensor toforward().
Returning typed objects
With return_object=True, the measurement strategy determines the return
type. This applies equally to angle and amplitude encoding:
layer = QuantumLayer(
builder=builder,
n_photons=2,
measurement_strategy=MeasurementStrategy.amplitudes(ComputationSpace.FOCK),
return_object=True,
)
sv = StateVector.from_tensor(torch.randn(len(layer.output_keys)), n_modes=4, n_photons=2)
sv_out = layer(sv) # StateVector
sv_out.n_modes # 4
sv_out[[1, 0, 1, 0]] # complex amplitude for a specific Fock state
And with a probability strategy, a
ProbabilityDistribution:
layer = QuantumLayer(
builder=builder,
n_photons=2,
measurement_strategy=MeasurementStrategy.probs(ComputationSpace.FOCK),
return_object=True,
)
sv = StateVector.from_tensor(torch.randn(len(layer.output_keys)), n_modes=4, n_photons=2)
pd = layer(sv) # ProbabilityDistribution
pd.probabilities() # dense probability tensor
pd.filter(ComputationSpace.UNBUNCHED) # post-select
Chaining quantum layers
Because QuantumLayer can both consume and produce
StateVector objects, you can chain layers
so that the output amplitudes of one feed into the next:
from merlin.core.state_vector import StateVector
layer_1 = QuantumLayer(
builder=builder_1,
input_state=StateVector.from_basic_state([1, 0, 1, 0]),
measurement_strategy=MeasurementStrategy.amplitudes(ComputationSpace.FOCK),
return_object=True,
)
layer_2 = QuantumLayer(
builder=builder_2,
n_photons=2,
measurement_strategy=MeasurementStrategy.probs(ComputationSpace.FOCK),
return_object=True,
)
sv_mid = layer_1(x_input) # StateVector
pd_out = layer_2(sv_mid) # ProbabilityDistribution
Gradients flow through both layers during backpropagation.
Encodings Key Differences
Aspect |
Angle Encoding |
Amplitude Encoding |
|---|---|---|
Input to |
Real |
|
Number of inputs |
User-defined
( |
Fixed by n_modes and n_photons (combinatorial formula) |
Circuit dependence |
Features set parameters (phases/angles) |
Data defines input quantum state; circuit is fixed unitary |
Setup (constructor) |
|
|
Activation trigger |
Real tensor to
|
|
Typical use |
Feature maps, kernels, hybrid NN layers |
Classical data as amplitudes.
via |
Measurement options |
Probabilities, modes, amplitudes (sim-only), partial (sim-only) |
Probabilities, modes, amplitudes (sim-only), partial (sim-only) |
Troubleshooting
Shape errors (angle encoding): Ensure
input_sizeequals the number of features you feed into the layer and matches the encoding specification (number of input phase shifters and prefixes).Too many features: If you attempt to encode more features than modes in your encoding stage, reduce features using dimensionality reduction techniques such as PCA or UMAP, or expand the circuit’s encoding modes.
Shape errors (amplitude encoding): The amplitude vector length must match the layer basis size:
len(layer.output_keys). For batching, use(batch, len(output_keys)).Incompatible measurement strategy: When
MeasurementStrategy.amplitudes()is selected, do not set nonzeroshotsor enable detectors/noise.Unnormalized amplitudes: Always normalize amplitude inputs to avoid unstable gradients and to ensure proper probability mass.
Mixing input types: You cannot pass both a
StateVectorand atorch.Tensorin the sameforward()call. Use either angle encoding (real tensors) or amplitude encoding (StateVector/ complex tensor), not both.DeprecationWarning for
amplitude_encoding=True: Migrate to passing aStateVectoror complex tensor toforward()instead of using the constructor flag.Batched amplitude encoding: Pass a 2-D tensor to
StateVector.from_tensor()and callforward()with the resultingStateVector. The layer normalizes each sample independently and returns a(batch_size, output_size)tensor.
Complete Examples
Angle encoding with builder and probabilities out
import numpy as np
import torch
from merlin.builder import CircuitBuilder
from merlin.algorithms import QuantumLayer
from merlin.measurement import MeasurementStrategy
from merlin.core.computation_space import ComputationSpace
from merlin.core.state_vector import StateVector
builder = CircuitBuilder(n_modes=6)
builder.add_angle_encoding(modes=list(range(6)), name="input", scale=np.pi)
builder.add_entangling_layer(trainable=True)
builder.add_superpositions(modes=list(range(6)), trainable=True, depth=1)
layer = QuantumLayer(
input_size=6,
builder=builder,
input_state=StateVector.from_basic_state([1, 0, 1, 0, 1, 0]),
measurement_strategy=MeasurementStrategy.probs(ComputationSpace.FOCK),
)
x = torch.rand((3, 6))
probs = layer(x) # shape: [3, layer.output_size]
Amplitude encoding with StateVector.from_tensor and amplitudes out
import torch
import perceval as pcvl
from merlin.algorithms import QuantumLayer
from merlin.measurement import MeasurementStrategy
from merlin.core.computation_space import ComputationSpace
from merlin.core.state_vector import StateVector
# Simple unitary circuit placeholder; customize as needed
circuit = pcvl.Circuit(4)
# ... populate circuit ...
layer = QuantumLayer(
circuit=circuit,
n_photons=2,
measurement_strategy=MeasurementStrategy.amplitudes(ComputationSpace.FOCK),
return_object=True,
)
# Encode classical data as a StateVector (real data is auto-promoted to complex)
d = len(layer.output_keys) # basis size for (4 modes, 2 photons)
features = torch.randn(d) # real-valued classical features
sv = StateVector.from_tensor(features, n_modes=4, n_photons=2)
sv_out = layer(sv) # StateVector with output amplitudes
Amplitude encoding with complex tensor (no wrapper)
import torch
from merlin.algorithms import QuantumLayer
from merlin.measurement import MeasurementStrategy
from merlin.core.computation_space import ComputationSpace
layer = QuantumLayer(
circuit=circuit, # same circuit as above
n_photons=2,
measurement_strategy=MeasurementStrategy.probs(ComputationSpace.FOCK),
)
d = len(layer.output_keys)
amps = torch.randn(d, dtype=torch.complex64)
amps = amps / amps.abs().pow(2).sum().sqrt()
probs = layer(amps) # complex dtype triggers amplitude encoding
Amplitude encoding with probabilities out and post-selection
import torch
from merlin.algorithms import QuantumLayer
from merlin.measurement import MeasurementStrategy
from merlin.core.computation_space import ComputationSpace
from merlin.core.state_vector import StateVector
layer = QuantumLayer(
circuit=circuit,
n_photons=2,
measurement_strategy=MeasurementStrategy.probs(ComputationSpace.FOCK),
return_object=True,
)
sv = StateVector.from_tensor(torch.randn(len(layer.output_keys)), n_modes=4, n_photons=2)
pd = layer(sv) # ProbabilityDistribution
# Post-select to unbunched states
pd_ub = pd.filter(ComputationSpace.UNBUNCHED)
print(pd_ub.logical_performance) # fraction of mass kept
print(pd_ub.probabilities()) # renormalized probabilities
Measurement Strategies (Output Options)
Both angle and amplitude encoding support the following output measurement strategies. For more details, see Measurement Strategy Guide.
MeasurementStrategy.probs(computation_space)(default): returns a probability vector aligned withlayer.output_keys. Withreturn_object=True, returns aProbabilityDistribution.MeasurementStrategy.mode_expectations(computation_space): returns per-mode expected photon counts.MeasurementStrategy.amplitudes(): returns complex amplitudes (simulation-only; bypasses detectors and noise). Withreturn_object=True, returns aStateVector.MeasurementStrategy.partial(computation_space): returns a partial measurement result for selected modes.
References
Tak Hur et al., “Quantum convolutional neural network for classical data classification”, 2022. https://arxiv.org/abs/2108.00661
See also
quickstart-basic-concepts — overview of
StateVectorandProbabilityDistributionapi-state-vector — full API reference for
StateVectorincludingfrom_tensor