Quantum Autoencoder
Encoder Types
Classical Encoders
Encode or compress classical data into smaller-sized data via a deterministic algorithm. For example, JPEG is essentially an algorithm that compresses images into smaller-sized images.
Classical Autoencoders
Use machine-learning techniques and train a variational network for compressing data. In general, an autoencoder network looks as follows:

Classical autoencoder layout (from Wikipedia)
The network has three main parts:
-
The encoder compresses the data into a smaller, coded layer.
-
The latter is the input to a decoder part.
-
Typically, training is done against the comparison between the input and the output of this network.
Classical autoencoders can also be used for anomaly detection (see below).
Quantum Autoencoders
In a similar fashion to the classical counterpart, a quantum autoencoder compresses quantum data stored initially on \(n\) qubits into a smaller quantum register of \(m<n\) qubits via a variational circuit. However, quantum computing is reversible; therefore, qubits cannot be "erased". Alternatively, a quantum autoencoder tries to achieve the following transformation from an uncoded quantum register of size \(n\) to a coded one of size \(m\):
Namely, we try to decouple the initial state to a product state of a smaller register of size \(m\) and a register that is in the zero state. The former is usually called the coded state and the latter the trash state.

Quantum autoencoder layout: uncoded data of size 5 transforms into two outputs: a coded register of size 3 and trash output of size 2 at state \(|00\rangle\)
Training Quantum Autoencoders
To train a quantum autoencoder, we define a proper cost function. Below are two common approaches, one using a swap test and the other using Hamiltonian measurements. We focus on the swap test case, and comment on the other approach at the end of this notebook.
The Swap Test
The swap test is a quantum function that checks the overlap between two quantum states. The inputs of the function are two quantum registers of the same size, \(|\psi_1\rangle, \,|\psi_2\rangle\), and it returns as output a single "test" qubit whose state encodes the overlap between the two inputs: \(|q\rangle_{\rm test} = \alpha|0\rangle + \sqrt{1-\alpha^2}|1\rangle\), with
Thus, the probability to measure the test qubit at state \(0\) is 1 if the states are identical (up to a global phase) and 0 if the states are orthogonal to each other.
The quantum model starts with an H gate on the test qubit, followed by swapping between the two states controlled on the test qubit and a final H gate on the test qubit:
![]() | ![]() |
Closed (left panel) and opened (right panel) visualization of the swap test algorithm
Quantum Neural Networks for Quantum Autoencoders
The quantum autoencoder can be built as a quantum neural network with these parts:
-
A data loading block that loads classical data on \(n\) qubits.
-
An encoder block, which is a variational quantum ansatz with input port of size \(n\) and output ports of size \(m\) and \(n-m\).
-
A swap test block between the \(n-m\) trash output of the encoder and new \(n-m\) zero registers.
We train the network such that the test qubit of the swap test is at state |0⟩ with probability 1.

