构建量子神经网络分类器
适用版本 · ArcQML 0.1.0
本篇展示关键代码片段,完整程序和数据随 ArcQML 发行包提供。配对代码框内的 Rust / Python 按钮会同步切换全篇示例,并在 VQE、QNN 之间记住选择;单段代码和命令保持显示。
本页目录
ArcQML 是一个用 Rust 语言原生实现的量子机器学习框架,提供了量子电路构建、状态向量模拟、可观测量期望值计算、自动微分和参数优化等核心能力。框架既支持单个量子态的计算,也支持批量状态模拟,并可使用 Pauli 算符及其线性组合表示可观测量。量子电路计算得到的期望值以可微分张量形式输出,可以继续参与损失函数及其他经典可微运算;执行反向传播时,梯度能够沿完整计算图传递至量子电路参数。对于含参量子电路,ArcQML 可以利用伴随法计算电路参数梯度,再与 Adam、SGD 等经典优化器配合完成混合量子—经典算法的训练。
这些能力可以用于变分量子本征求解、量子神经网络、量子态分析和小规模酉矩阵综合等任务。由于 Rust 语言具备原生编译、内存安全和高效并行等特性,ArcQML 能在保持可靠性的同时提供良好的计算性能。
本教程使用 ArcQML 实现一个完整的二分类任务,选取德国信用数据集中的 10 个特征,预测申请者是否属于“信用不良”类别。教程将依次说明数据预处理、量子态编码、构造量子电路与制备量子态、计算二元交叉熵损失、参数优化以及验证和测试。完整程序分别位于 examples/rust/qnn_german_credit.rs 和 examples/python/qnn_german_credit.py。
完整数据集位于 examples/data/german_credit.csv。
1. 准备运行环境
请先按安装与环境配置准备发行包和运行环境。Rust 用户完成 Rust 与 Runtime 配置;Python 用户完成 wheel 安装即可,无需安装 Rust 或设置 Runtime 环境变量。
2. QNN 拟解决的问题
2.1 从经典数据到二分类结果
二分类是一类监督学习任务,其目标是根据输入特征学习一个判别函数,将样本划分到两个互斥类别之一。数据集中的 Creditability 字段将每条信用记录分为:
0:信用不良,占总数据的 30%;1:信用良好,占总数据的 70%。
在本教程中,量子神经网络(QNN)被构造为参数化二分类模型。每条记录首先经过预处理并编码成特定的量子态;随后,不同样本通过同一条可训练的含参量子电路。电路末端测量第 5 号量子比特的 Pauli-Z 期望值,并将该连续值作为二分类的分类分数 (logit)。训练阶段以真实标签作为监督信号,通过最小化二元交叉熵损失更新量子电路参数,使模型输出更符合训练数据的类别分布,最终的预测类别会与真实类别逐渐一致。
2.2 数据与教学配置
数据文件:examples/data/german_credit.csv,随 ArcQML 代码仓库提供。
本示例使用的德国信用数据集包含 1000 条申请人的信用记录,通过账户状况、还款历史、贷款用途、就业情况和资产状况等特征,将申请人划分为信用良好与信用不良两类。教学示例采用以下配置:
| 配置 | 数值 | 作用 |
|---|---|---|
| 量子比特数 | 10 | 每个特征对应一个量子比特。 |
| 状态向量长度 | 10 量子比特量子态包含 1024 个复数振幅。 | |
| QNN 层数 | 1 | 控制可训练电路的深度。 |
| 每层参数数 | 37 | 由本例的旋转门结构决定。 |
| 训练/验证/测试 | 800/100/100 | 分别用于训练、调试观察和最终评估。 |
| 每批样本数 | 100 | 每次共同计算损失并更新参数的样本数。 |
| 训练轮数 | 5 | 完整遍历训练集 5 次。 |
| Adam 学习率 | 0.01 | 控制每次参数更新的总体幅度。 |
| 随机种子 | 4 | 让同一语言中的数据划分和初始化可以复现。 |
程序选取以下 10 个特征:Account Balance,Payment Status of Previous Credit,Purpose,Value Savings/Stocks,Length of current employment,Guarantors,Most valuable available asset,Concurrent Credits,Type of apartment,No of Credits at this Bank,每个特征对应一个量子比特。
3. 初始化设置
首先导入 ArcQML、文件读取和数值计算所需的接口:
use arcqml::prelude::*;
use num_complex::Complex64;
use std::{f64::consts::PI, fs};
// `AppResult<T>` 表示成功时返回 T,失败时返回可打印的错误
// 后续代码中的 `?` 会把错误自动交给调用者处理
type AppResult<T> = std::result::Result<T, Box<dyn std::error::Error>>;
from __future__ import annotations
import csv
from pathlib import Path
import numpy as np
import arcqml
示例中将主要数值集中声明为常量:
const QUBITS: usize = 10; // 10 个特征对应 10 个量子比特
const STATE_DIMENSION: usize = 1 << QUBITS; // 即 2^10 = 1024
const PARAMETERS_PER_LAYER: usize = 37; // 由后续声明的电路结构决定
const LAYERS: usize = 1; // 教程只使用 1 层,降低运行成本
const EPOCHS: usize = 5; // 完整遍历训练集 5 次
const BATCH_SIZE: usize = 100; // 每次处理 100 条记录
const LEARNING_RATE: f64 = 0.01; // Adam 的学习率
const SEED: u64 = 4; // 固定种子用于复现
const DATA_PATH: &str = "examples/data/german_credit.csv";
QUBITS = 10 # 10 个特征对应 10 个量子比特
STATE_DIMENSION = 1 << QUBITS # 即 2**10 = 1024
PARAMETERS_PER_LAYER = 37 # 由后续声明的电路结构决定。
LAYERS = 1 # 教程只使用 1 层,降低运行成本
EPOCHS = 5 # 完整遍历训练集 5 次
BATCH_SIZE = 100 # 每次处理 100 条记录
LEARNING_RATE = 0.01 # Adam 的学习率
SEED = 4 # 固定种子用于复现
DATA_PATH = Path(__file__).resolve().parents[1] / "data" / "german_credit.csv"
部分超参数可以在后续实验中调整。
4. 读取和预处理数据
4.1 标签读取
数据集中的 Creditability 列中,1 表示信用良好,0 表示信用不良。本示例将“信用不良”作为正类 1:
// 读取标签列
let label_column = headers
.iter()
.position(|name| *name == "Creditability")
.ok_or("missing Creditability column")?;
let creditability: u8 = fields[label_column].parse()?;
// 原始值 1(信用良好)转为标签 0,原始值 0(信用不良)转为标签 1
let label = f64::from(1 - creditability);
labels = np.asarray(
[1.0 - float(row["Creditability"]) for row in rows],
dtype=np.float64,
)
4.2 特征预处理
数据集中的原始特征均是离散数值,不能不加处理地作为旋转角。示例对每一特征列独立执行 min-max 缩放:
其中 和 是当前特征列在全部 1000 条记录中的最小值和最大值。转换后,每个特征都落在 ,可以直接作为 RX 和 RZ 门的旋转角。
for column in 0..QUBITS {
let minimum = samples
.iter()
.map(|sample| sample.features[column])
.fold(f64::INFINITY, f64::min);
let maximum = samples
.iter()
.map(|sample| sample.features[column])
.fold(f64::NEG_INFINITY, f64::max);
for sample in &mut samples {
// 把当前特征列缩放到 [π/2, 3π/2],得到角度编码所需的弧度值
sample.features[column] =
PI / 2.0
+ (sample.features[column] - minimum) / (maximum - minimum) * PI;
}
}
minimum = features.min(axis=0)
maximum = features.max(axis=0)
# 把当前特征列缩放到 [π/2, 3π/2],得到角度编码所需的弧度值
features = (
np.pi / 2.0
+ (features - minimum) / (maximum - minimum) * np.pi
)
如果某一列所有值都相同,就会出现 。本例选取的 10 列不存在该情况;如需替换特征列时应先检查并排除常量列,或为它们单独指定固定角度进行编码。
4.3 集合划分
程序先打乱记录,再按训练集:验证集:测试集以 8:1:1 的比例划分:
Rng::new(SEED).shuffle(&mut samples);
let test_samples = samples.split_off(900); // 测试集
let validation_samples = samples.split_off(800); // 验证集
let training_samples = samples; // 训练集
rng = np.random.default_rng(seed=SEED)
shuffled_indices = rng.permutation(len(features))
training_indices = shuffled_indices[:800] # 训练集
validation_indices = shuffled_indices[800:900] # 验证集
test_indices = shuffled_indices[900:] # 测试集
5. 初始量子态制备
5.1 角度编码
每条记录包含 10 个缩放后的角度 。本例在第 个量子比特上先施加 RX(x'_i),再施加 RZ(x'_i)。这种把经典数值作为量子门角度的方式称为角度编码。
单个量子比特从 出发,编码后为:
10 个量子比特在编码阶段互不纠缠,因此整条记录的编码结果是 10 个局部量子态的张量积。每条记录最终得到一个长度为 的复数状态向量。
5.2 量子态制备
为了防止将 RX(x'_i) 和 RZ(x'_i) 直接加入可训练电路导致 ArcQML 将这些角度也登记成参数,示例预先计算与这些门完全等价的量子态编码结果,再让整组样本共用一条只包含 37 个可训练参数的电路。
fn encode_product_states(samples: &[Sample]) -> AppResult<Tensor> {
let mut encoded_amplitudes =
Vec::with_capacity(samples.len() * STATE_DIMENSION);
for sample in samples {
let mut encoded_state = vec![Complex64::new(1.0, 0.0)];
// 按 q9、q8、...、q0 的顺序展开,使最终振幅索引与 ArcQML 一致
for qubit in (0..QUBITS).rev() {
let angle = sample.features[qubit];
let half_angle = angle / 2.0;
let amplitude_zero =
Complex64::from_polar(1.0, -half_angle) * half_angle.cos();
let amplitude_one = Complex64::new(0.0, -1.0)
* Complex64::from_polar(1.0, half_angle)
* half_angle.sin();
let mut expanded_state = Vec::with_capacity(encoded_state.len() * 2);
for amplitude in encoded_state {
expanded_state.push(amplitude * amplitude_zero);
expanded_state.push(amplitude * amplitude_one);
}
encoded_state = expanded_state;
}
encoded_amplitudes.extend(encoded_state);
}
Ok(Tensor::new(TensorData::FlatC64 {
data: encoded_amplitudes,
shape: vec![samples.len(), STATE_DIMENSION],
})?)
}
def encode_product_states(features: np.ndarray) -> np.ndarray:
"""把角度编码直接转换为一组乘积量子态。"""
encoded_states = np.ones((len(features), 1), dtype=np.complex128)
# 按 q9、q8、...、q0 的顺序展开,使最终振幅索引与 ArcQML 一致
for qubit in range(QUBITS - 1, -1, -1):
angles = features[:, qubit]
local_encoded_states = np.column_stack(
(
np.exp(-0.5j * angles) * np.cos(0.5 * angles),
-1j * np.exp(0.5j * angles) * np.sin(0.5 * angles),
)
)
encoded_states = np.einsum(
"bi,bj->bij",
encoded_states,
local_encoded_states,
).reshape(len(features), -1)
assert encoded_states.shape == (len(features), STATE_DIMENSION)
return np.ascontiguousarray(encoded_states, dtype=np.complex128)
每条记录得到一个量子态编码结果,若干结果组成形状为 [样本数, 1024] 的批量量子态数组。
6. 变分量子电路构造
6.1 单层电路结构
角度编码只负责放入输入数据,真正接受优化器更新的是变分量子电路中的可训练参数。示例中构造的电路包含 1 层,每层由 37 个旋转门和 15 个 CNOT 门组成,共 52 个门。旋转门提供可训练角度,CNOT 门让不同量子比特建立关联。可以通过调整 LAYERS 从而调整示例中的电路层数。
fn append_ansatz_layer(circuit: &mut Circuit, values: &[f64]) -> AppResult<()> {
if values.len() != PARAMETERS_PER_LAYER {
return Err("each ansatz layer requires 37 parameters".into());
}
for qubit in 0..QUBITS {
circuit.rx(values[qubit], qubit)?;
}
for (control, target) in [(0, 1), (2, 3), (4, 5), (9, 8), (7, 6)] {
circuit.cnot(control, target)?;
}
for qubit in 1..9 {
circuit.rx(values[9 + qubit], qubit)?;
}
for (control, target) in [(1, 2), (3, 4), (8, 7), (6, 5)] {
circuit.cnot(control, target)?;
}
for qubit in 2..8 {
circuit.rx(values[16 + qubit], qubit)?;
}
for (control, target) in [(2, 3), (4, 5), (7, 6)] {
circuit.cnot(control, target)?;
}
for (parameter, qubit) in [(24, 3), (25, 4), (26, 5), (27, 6)] {
circuit.ry(values[parameter], qubit)?;
}
circuit.cnot(3, 4)?.cnot(6, 5)?;
circuit
.rz(values[28], 4)?
.ry(values[29], 4)?
.rz(values[30], 4)?;
circuit
.rz(values[31], 5)?
.ry(values[32], 5)?
.rz(values[33], 5)?;
circuit.cnot(4, 5)?;
circuit
.rz(values[34], 5)?
.ry(values[35], 5)?
.rz(values[36], 5)?;
Ok(())
}
def append_ansatz_layer(circuit: arcqml.Circuit, values: np.ndarray) -> None:
if values.shape != (PARAMETERS_PER_LAYER,):
raise ValueError("each ansatz layer requires 37 parameters")
for qubit in range(QUBITS):
circuit.rx(angle=float(values[qubit]), qubit=qubit)
for control, target in ((0, 1), (2, 3), (4, 5), (9, 8), (7, 6)):
circuit.cnot(control=control, target=target)
for qubit in range(1, 9):
circuit.rx(angle=float(values[9 + qubit]), qubit=qubit)
for control, target in ((1, 2), (3, 4), (8, 7), (6, 5)):
circuit.cnot(control=control, target=target)
for qubit in range(2, 8):
circuit.rx(angle=float(values[16 + qubit]), qubit=qubit)
for control, target in ((2, 3), (4, 5), (7, 6)):
circuit.cnot(control=control, target=target)
for parameter, qubit in ((24, 3), (25, 4), (26, 5), (27, 6)):
circuit.ry(angle=float(values[parameter]), qubit=qubit)
circuit.cnot(control=3, target=4)
circuit.cnot(control=6, target=5)
circuit.rz(angle=float(values[28]), qubit=4)
circuit.ry(angle=float(values[29]), qubit=4)
circuit.rz(angle=float(values[30]), qubit=4)
circuit.rz(angle=float(values[31]), qubit=5)
circuit.ry(angle=float(values[32]), qubit=5)
circuit.rz(angle=float(values[33]), qubit=5)
circuit.cnot(control=4, target=5)
circuit.rz(angle=float(values[34]), qubit=5)
circuit.ry(angle=float(values[35]), qubit=5)
circuit.rz(angle=float(values[36]), qubit=5)
电路结构如下,可左右滚动查看完整电路:
6.2 初始化角度
本示例从均值为 0、标准差为 1 的标准正态分布生成 37 个可训练角度。
let mut rng = Rng::new(SEED);
// 创建电路
let mut circuit = Circuit::new(QUBITS)?;
for _layer in 0..LAYERS {
// 生成标准正态分布初值
let initial_angles: Vec<f64> = (0..PARAMETERS_PER_LAYER)
.map(|_| rng.normal())
.collect();
append_ansatz_layer(&mut circuit, &initial_angles)?;
}
rng = np.random.default_rng(seed=SEED)
# 创建电路
circuit = arcqml.Circuit(num_qubits=QUBITS)
for _layer in range(LAYERS):
# 生成标准正态分布初值
initial_angles = rng.standard_normal(PARAMETERS_PER_LAYER)
append_ansatz_layer(circuit=circuit, values=initial_angles)
7. 期望值测量
本实例选择测量第 5 号量子比特的 Pauli-Z 期望值。选择 q5 是本例电路结构的固定设计,因为它聚合了所有特征的全部信息。
let observable = SparsePauliOp::single(
QUBITS,
5usize, // 测量 q5
Pauli::Z,
1.0, // Pauli-Z 项的系数
)?;
observable = arcqml.PauliSum.z(
num_qubits=QUBITS,
qubit=5, # 测量 q5
coefficient=1.0, # Pauli-Z 项的系数
)
对第 个样本,模型输出为:
其中 表示输入的编码后特征, 表示电路中的可训练角度。Pauli-Z 期望值位于 ,本例把 直接作为二分类的 logit,再通过 sigmoid 函数解释为正类概率:
等价于 ,因此预测为信用不良; 则预测为信用良好。
8. 损失函数定义
损失函数把一组预测和已知标签归纳成一个需要最小化的数值。本例使用带 logit 的二元交叉熵。对于包含 条记录的一批数据,损失为:
如果标签 ,损失会推动 增大;如果 ,损失会推动 减小。
let targets = labels(training_samples)?;
let loss = binary_cross_entropy_with_logits_loss(
&logits,
&targets,
)?;
// loss 是整批样本二元交叉熵的平均值,而不是某一个样本的损失
let loss_value = loss.value()?;
targets = arcqml.tensor(
np.ascontiguousarray(training_labels, dtype=np.float64)
)
loss = arcqml.binary_cross_entropy_with_logits(
logits=logits,
targets=targets,
)
# loss 是整批样本二元交叉熵的平均值,而不是某一个样本的损失
loss_value = float(loss.item())
损失越小,说明当前预测与标签越一致。通过不断迭代优化量子电路中的可训练参数从而最小化损失函数,能够有效使模型输出的分类分数逐渐逼近标签所对应的目标,从而提高模型对样本类别的预测能力。
9. 模拟器创建和优化器选择
9.1 状态向量模拟器
// 从给定状态创建模拟器
let simulator = BatchStateVectorSimulator::from_state_tensor(QUBITS, states)?;
# 从给定状态创建模拟器
simulator = arcqml.BatchStateVectorSimulator.from_amplitudes(
num_qubits=QUBITS,
amplitudes=np.ascontiguousarray(states[batch_indices]),
)
模拟器从给定的 states 开始执行整条电路。Rust 的 simulator.run(circuit, observable) 与 Python 的 simulator.run(circuit=circuit, observable=observable) 都不会永久改变模拟器保存的初始状态,因此同一个模拟器可以在全部训练步骤中重复使用。
9.2 Adam 优化器
普通梯度下降对所有参数使用同一个固定更新尺度。Adam 会为每个参数分别记录近期梯度的平均趋势和梯度平方的平均趋势,据此自动调节每个参数的实际更新幅度。
let mut optimizer = Adam::new(
LEARNING_RATE, // 使用前文定义的学习率 0.01
0.9, // beta1
0.999, // beta2
1e-8, // epsilon
0.0, // weight_decay
)?;
# 其余可选参数采用与 Rust 代码相同的默认值
optimizer = arcqml.Adam(learning_rate=LEARNING_RATE)
该超参数组合不适用于所有任务和电路。如果训练效果不好,该部分超参数也可能需要重新调整。
10. 批量前向计算与反向传播
一次训练迭代包含四个步骤:
- 前向计算当前损失值。
- 从损失值反向计算所有参数的梯度。
- Adam 根据梯度更新参数。
- 清空本轮梯度,避免它们与下一轮结果累加。
// 本部分为伪代码,仅供演示
// logits 的形状是 [批样本数],每个元素对应一条记录的 Z(q5) 期望值
let logits = simulator.run(circuit, observable)?;
// 前向计算当前损失值
let loss = binary_cross_entropy_with_logits_loss(&logits, &targets)?;
// 从损失值反向计算所有参数的梯度
loss.backward()?;
// Adam 根据梯度更新参数
optimizer.step(circuit.parameters())?;
// 清空本轮梯度,避免它们与下一轮结果累加
optimizer.zero_grad(circuit.parameters());
# 本部分为伪代码,仅供演示
# logits 的形状是 [批样本数],每个元素对应一条记录的 Z(q5) 期望值
logits = simulator.run(circuit=circuit, observable=observable)
# 前向计算当前损失值
loss = arcqml.binary_cross_entropy_with_logits(
logits=logits,
targets=targets,
)
# 从损失值反向计算所有参数的梯度
loss.backward()
# Adam 根据梯度更新参数
optimizer.step(circuit=circuit)
# 清空本轮梯度,避免它们与下一轮结果累加
optimizer.zero_grad(circuit=circuit)
run 返回的是 Tensor,调用 backward() 时,ArcQML 根据这份记录自动计算梯度。第一次迭代前参数还没有梯度,所以不需要预先调用 zero_grad;第一次反向之后,每轮都必须清零。
11. 验证与测试
验证和测试只读取模型输出,不更新参数,因此应关闭梯度记录。这样可以减少不必要的计算过程记录和内存占用:
let _guard = no_grad();
let encoded_states = encode_product_states(evaluation_samples)?;
let simulator = BatchStateVectorSimulator::from_state_tensor(
QUBITS,
encoded_states,
)?;
let logits = simulator.run(&circuit, &observable)?;
// `_guard` 离开当前作用域时自动恢复梯度记录
encoded_state_batch = np.ascontiguousarray(
evaluation_encoded_states,
dtype=np.complex128,
)
simulator = arcqml.BatchStateVectorSimulator.from_amplitudes(
num_qubits=QUBITS,
amplitudes=encoded_state_batch,
)
with arcqml.no_grad():
scores = simulator.run(
circuit=circuit,
observable=observable,
).numpy()
12. 运行完整示例
12.1 运行 Rust 版本
在安装页的 Rust 配置说明已经设置 ARCQML_RUNTIME_LIB_DIR 的同一个 Windows PowerShell 或 Linux 终端中执行:
cargo run --release -p arcqml --example qnn_german_credit
12.2 运行 Python 版本
先按照安装页的 Python 配置说明安装 wheel 并激活 Conda 环境,再从 ArcQML 根目录执行:
conda activate arcqml-example
python examples/python/qnn_german_credit.py
12.3 输出示例
两种程序都会显示数据划分、参数数量、每轮训练和验证结果,以及最终测试集 AUC:
samples: train=800, validation=100, test=100
qubits=10, layers=1, parameters=37
epoch 01/5: train_loss=..., validation_loss=..., validation_accuracy=...%
...
epoch 05/5: train_loss=..., validation_loss=..., validation_accuracy=...%
test ROC-AUC=..., PR-AUC=...
13. 如何理解结果
- 训练损失整体下降,表示电路正在学习训练记录;单轮略有波动是小批量训练和 Adam 的正常现象。
- 验证损失与训练损失同时下降通常是较健康的趋势。如果训练损失下降而验证损失持续上升,模型可能开始过度适应训练数据。
- 本例只有 100 条验证记录和 100 条测试记录,指标可能随数据划分明显波动,不应把一次运行结果当作生产结论。
- 10 量子比特的单个状态向量有 1024 个复数振幅,内存与计算量会随量子比特数按 增长。增加特征或电路层数前应评估运行成本。
14. 下一步
可以在此示例上继续尝试:
- 调整
EPOCHS、BATCH_SIZE或 Adam 学习率,观察训练稳定性和验证集表现。 - 把
LAYERS从 1 增加到 2,比较更多参数带来的表达能力和运行成本。 - 保存训练后的电路参数,并在独立测试程序中恢复和评估。