简介:面向量子机器学习与循环神经网络交叉应用的开发者,qlstm量子LSTM示例包展示了如何利用PennyLane框架构建量子版本的LSTM,并与PyTorch经典LSTM进行效果对比。资源包体仅84KB,共含10个文件,包括3个Python脚本(负责训练示例、量子电路搭建、辅助依赖下载)、1个Jupyter Notebook(词性标注实验)、3张训练过程对比图、2个依赖说明文本和1份README,结构精简,适合快速克隆后从虚拟环境配置到对比实验一气呵成。已有423人下载学习。读者可获得完整的量子LSTM环境配置指引、可直接复现的POS tagging示例,以及量子/经典模型在损失与准确率曲线上的直观对比;借助超参数调整与训练日志,还能进一步理解量子循环网络的状态编码、门控机制和收敛表现,是量子机器学习入门与复现实验的高性价比资料。
1. qlstm 是什么:一个量子 LSTM 的最小可跑示例
第一次跑通 qlstm 这个量子 LSTM 示例的 Notebook 时,我的第一反应是:它没有想象中那么“玄”,但也绝对不是换了个激活函数的 LSTM。它是一个把经典循环网络里的门控计算,替换成参数化量子电路的混合模型——输入先被编码成量子态,经过可训练电路后测量出数值,再送回经典循环结构里完成状态更新。这套结构适合两类人:一类是想验证“量子电路到底能不能参与序列建模”的研究者,另一类是已经在用经典 LSTM、想看看量子版本边界在哪里的工程人员。它不是生产级框架,而是一个能让你在半天内理解量子循环模型工作方式的起点。
2. 在 Jupyter Notebook 里把 qlstm 跑起来:依赖、版本与第一处参数改动
2.1 环境准备:Python 版本与两个核心库的搭配
这个示例的资源形态是 Jupyter Notebook,所以第一步不是写模型,而是把环境先立住。我拆过不少量子机器学习项目,踩得最多的坑都集中在依赖版本上。qlstm 这类项目通常依赖 PennyLane 作为量子计算前端,再用 PyTorch 或 TensorFlow 做经典自动微分。你打开 Notebook 后,第一件事应该是看它的 import 段:
import pennylane as qml from pennylane import numpy as np import torch import torch.nn as nn逻辑说明:PennyLane 负责定义量子节点和计算梯度,PyTorch 负责搭建经典神经网络层,两者通过torch接口连接。pennylane.numpy是一个包装过的 NumPy,它能记录操作轨迹,方便在量子电路内部做自动微分。
参数说明:这里最需要注意的是 PennyLane 的版本。早期版本(0.20 以下)的接口和现在的qml.RY、qml.CNOT在行为上没有大变化,但qml.qnode的装饰器参数有过调整。我一般用 0.30 以上的版本,并且建议把 PyTorch 固定在 2.0 以上,这样和 PennyLane 的torch接口配合更顺滑。如果你是第一次装,直接执行:
pip install pennylane torch jupyterlabpennylane会带起来autograd和numpy的依赖,torch则是后面搭建混合模型必须的。安装完成后,在终端运行python -c "import pennylane as qml; print(qml.__version__)"能正常输出版本号就说明基础环境没问题。
2.2 Notebook 里的三个关键 Cell:先跑通再改
打开 Notebook 后,你通常会在前几个 Cell 里看到量子设备的定义、电路的初始化,以及一个前向传播的函数。先别急着从头到尾运行一遍,我建议你把目光锁定在三个关键 Cell 上。第一个是设备创建:
dev = qml.device("default.qubit", wires=4, shots=None)逻辑说明:这句话创建了一个模拟 4 量子比特的量子设备。default.qubit是 PennyLane 自带的纯态模拟器,它不涉及真实硬件,而是用矩阵运算模拟量子态演化。shots=None表示使用解析期望值,而不是采样估计。在训练阶段,解析模式能让梯度计算更稳定,采样模式则更接近真实硬件的行为,但会引入统计噪声。
参数说明:wires=4决定了量子电路的宽度,也决定了状态空间的维度是 2^4=16 维。如果你把wires改成 6,状态空间会变成 64 维,表达能力增强,但参数数量和训练时间都会明显上升。对于示例级别的任务,4 个量子比特是一个折中的选择。
第二个关键 Cell 是量子节点的定义,它用@qml.qnode装饰。第三个 Cell 是混合模型的forward函数,它把量子电路的输出接到经典线性层上。这三个 Cell 搞明白后,你就可以在 Notebook 的末尾看到训练循环。第一次跑通时先别改任何超参数,直接全部运行一遍,把训练 loss 下降的曲线记录下来。这个“原始曲线”是你之后所有改动的对照基线。
2.3 训练前的版本检查清单
量子机器学习项目的环境问题有个特点:报错信息往往出现在模型定义之后,而且归因很远。为了避免在这种无关问题上浪费时间,我建议你在打开 Notebook 后先做一次环境自检,把下面这段写在一个空 Cell 里运行:
import pennylane as qml import torch dev = qml.device("default.qubit", wires=2) @qml.qnode(dev, interface="torch") def circuit(x): qml.RY(x, wires=0) qml.CNOT(wires=[0, 1]) return qml.expval(qml.PauliZ(0)) x = torch.tensor(0.5, requires_grad=True) y = circuit(x) y.backward() print("QNode output:", y.item()) print("Gradient:", x.grad.item())逻辑说明:这段代码定义了一个两比特量子节点,输入x通过 Ry 旋转门编码到第一个量子比特上,再用 CNOT 门把纠缠传到第二个比特,最后测量第一个比特的泡利 Z 算符期望值。interface="torch"告诉 PennyLane 我们要用 PyTorch 的自动微分链路。y.backward()触发反向传播,验证梯度能否从量子电路传导回经典参数x。
参数说明:如果这段代码能正常输出QNode output和Gradient,说明 PennyLane 与 PyTorch 的接口是通的,你可以放心往下跑。如果这里报错,问题几乎都在 PennyLane 和 PyTorch 的版本兼容上,常见做法是升级 PennyLane 或者降级 PyTorch 到 2.1 左右。这个检查动作虽然多花两分钟,但能省下后面排查模型不收敛的大把时间。
3. 量子 LSTM 的结构拆解:从经典门控到量子电路的映射
3.1 经典 LSTM 的门控在量子版本里被替换成了什么
要想理解 qlstm,你得先知道一个经典 LSTM 单元里到底有哪几个计算节点。经典的 LSTM 单元有三个门:遗忘门、输入门和输出门,外加一个候选记忆状态。每个门都是一个带 sigmoid 激活的线性变换,数学形式是:
f_t = sigmoid(W_f · [h_{t-1}, x_t] + b_f) i_t = sigmoid(W_i · [h_{t-1}, x_t] + b_i) o_t = sigmoid(W_o · [h_{t-1}, x_t] + b_o) c_tilde_t = tanh(W_c · [h_{t-1}, x_t] + b_c)这些式子中的权重矩阵 W 和偏置 b 是经典可训练参数。在 qlstm 里,一种直接的做法是保留外部这个门控结构,但把计算W_f · [h_{t-1}, x_t] + b_f这一步替换成一个参数化量子电路。也就是说,经典 LSTM 的每个门对应一个独立的线性变换;量子 LSTM 的每个门对应一个量子电路加一个测量输出。电路的输出是一个标量期望值,再经过 sigmoid 或 tanh 缩放后,作为门控信号参与后续的状态更新。
这个替换不是随意的,它背后有一个合理的逻辑:经典线性变换本质上是把输入向量映射到另一个向量空间,而量子电路在 2^n 维的 Hilbert 空间里做幺正演化,它提供了一种非线性的、可能更紧凑的表示方式。当然,代价是量子电路的输出维度受量子比特数限制,所以它更适合替代隐藏层宽度较小的门控计算,而不是替代整个大模型。
3.2 量子电路内部:数据重上传编码、变分层与测量
我拆过几个量子 LSTM 的开源实现,它们在电路设计上通常遵循三段式结构。第一段是数据编码,第二段是变分层,第三段是测量。数据编码常用“角度编码”,就是把输入向量的每个分量归一化后作为旋转门的旋转角度。变分层一般用“强纠缠层”,即每个量子比特先做自身的旋转门,再通过 CNOT 门互相纠缠。测量则是在特定量子比特上测泡利 Z 算符的期望值。
一个典型的电路代码如下:
def qlstm_circuit(inputs, weights, wires): # 数据重上传编码:把输入向量的每个分量映射为 Ry 旋转角 for i in range(len(wires)): qml.RY(inputs[i], wires=wires[i]) # 变分层:堆叠两层强纠缠电路 for layer in range(weights.shape[0]): for i in range(len(wires)): qml.RY(weights[layer, i], wires=wires[i]) for i in range(len(wires) - 1): qml.CNOT(wires=[wires[i], wires[i + 1]]) # 测量:返回每个量子比特的 PauliZ 期望值 return [qml.expval(qml.PauliZ(wires[i])) for i in range(len(wires))]逻辑说明:inputs是当前时刻的输入向量,长度为量子比特数;weights是形状为(层数, 量子比特数)的可训练参数。编码阶段把输入压缩到量子态的旋转角上,变分层通过 Ry 和 CNOT 的组合让各个量子比特之间产生纠缠,最后测量泡利 Z 的期望值得到一个向量。这个向量会被送到后面的经典层继续处理。
参数说明:为什么用 Ry 而不是 Rx 或 Rz?因为 Ry 旋转门对角度的变化能同时改变量子态在两个基态上的概率幅,作为编码门能更好地利用输入信息。变分层的层数是一个关键超参数:1 层电路表达能力很弱,往往只能学到线性的映射;2 到 3 层在示例任务上已经够用;超过 4 层训练时间显著增加,但精度提升有限。我一般把变分层数设为 2,先把流程跑通,再根据训练曲线的收敛速度决定要不要加深。
3.3 量子输出怎么接回经典 LSTM 单元
量子电路输出的是期望值向量,每个分量都落在 [-1, 1] 区间内,这个区间范围如果不处理,会和经典 LSTM 里的门控信号产生分布偏移。常见做法是在量子电路后面接一个经典线性层,把向量从[-1, 1]映射到合适的范围,再接 sigmoid 或 tanh。这一层很薄,通常只是一个nn.Linear(量子比特数, 隐藏层宽度),但它能把量子输出“翻译”成经典 LSTM 单元能消费的信号。
如果不加这个线性层,你会发现训练初期的 loss 很难下降,因为量子电路的输出分布和门控信号的期望分布差异太大。这个线性层的参数数量很少,不会破坏模型的整体可训练性,但它往往是整个模型从“跑得动”到“收敛好”的关键一步。
4. 训练与调试:学习率、损失函数与量子参数的实践值
4.1 混合模型的训练循环怎么写
qlstm 的训练循环和经典 LSTM 的差别不大,你仍然需要定义损失函数、优化器和迭代循环。但有两个细节必须注意:一是量子电路的参数要放在同一个优化器里管理,二是每次前向传播时量子电路都会重新执行,这比纯经典模型要慢得多。
下面是一个最小训练循环的示意代码:
import torch import torch.nn as nn # 假设 qlstm_model 是已经定义好的量子 LSTM 模型 # 它内部包含量子电路参数 q_params 和经典参数 c_params optimizer = torch.optim.Adam(qlstm_model.parameters(), lr=0.02) loss_fn = nn.MSELoss() for epoch in range(100): optimizer.zero_grad() # 输入序列和真实标签 inputs = torch.randn(32, 8, 4) # (batch, seq_len, input_size) targets = torch.randn(32, 4) # (batch, output_size) outputs, _ = qlstm_model(inputs) # 模型前向传播 loss = loss_fn(outputs, targets) loss.backward() optimizer.step() if epoch % 10 == 0: print(f"Epoch {epoch}, Loss: {loss.item():.4f}")逻辑说明:qlstm_model.parameters()会把量子电路的参数和经典层的参数一起交给优化器管理,你不需要手动区分它们。前向传播时,模型内部会自动调用 PennyLane 的量子节点,计算量子电路的输出,然后和经典层一起完成整个序列的前向计算。loss.backward()的梯度会穿过量子电路,利用参数偏移规则或 PennyLane 的自动微分机制更新量子参数。
参数说明:学习率lr=0.02是我在这个示例上试出来的一个安全起点。量子参数和经典参数对学习率的敏感度不同:经典参数可以承受更大的学习率,而量子参数如果学习率太大,容易在参数空间里震荡不收敛。我一般会分别给量子参数和经典参数设置不同的学习率,或者使用param_groups在 Adam 里分配不同的lr。例如:
optimizer = torch.optim.Adam([ {"params": qlstm_model.quantum_params, "lr": 0.01}, {"params": qlstm_model.classical_params, "lr": 0.05} ])这比统一学习率更可控,尤其是当你发现 loss 在某个值附近来回震荡时,优先调小量子参数的学习率。
4.2 损失函数与任务类型的匹配
qlstm 示例里通常是做时间序列预测或序列分类。如果是时间序列预测,MSELoss是标准选择;如果是二分类,建议用BCEWithLogitsLoss。我见过有人拿这个模型做多步预测,但效果并不好,根本原因是量子电路的输出维度有限,它不太适合生成高维的序列分布。更合适的用法是把 qlstm 当成一个编码器,提取序列特征,再接一个经典分类头。
# 二分类任务的损失函数 loss_fn = nn.BCEWithLogitsLoss()逻辑说明:BCEWithLogitsLoss把 sigmoid 和交叉熵合并在一起计算,数值上更稳定。如果你在模型最后一层没有加 sigmoid,这个损失函数可以直接使用原始 logits,内部会自动处理。
参数说明:outputs的形状必须和targets匹配。如果模型最后一层输出维度是 1,你需要确保targets.squeeze()去掉多余的维度,否则 PyTorch 会报形状不匹配的错。这是一个非常常见的踩坑点。
4.3 训练曲线怎么看:量子模型的收敛特征
量子混合模型的训练曲线和纯经典模型有一个显著差异:它前期收敛较快,但后期容易出现 loss 平台。这不是 bug,而是量子电路结构固定后,模型的有效表达能力存在上限。当 loss 下降变缓时,我一般按这个顺序排查:先看学习率是不是太大导致参数在最小点附近震荡,再看经典线性层是否太窄限制了输出表达,最后才考虑增加量子电路层数。排序的依据是,改动经典层比加深量子电路成本低得多,而且很多时候瓶颈根本不在量子部分。
另一个我在实践中发现的细节是:量子模型的初始 loss 往往比经典 LSTM 高一些,这是因为量子电路的输出在被线性层“翻译”之前,分布和任务目标完全无关。如果你的初始 loss 低到离谱,反而要警惕,可能是模型退化成只依赖经典层而忽略量子电路了。
5. 量子 LSTM 避坑指南:五个让训练失败的隐蔽问题
5.1 梯度消失的假象:量子输出的方差坍缩
现象:训练了十几个 epoch,loss 几乎不动,打印每层的梯度发现量子参数的梯度数量级在 1e-6 以下。
原因:量子电路变分层过深或层数过多时,输出期望值可能趋近于某个固定值,尤其是当所有参数初始化为零或接近零时,电路输出的方差会坍缩,梯度跟着消失。这和经典深度网络里的梯度消失类似,但量子电路还多了一个角度周期性的影响。
解决:把变分层数从 3 层降到 2 层;把量子参数的初始化范围从均匀分布[-0.5, 0.5]扩大到[-pi, pi]。这样能避免所有旋转门初始角度几乎相同的问题。我在实现里偏好用np.random.uniform(-pi, pi)初始化量子参数,效果比小范围初始化稳定不少。
5.2 梯度爆炸的另一种形态:测量结果接近边界
现象:loss 某一步突然变成 NaN,重新运行又可能恢复,但一跑到同一位置就崩溃。
原因:量子测量值落在 [-1, 1] 区间,本身不会爆炸,但经典线性层会把测量值放大。如果某个测量值恰好接近 1 或 -1,经过线性层和后续 tanh 的梯度链,数值可能溢出。这在时间序列较长、序列长度超过 30 步时更容易出现。
解决:在经典线性层后面加一个 LayerNorm,把输出拉回均值为零、方差为一的分布。这比 ClipGradient 更优雅,因为它不改变梯度的方向,只调整尺度。另一个做法是把量子测量值先做一次tanh缩放,把 [-1, 1] 的边界稍微压平一点,让梯度在边缘区域不至于过大。
5.3 数据编码的归一化陷阱
现象:训练时很多时候输入向量数值很大,结果量子电路输出几乎不随输入变化,模型学不到任何时间相关性。
原因:角度编码时,如果输入值超过 pi 或者远大于 2pi,旋转门会多次绕圈,输入的变化被周期性掩盖。量子旋转门的角度是周期函数,数值 3.0 和 3.0 + 2pi 在电路里是等价的。如果你的输入序列是真实传感器数据,数值范围可能是 0 到 100,直接编码必然失效。
解决:在输入进入量子电路前,做一个 Min-Max 归一化,把每个特征缩放到 [-pi/2, pi/2] 或 [0, pi] 区间。我一般这样写:
def normalize_inputs(x, min_val, max_val): # 把输入从 [min_val, max_val] 映射到 [-pi/2, pi/2] normalized = (x - min_val) / (max_val - min_val) * np.pi - np.pi / 2 return normalized逻辑说明:归一化保证了输入角度落在旋转门灵敏度最高的区间内,避免出现周期等价问题。min_val和max_val从训练集的统计量中计算,不要用测试集的数据,否则会引入信息泄漏。
参数说明:区间的两端选择并非越宽越好。[-pi/2, pi/2] 是 Ry 门变化最灵敏的区域,超出这个区间灵敏度会下降。实际使用时,你可以先统计训练集每个特征的真实范围,再做线性映射,这就是一个标准 Min-Max 的过程。
5.4 量子设备的内存与性能瓶颈
现象:Notebook 跑了几十个 epoch 后,内存占用持续上升,最后计算机变得非常卡顿。
原因:PennyLane 的default.qubit模拟器在每次电路求值时会创建新的状态向量。对于 10 个及以上的量子比特,状态向量长度为 2^10,已经是 1024 个复数,GPU 版本的内存压力还不大,但从 12 比特开始,状态向量膨胀为 4096 个复数,CPU 版本每次前向和反向都要付出高昂的计算代价。更隐蔽的是,如果你在循环里重复创建设备或模型对象,Python 的垃圾回收机制来不及释放不再引用的对象,内存就持续上涨。
解决:把量子比特数控制在 8 以内,这是一个实践上的安全边界。同时,训练循环外创建模型和设备,训练循环内只做前向、反向和优化器更新,不重新创建任何对象。如果你确实需要更多量子比特,考虑换用带 GPU 加速的模拟器,或者在每次 epoch 结束后手动调用torch.cuda.empty_cache()。
5.5 量子电路退化成经典层的辅助工具
现象:训练结束后,把量子电路的参数固定住不更新,只训练经典层,最终 loss 和完全训练整个模型的结果几乎一样——这意味着量子电路根本没在起作用。
原因:当经典线性层足够宽时,它可以仅靠自己拟合大部分目标函数,量子电路的贡献被“惰性”地忽略了。这是一个很隐蔽的结构退化,模型看起来在收敛,实际上量子部分已经失效。
解决:训练到一半时,打印量子参数的梯度范数:
total_grad = sum(p.grad.norm().item() for p in qlstm_model.quantum_params) print(f"Quantum grad norm: {total_grad:.6f}")如果这个值长期小于经典参数梯度的百分之一,说明量子部分没有有效参与学习。解决方法是缩减经典层的宽度,或者在每次前向传播时对量子参数加入一个小的正则扰动,强制优化器注意到它们的更新方向。我在实际项目中更倾向于缩减经典层宽度,因为正则扰动只是掩盖问题,而不是解决问题。
6. 进阶验证:确认量子电路真的在起作用
到了这一步,你已经能跑通整个 Notebook,也经历了若干次训练和调参。但在把这个模型当作结论之前,我建议你花一个小时做一组对照实验,专门验证量子电路是否真的在为模型提供独特的计算能力。这个验证不会写进任何教程,却是我每次搭建量子机器学习模型之后都要做的事情。
第一个对照是固定量子参数:随机初始化一组量子参数后,把它们冻结住,只训练经典层。如果模型仍然能达到和完整训练时相近的性能,说明量子电路只是一个固定的特征变换,可以被任何经典非线性层替代。第二个对照是随机关闭部分 CNOT 门。把电路中一半的 CNOT 门替换成独立旋转门,相当于降低量子比特之间的纠缠程度,然后观察 loss 是否明显变差。如果性能没有变化,说明你的任务可能根本不依赖纠缠,量子电路的“量子优势”就没有被激活。
# 随机关闭 50% CNOT 门的简单实现 def drop_cnots(circuit, keep_prob=0.5): if "CNOT" in circuit.operations: return False # 简化示意,实际需要按量子比特对随机决定 return True这个实验的真实含义是:量子 LSTM 适合的任务,通常是那些经典 LSTM 需要用较大隐藏层才能表达好的问题。如果你的问题用一个 16 维隐藏层的经典 LSTM 就能解决得很好,那量子电路的 4 比特版本不会有绝对优势。它不是替代品,而是一个需要刻意设计任务才能发挥作用的工具。
我印象很深的一次是,模型训练完读出来的效果相当好,但把量子参数固定后性能几乎不变,这意味着那一次跑通的“量子 LSTM”实际上是一个自带随机映射的经典网络。从那以后,我每次在 Notebook 里跑完训练,都会强制把量子参数的更新开关关掉再跑一遍。这个 10 分钟的对比能让你清楚地知道,自己是在训练一个混合模型,还是在安慰自己。希望这份拆解能帮你少走几步弯路。
本文还有配套的精品资源,点击获取