Quantum autoencoder layout: uncoded data of size 5 transforms into two outputs, a coded register of size 3 and trash outputs of size 2 at state \(|00\rangle\)
Predefined Functions That Construct the Quantum Layer
In the first step we build user-defined functions that allow flexible modeling:
-
angle_encoding
: This function loads data of sizenum_qubits
onnum_qubits
qubits via RY gates. It has an output port namedqpv
. -
encoder_ansatz
: A simple variational ansatz for encodingnum_qubits
qubits onnum_encoding_qubits
qubits (see the description in the code block). The input port isx
, and the output ports arecoded
andtrash
.
from classiq import *
from classiq.qmod.symbolic import pi
@qfunc
def angle_encoding(exe_params: CArray[CReal], qbv: Output[QArray[QBit]]) -> None:
allocate(exe_params.len, qbv)
repeat(
count=exe_params.len,
iteration=lambda index: RY(pi * exe_params[index], qbv[index]),
)
@qfunc
def encoder_ansatz(
num_qubits: CInt,
num_encoding_qubits: CInt,
exe_params: CArray[CReal],
x: Input[QArray[QBit, "num_qubits"]],
trash: Output[QArray[QBit, "num_qubits-num_encoding_qubits"]],
coded: Output[QArray[QBit, "num_encoding_qubits"]],
) -> None:
"""
This is a parametric model which has num_trash_qubits = num_qubits-num_encoding_qubits as an output.
It contains num_trash_qubits layers, each composed of RY gates and CX gates with a linear connectivity,
and a final layer with RY gate on each of the trash qubits is applied.
"""
def single_layer(rep: CInt) -> None:
repeat(
count=num_qubits,
iteration=lambda index: RY(exe_params[rep * num_qubits + index], x[index]),
)
repeat(
count=num_qubits - 1,
iteration=lambda index: CX(x[index], x[index + 1]),
)
repeat(count=num_qubits - num_encoding_qubits, iteration=single_layer)
bind(x, [coded, trash])
repeat(
count=num_qubits - num_encoding_qubits,
iteration=lambda index: RY(
exe_params[(num_qubits - num_encoding_qubits) * num_qubits + index],
trash[index],
),
)
Example: Autoencoder for Domain Wall Data
In the following example we try to encode data which has a domain wall structure. Let us define the relevant data for strings of size 4.
The Data
import numpy as np
domain_wall_data = np.array([[0, 0, 1, 1], [0, 0, 0, 1], [0, 1, 1, 1]])
print("domain wall data:\n", domain_wall_data)
domain wall data:
[[0 0 1 1]
[0 0 0 1]
[0 1 1 1]]
The Quantum Program
We encode this data of size 4 on 2 qubits. Let us build the corresponding quantum layer based on the predefined functions above:
NUM_QUBITS = 4
NUM_ENCODING_QUBITS = 2
num_trash_qubits = NUM_QUBITS - NUM_ENCODING_QUBITS
num_weights_in_encoder = NUM_QUBITS * num_trash_qubits + num_trash_qubits
We construct the model:
@qfunc
def main(
w: CArray[CReal, num_weights_in_encoder],
input: CArray[CReal, NUM_QUBITS],
trash: Output[QArray[QBit, num_trash_qubits]],
coded: Output[QArray[QBit, NUM_ENCODING_QUBITS]],
test: Output[QBit],
) -> None:
x = QArray("x")
psi2 = QArray("psi2")
allocate(num_trash_qubits, psi2)
angle_encoding(exe_params=input, qbv=x)
encoder_ansatz(
num_qubits=NUM_QUBITS,
num_encoding_qubits=NUM_ENCODING_QUBITS,
exe_params=w,
x=x,
trash=trash,
coded=coded,
)
swap_test(state1=trash, state2=psi2, test=test)
ae_qmod = create_model(main)
We synthesize and visualize the quantum layer:
qprog = synthesize(ae_qmod)
show(qprog)
Opening: https://platform.classiq.io/circuit/dae2bad0-c23b-4a53-966e-3ea1c721051c?version=0.41.0.dev39%2B79c8fd0855
The Network
The network for training contains only a quantum layer. The corresponding quantum program was already defined above, so what remains is to define the execution preferences and the classical postprocess. The classical output is defined as \(1-\alpha^2\), with \(\alpha\) being the probability of the test qubit being at state 0.
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from classiq.applications.qnn import QLayer
from classiq.applications.qnn.types import (
MultipleArguments,
ResultsCollection,
SavedResult,
)
from classiq.execution import (
ExecutionPreferences,
execute_qnn,
set_quantum_program_execution_preferences,
)
from classiq.synthesis import SerializedQuantumProgram
num_shots = 4096
def execute(
quantum_program: SerializedQuantumProgram, arguments: MultipleArguments
) -> ResultsCollection:
quantum_program = set_quantum_program_execution_preferences(
quantum_program, preferences=ExecutionPreferences(num_shots=num_shots)
)
return execute_qnn(quantum_program, arguments)
def post_process(result: SavedResult) -> torch.Tensor:
alpha_sqaured = result.value.counts_of_output("test")["0"] / num_shots
out = 1 - alpha_sqaured
return torch.tensor(out)
def create_net(*args, **kwargs) -> nn.Module:
class Net(nn.Module):
def __init__(self, *args, **kwargs):
super().__init__()
self.qlayer = QLayer(
qprog,
execute,
post_process,
*args,
**kwargs,
)
def forward(self, x):
x = self.qlayer(x)
return x
return Net(*args, **kwargs)
encoder_train_network = create_net()
Creating the Dataset
The cost function to minimize is \(|1-\alpha^2|\) for all our training data. Looking at the Qlayer output, this means that we should define the corresponding labels as \(0\):
class MyDWDataset:
def __init__(self, data, labels) -> None:
self.data = torch.from_numpy(data).float()
self.labels = torch.unsqueeze(torch.from_numpy(labels), dim=-1).float()
def __len__(self):
return self.data.shape[0]
def __getitem__(self, idx):
return self.data[idx], self.labels[idx]
labels = np.array([0, 0, 0])
train_dataset = MyDWDataset(domain_wall_data, labels)
train_data_loader = DataLoader(
train_dataset, batch_size=2, shuffle=True, drop_last=False
)
Defining the Training
import time as time
def train(
model: nn.Module,
data_loader: DataLoader,
loss_func: nn.modules.loss._Loss,
optimizer: optim.Optimizer,
epoch: int = 40,
) -> None:
for index in range(epoch):
start = time.time()
for data, label in data_loader:
optimizer.zero_grad()
output = model(data)
loss = loss_func(torch.squeeze(output), torch.squeeze(label))
loss.backward()
optimizer.step()
print(time.time() - start)
print(index, f"\tloss = {loss.item()}")
Setting Hyperparameters
The L1 loss function fits the intended cost function we aim to minimize:
_LEARNING_RATE = 0.3
loss_func = nn.L1Loss()
optimizer = optim.SGD(encoder_train_network.parameters(), lr=_LEARNING_RATE)
Training
In this demo we initialize the network with trained parameters and run only one epoch for demonstration purposes. Reasonable training with the above hyperparameters can be achieved with \(\sim 40\) epochs. To train the network from the beginning, uncomment the following code line:
trained_weights = torch.nn.Parameter(
torch.Tensor(
[1.5227, 0.3588, 0.6905, 1.4777, 1.5718, 1.5615, 1.5414, 0.6021, 0.1254, 0.9903]
)
)
encoder_train_network.qlayer.weight = trained_weights
data_loader = train_data_loader
train(encoder_train_network, data_loader, loss_func, optimizer, epoch=1)
7.038687229156494
0 loss = 0.00146484375
Verifying
Once we have trained the network, we can build a new network with the trained variables. We verify our encoder by taking only the encoding block, changing the postprocess, etc. Below, we verify our quantum autoencoder by comparing the input with the output of an encoder-decoder network.
We create the following network containing two quantum blocks:
-
The first two blocks of the previous network: a block for loading the inputs followed by our quantum encoder.
-
The inverse of the quantum encoder, where the inputs for the trash qubits are new zero inputs.
The network weights are allocated together with the trained ones.

