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.

  1. 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)
  1. 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 FeatureMap and FidelityKernel if 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:

  1. 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.

  2. Wrap it via StateVector.from_tensor(), which attaches the Fock metadata and auto-promotes real data to complex.

  3. Pass the StateVector to forward() — 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 torch.Tensor

A single complex-dtype tensor is treated identically to a StateVector’s
underlying 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_photons is set (so the layer knows the Hilbert space dimension).

  • input_size and input_parameters are 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:

\[d = \binom{n\_modes + n\_photons - 1}{n\_photons}\]

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 StateVector may be passed per forward() call.

  • StateVector and torch.Tensor inputs cannot be mixed in the same call.

  • Batched (2-D) StateVector inputs are supported. Pass a 2-D tensor of shape (batch_size, d) to from_tensor() and the layer processes the whole batch in a single forward() call.

  • With MeasurementStrategy.amplitudes() the layer bypasses detectors and noise; shots must 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_state constructor parameter and pass the classical features as a real tensor to forward().

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 forward()

Real torch.Tensor

StateVector via from_tensor() (preferred) or complex tensor

Number of inputs

User-defined (input_size)

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)

input_size, add_angle_encoding(...)

n_photons; no input_size or input_parameters

Activation trigger

Real tensor to forward()

StateVector.from_tensor(data, ...) or complex tensor to forward()

Typical use

Feature maps, kernels, hybrid NN layers

Classical data as amplitudes. via from_tensor; or injecting a
prepared quantum state

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_size equals 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 nonzero shots or 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 StateVector and a torch.Tensor in the same forward() call. Use either angle encoding (real tensors) or amplitude encoding (StateVector / complex tensor), not both.

  • DeprecationWarning for amplitude_encoding=True: Migrate to passing a StateVector or complex tensor to forward() instead of using the constructor flag.

  • Batched amplitude encoding: Pass a 2-D tensor to StateVector.from_tensor() and call forward() with the resulting StateVector. 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 with layer.output_keys. With return_object=True, returns a ProbabilityDistribution.

  • MeasurementStrategy.mode_expectations(computation_space): returns per-mode expected photon counts.

  • MeasurementStrategy.amplitudes(): returns complex amplitudes (simulation-only; bypasses detectors and noise). With return_object=True, returns a StateVector.

  • 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 StateVector and ProbabilityDistribution

  • api-state-vector — full API reference for StateVector including from_tensor