构建量子神经网络分类器

适用版本 · ArcQML 0.1.0

本篇展示关键代码片段,完整程序和数据随 ArcQML 发行包提供。配对代码框内的 Rust / Python 按钮会同步切换全篇示例,并在 VQE、QNN 之间记住选择;单段代码和命令保持显示。

本页目录

ArcQML 是一个用 Rust 语言原生实现的量子机器学习框架,提供了量子电路构建、状态向量模拟、可观测量期望值计算、自动微分和参数优化等核心能力。框架既支持单个量子态的计算,也支持批量状态模拟,并可使用 Pauli 算符及其线性组合表示可观测量。量子电路计算得到的期望值以可微分张量形式输出,可以继续参与损失函数及其他经典可微运算;执行反向传播时,梯度能够沿完整计算图传递至量子电路参数。对于含参量子电路,ArcQML 可以利用伴随法计算电路参数梯度,再与 Adam、SGD 等经典优化器配合完成混合量子—经典算法的训练。

这些能力可以用于变分量子本征求解、量子神经网络、量子态分析和小规模酉矩阵综合等任务。由于 Rust 语言具备原生编译、内存安全和高效并行等特性,ArcQML 能在保持可靠性的同时提供良好的计算性能。

本教程使用 ArcQML 实现一个完整的二分类任务,选取德国信用数据集中的 10 个特征,预测申请者是否属于“信用不良”类别。教程将依次说明数据预处理、量子态编码、构造量子电路与制备量子态、计算二元交叉熵损失、参数优化以及验证和测试。完整程序分别位于 examples/rust/qnn_german_credit.rsexamples/python/qnn_german_credit.py

完整数据集位于 examples/data/german_credit.csv

1. 准备运行环境

请先按安装与环境配置准备发行包和运行环境。Rust 用户完成 Rust 与 Runtime 配置;Python 用户完成 wheel 安装即可,无需安装 Rust 或设置 Runtime 环境变量。

2. QNN 拟解决的问题

2.1 从经典数据到二分类结果

二分类是一类监督学习任务,其目标是根据输入特征学习一个判别函数,将样本划分到两个互斥类别之一。数据集中的 Creditability 字段将每条信用记录分为:

在本教程中,量子神经网络(QNN)被构造为参数化二分类模型。每条记录首先经过预处理并编码成特定的量子态;随后,不同样本通过同一条可训练的含参量子电路。电路末端测量第 5 号量子比特的 Pauli-Z 期望值,并将该连续值作为二分类的分类分数 (logit)。训练阶段以真实标签作为监督信号,通过最小化二元交叉熵损失更新量子电路参数,使模型输出更符合训练数据的类别分布,最终的预测类别会与真实类别逐渐一致。

2.2 数据与教学配置

数据文件:examples/data/german_credit.csv,随 ArcQML 代码仓库提供。

本示例使用的德国信用数据集包含 1000 条申请人的信用记录,通过账户状况、还款历史、贷款用途、就业情况和资产状况等特征,将申请人划分为信用良好与信用不良两类。教学示例采用以下配置:

配置 数值 作用
量子比特数 10 每个特征对应一个量子比特。
状态向量长度 210=10242^{10}=1024 10 量子比特量子态包含 1024 个复数振幅。
QNN 层数 1 控制可训练电路的深度。
每层参数数 37 由本例的旋转门结构决定。
训练/验证/测试 800/100/100 分别用于训练、调试观察和最终评估。
每批样本数 100 每次共同计算损失并更新参数的样本数。
训练轮数 5 完整遍历训练集 5 次。
Adam 学习率 0.01 控制每次参数更新的总体幅度。
随机种子 4 让同一语言中的数据划分和初始化可以复现。

程序选取以下 10 个特征:Account BalancePayment Status of Previous CreditPurposeValue Savings/StocksLength of current employmentGuarantorsMost valuable available assetConcurrent CreditsType of apartmentNo of Credits at this Bank,每个特征对应一个量子比特。

3. 初始化设置

首先导入 ArcQML、文件读取和数值计算所需的接口:

Rust / Python
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

示例中将主要数值集中声明为常量:

Rust / Python
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

Rust / Python
// 读取标签列
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 缩放:

x=π2+xxminxmaxxminπ.x'=\frac{\pi}{2}+\frac{x-x_{\min}}{x_{\max}-x_{\min}}\pi.

其中 xminx_{\min}xmaxx_{\max} 是当前特征列在全部 1000 条记录中的最小值和最大值。转换后,每个特征都落在 [π/2,3π/2][\pi/2,3\pi/2],可以直接作为 RXRZ 门的旋转角。

Rust / Python
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
)