Qlayer for verifying the quantum autoencoder containing three quantum blocks: (1) data encoding, (2) trained encoder, and (3) inverse of trained encoder (i.e., decoder)
Building the Quantum Layer for the Validator
The validator contains the inverse of our encoder. We have to wrap the encoder function because the invert
quantum function accepts a single in/out port:
@qfunc
def encoder_ansatz_wrapper(
num_qubits: CInt,
num_encoding_qubits: CInt,
exe_params: CArray[CReal],
qbv: QArray[QBit, "num_qubits"],
) -> None:
coded = QArray("coded")
trash = QArray("trash")
encoder_ansatz(
num_qubits=num_qubits,
num_encoding_qubits=num_encoding_qubits,
exe_params=exe_params,
x=qbv,
trash=trash,
coded=coded,
)
bind([coded, trash], qbv)
We are now ready to model our verification quantum layer:
@qfunc
def main(
w: CArray[CReal, num_weights_in_encoder],
input: CArray[CReal, NUM_QUBITS],
decoded: Output[QArray[QBit, NUM_QUBITS]],
trash: Output[QArray[QBit, num_trash_qubits]],
) -> None:
psi2 = QArray("psi2")
coded = QArray("coded")
allocate(num_trash_qubits, psi2)
angle_encoding(exe_params=input, qbv=decoded)
encoder_ansatz(
num_qubits=NUM_QUBITS,
num_encoding_qubits=NUM_ENCODING_QUBITS,
exe_params=w,
x=decoded,
trash=trash,
coded=coded,
)
bind([coded, psi2], decoded)
invert(
stmt_block=lambda: encoder_ansatz_wrapper(
num_qubits=NUM_QUBITS,
num_encoding_qubits=NUM_ENCODING_QUBITS,
exe_params=w,
qbv=decoded,
),
)
qmod_validator = create_model(main)
qprog_validator = synthesize(qmod_validator)
show(qprog_validator)
Opening: https://platform.classiq.io/circuit/9aee699f-59eb-4d4e-b543-c0e346619889?version=0.41.0.dev39%2B79c8fd0855
Defining the Classical Output of the Network
For the validator postprocessing, we take the output with the maximum counts:
def execute_validator(
quantum_program: SerializedQuantumProgram, arguments: MultipleArguments
) -> ResultsCollection:
return execute_qnn(quantum_program, arguments)
def post_process_validator(result: SavedResult) -> torch.Tensor:
counts = result.value.counts_of_output("decoded")
max_key = max(counts, key=counts.get)
return torch.tensor([int(k) for k in max_key])
Creating the Network and Assigning the Trained Parameters
def create_encoder_decoder_net(*args, **kwargs) -> nn.Module:
class Net(nn.Module):
def __init__(self, *args, **kwargs):
super().__init__()
self.qlayer = QLayer(
qprog_validator,
execute_validator,
post_process_validator,
*args,
**kwargs,
)
def forward(self, x):
x = self.qlayer(x)
return x
return Net(*args, **kwargs)
validator_network = create_encoder_decoder_net()
validator_network.qlayer.weight = encoder_train_network.qlayer.weight
Now we can compare the input with the output of the validator for different data:
validator_data_loader = DataLoader(
train_dataset, batch_size=1, shuffle=True, drop_last=False
)
for data, label in validator_data_loader:
output = validator_network(data)
print("input=", data.tolist()[0], ", output=", output.tolist()[0])
input= [0.0, 0.0, 1.0, 1.0] , output= [0.0, 0.0, 1.0, 1.0]
input= [0.0, 0.0, 0.0, 1.0] , output= [0.0, 0.0, 0.0, 1.0]
input= [0.0, 1.0, 1.0, 1.0] , output= [0.0, 1.0, 1.0, 1.0]
Detecting Anomalies
We can use our trained network for anomaly detection. Let's see what happens to the trash qubits when we insert an anomaly; namely, non-domain-wall data:
input_anomaly_data = np.array(
[[0, 0, 1, 1], [0, 0, 0, 1], [0, 1, 1, 1], [1, 0, 1, 0], [1, 1, 1, 1]]
)
anomaly_labels = np.array([0, 0, 0, 0, 0])
anomaly_dataset = MyDWDataset(input_anomaly_data, anomaly_labels)
anomaly_data_loader = DataLoader(
anomaly_dataset, batch_size=1, shuffle=True, drop_last=False
)
We print all the anomaly data based on predefined accuracy for the cost function:
tolerance = 1e-2
for data, label in anomaly_data_loader:
output = encoder_train_network(data)
if abs(output.tolist()[0]) > tolerance:
print("anomaly:", data.tolist()[0])
anomaly: [1.0, 1.0, 1.0, 1.0]
anomaly: [1.0, 0.0, 1.0, 0.0]
Alternative Network for Training a Quantum Autoencoder
Another way to introduce a cost function is by estimating Hamiltonians. Measuring the Pauli \(Z\) matrix on a qubit at the general state \(|q\rangle=a|0\rangle+b|1\rangle\) is \(\langle q |Z|q \rangle=a^2-b^2\). Therefore, a cost function can be defined by taking expectation values on the trash output (without a swap test) as follows:
Below we show how to define the corresponding Qlayer: the quantum program and postprocessing.
The Quantum Program
@qfunc
def main(
w: CArray[CReal, num_weights_in_encoder],
input: CArray[CReal, NUM_QUBITS],
trash: Output[QArray[QBit, num_trash_qubits]],
) -> None:
x = QArray("x")
coded = QArray("coded")
angle_encoding(exe_params=input, qbv=x)
encoder_ansatz(
num_qubits=NUM_QUBITS,
num_encoding_qubits=NUM_ENCODING_QUBITS,
exe_params=w,
x=x,
trash=trash,
coded=coded,
)
qmod_ae_alt = create_model(main)
qprog_ae_alt = synthesize(qmod_ae_alt)
show(qprog_ae_alt)
Opening: https://platform.classiq.io/circuit/00ce49a6-7aa2-4e35-9295-d598d672b71d?version=0.41.0.dev39%2B79c8fd0855
Executing and Postprocessing
The size of the trash register is 2. We measure the Pauli \(Z\) matrix on each of its qubits:
from classiq.applications.chemistry import PauliOperator, PauliOperators
def execute(
quantum_program: SerializedQuantumProgram, arguments: MultipleArguments
) -> ResultsCollection:
return execute_qnn(
quantum_program,
arguments,
observable=PauliOperator(pauli_list=[("IZ", 1), ("ZI", 1)]),
)
def post_process(result: SavedResult) -> torch.Tensor:
out = 1 / 2 * (2 - np.real(result.value.value))
return torch.tensor(out)
def create_net(*args, **kwargs) -> nn.Module:
class Net(nn.Module):
def __init__(self, *args, **kwargs):
super().__init__()
self.qlayer = QLayer(
qprog_ae_alt,
execute,
post_process,
*args,
**kwargs,
)
def forward(self, x):
x = self.qlayer(x)
return x
return Net(*args, **kwargs)
encoder_train_network = create_net()