如果某一列所有值都相同,就会出现 xmaxxmin=0x_{\max}-x_{\min}=0。本例选取的 10 列不存在该情况;如需替换特征列时应先检查并排除常量列,或为它们单独指定固定角度进行编码。

4.3 集合划分

程序先打乱记录,再按训练集:验证集:测试集以 8:1:1 的比例划分:

Rust / Python
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 个缩放后的角度 x0,x1,,x9x'_0,x'_1,\ldots,x'_9。本例在第 ii 个量子比特上先施加 RX(x'_i),再施加 RZ(x'_i)。这种把经典数值作为量子门角度的方式称为角度编码

单个量子比特从 0\lvert0\rangle 出发,编码后为:

RZ(x)RX(x)0=eix/2cos(x/2)0ieix/2sin(x/2)1.R_Z(x')R_X(x')\lvert0\rangle= e^{-ix'/2}\cos(x'/2)\lvert0\rangle -i e^{ix'/2}\sin(x'/2)\lvert1\rangle.

10 个量子比特在编码阶段互不纠缠,因此整条记录的编码结果是 10 个局部量子态的张量积。每条记录最终得到一个长度为 210=10242^{10}=1024 的复数状态向量。

5.2 量子态制备

为了防止将 RX(x'_i)RZ(x'_i) 直接加入可训练电路导致 ArcQML 将这些角度也登记成参数,示例预先计算与这些门完全等价的量子态编码结果,再让整组样本共用一条只包含 37 个可训练参数的电路。

Rust / Python
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 从而调整示例中的电路层数。

Rust / Python
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)

电路结构如下,可左右滚动查看完整电路:

10 量子比特单层 QNN:37 个旋转门、15 个 CNOT 门,最后测量 q5 的 Pauli-Z 期望值

6.2 初始化角度

本示例从均值为 0、标准差为 1 的标准正态分布生成 37 个可训练角度。

Rust / Python
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 是本例电路结构的固定设计,因为它聚合了所有特征的全部信息。

Rust / Python
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 项的系数
)

对第 ii 个样本,模型输出为:

zi=ψ(xi,θ)Z5ψ(xi,θ).z_i= \langle\psi(x'_i,\boldsymbol{\theta})\rvert Z_5 \lvert\psi(x'_i,\boldsymbol{\theta})\rangle.

其中 xix'_i 表示输入的编码后特征,θ\boldsymbol{\theta} 表示电路中的可训练角度。Pauli-Z 期望值位于 [1,1][-1,1],本例把 ziz_i 直接作为二分类的 logit,再通过 sigmoid 函数解释为正类概率:

pi=σ(zi)=11+ezi.p_i=\sigma(z_i)=\frac{1}{1+e^{-z_i}}.

zi0z_i\ge0 等价于 pi0.5p_i\ge0.5,因此预测为信用不良;zi<0z_i<0 则预测为信用良好。

8. 损失函数定义

损失函数把一组预测和已知标签归纳成一个需要最小化的数值。本例使用带 logit 的二元交叉熵。对于包含 NN 条记录的一批数据,损失为:

L=1Ni=1N[yilogσ(zi)+(1yi)log(1σ(zi))].\mathcal{L}=-\frac{1}{N}\sum_{i=1}^{N} \left[ y_i\log\sigma(z_i) +(1-y_i)\log\left(1-\sigma(z_i)\right) \right].

如果标签 yi=1y_i=1,损失会推动 ziz_i 增大;如果 yi=0y_i=0,损失会推动 ziz_i 减小。

Rust / Python
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 状态向量模拟器

Rust / Python
// 从给定状态创建模拟器
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 会为每个参数分别记录近期梯度的平均趋势和梯度平方的平均趋势,据此自动调节每个参数的实际更新幅度。

Rust / Python
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. 批量前向计算与反向传播

一次训练迭代包含四个步骤:

  1. 前向计算当前损失值。
  2. 从损失值反向计算所有参数的梯度。
  3. Adam 根据梯度更新参数。
  4. 清空本轮梯度,避免它们与下一轮结果累加。
Rust / Python
// 本部分为伪代码,仅供演示

// 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. 验证与测试

验证和测试只读取模型输出,不更新参数,因此应关闭梯度记录。这样可以减少不必要的计算过程记录和内存占用:

Rust / Python
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 终端中执行:

text
cargo run --release -p arcqml --example qnn_german_credit

12.2 运行 Python 版本

先按照安装页的 Python 配置说明安装 wheel 并激活 Conda 环境,再从 ArcQML 根目录执行:

text
conda activate arcqml-example
python examples/python/qnn_german_credit.py

12.3 输出示例

两种程序都会显示数据划分、参数数量、每轮训练和验证结果,以及最终测试集 AUC:

text
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. 如何理解结果

14. 下一步

可以在此示例上继续尝试: