量子LSTM实战:门控机制映射到参数化量子电路的工程实现

发布时间:2026/10/25 19:30:01
量子LSTM实战:门控机制映射到参数化量子电路的工程实现

简介:qlstm 示例包含一套完整的量子 LSTM(长短期记忆网络)实验代码,面向希望入门量子机器学习或对比量子/经典循环网络的算法研究者与学生。压缩包内提供基于 PennyLane 的量子 LSTM 模型、基于 PyTorch 的经典 LSTM 对比实现,以及用于词性标注任务的 Jupyter Notebook 演示,可运行后直接查看经典与量子模型在相同超参数下的训练损失曲线图,直观对比两者的收敛特征。除核心模型外,还附带 README.md 说明、requirements.txt 依赖清单和下载/预处理辅助脚本,帮助快速搭建环境、复现实验并开展参数调整。整体为 zip 包,共 10 个文件,以 Python 源码、Notebook、PNG 图片、Markdown 和 TXT 文档为主,压缩包仅 84KB,轻量而完整。已有 423 人浏览学习,适合作为量子 LSTM 研究的起步模板、课题对比基线或课程设计参考。

1. qlstm:量子LSTM的示例项目,不只是把“LSTM”前面加个“量子”

第一次拆这个qlstm示例时,我本以为它就是把经典LSTM里的矩阵乘法换成量子线路——毕竟公开代码里这类“量子版XX”大多是借了个概念做个demo。但翻完Jupyter Notebook里的实现才发现,它真正做的是把LSTM的门控机制整体映射到参数化量子电路(PQC)上,用测量期望值替代门控向量的计算,再用经典层包一层输出。这个思路对做时序预测、NLP特征提取的从业者来说,价值在于:你可以直接看到量子LSTM和经典LSTM在结构上的对应关系,而不是被“量子优势”这种大词带着走。这份资源适合两类人:一是已经跑过经典LSTM、想低成本入门量子机器学习怎么落地的人;二是需要在教学或技术方案里快速搭一个可演示的量子时序模型、但不想从零写线路的人。它能帮你解决的核心问题是:量子门控单元到底怎么构建、怎么训练、以及在模拟器上跑通一个最小闭环需要哪些设计决策。

2. 量子LSTM的门控映射:把遗忘门、输入门、输出门换成测量期望值

2.1 经典LSTM的门控为什么能被量子电路替代

经典LSTM的核心计算是三个门控向量:

  • 遗忘门:f_t = σ(W_f · [h_{t-1}, x_t] + b_f)
  • 输入门:i_t = σ(W_i · [h_{t-1}, x_t] + b_i)
  • 输出门:o_t = σ(W_o · [h_{t-1}, x_t] + b_o)

这些门控本质上是对拼接后的向量做线性变换再加σ激活。量子电路里的参数化线路也能做类似的事:把x_t和h_{t-1}编码成量子态,经过一组带旋转角参数的量子门,最后测量某个泡利算符的期望值。期望值落在[-1, 1]区间,经过平移缩放后和σ的输出范围[0, 1]非常接近。这个映射关系是这个qlstm示例的立身之本。

具体到这个项目的设计选择,门控单元用了角度编码(angle encoding),因为输入特征是几维到几十维的中低维数据,角度编码线路深度相对浅,模拟器上跑得动。如果是高维数据或者图像块序列,一般会考虑幅度编码,但那个线路深度和归一化要求高很多,不适合做入门示例。

2.2 Notebook里的量子门控单元是怎么构建的

拆开这个qlstm的Notebook,核心组件是一个自定义的量子门控层。它没有用完整的量子LSTM论文里的复杂结构,而是做了工程化简化:

import pennylane as qml import torch import torch.nn as nn n_qubits = 4 # 量子线路的比特数,对应输入维度的一部分映射 n_qlayers = 2 # 参数化量子电路的层数,增加表达力 q_depth = 6 # 每层内的门深度,控制纠缠能力 n_gates = 3 # LSTM三个门:遗忘、输入、输出 dev = qml.device("default.qubit", wires=n_qubits) @qml.qnode(dev, interface="torch", diff_method="adjoint") def quantum_gate(inputs, weights): # 角度编码:把输入向量的每个分量旋转到对应比特上 for i in range(n_qubits): qml.RY(inputs[i], wires=i) # 参数化纠缠层 for layer in range(n_qlayers): for i in range(n_qubits - 1): qml.CNOT(wires=[i, i + 1]) for i in range(n_qubits): qml.RY(weights[layer, i], wires=i) # 测量前两个比特的期望值,作为门控信号的原始值 return [qml.expval(qml.PauliZ(i)) for i in range(2)]

这段代码逻辑上很清晰:先把输入向量约束到4维(因为线路固定为4比特),用RY门做角度编码,然后用CNOT搭纠缠、用可训练的RY参数做变换,最后测量两个比特的Z期望值,作为门控计算的中间量。

需要注意几点:

  • 输入为什么是4维:量子线路的比特数决定了一次能编码的特征维度。示例里取4,是因为mnist这类简单时序输入降维后4维够用。如果你的特征是10维,可以把n_qubits调到10,或者保留4比特、分批编码。
  • 为什么用adjoint微分而不是parameter-shift:adjoint方法在模拟器上计算梯度更快,但显存占用更高。你如果在自己的机器上爆显存,可以换成diff_method="parameter-shift",速度慢一些但更稳。
  • CNOT连法用了线性链(i到i+1),这是最保守的纠缠拓扑。你可以在不改变整体结构的情况下换成环形连接(最后一比特和第一比特也做CNOT),有些任务上效果会好一点。

2.3 三个门的参数是共享还是独享

这是我看这个示例时比较关注的设计点:门控信号从量子线路里出来之后,要不要给三个门各配一套量子线路参数。

class QLSTMCell(nn.Module): def __init__(self, input_size, hidden_size, n_qubits=4): super().__init__() self.input_size = input_size self.hidden_size = hidden_size # 三个门共享同一个量子线路骨架,用不同权重实例化 self.q_weights_f = nn.Parameter(torch.randn(n_qlayers, n_qubits) * 0.1) self.q_weights_i = nn.Parameter(torch.randn(n_qlayers, n_qubits) * 0.1) self.q_weights_o = nn.Parameter(torch.randn(n_qlayers, n_qubits) * 0.1) # 经典输出层 self.out_layer = nn.Linear(hidden_size, hidden_size) def forward(self, x, h_prev, c_prev): # x 经过线性变换到4维,拼接 h_prev 后进入量子线路 combined = torch.cat((x, h_prev), dim=-1) combined = self.input_proj(combined) # 降维到 n_qubits 维度 f_gate = torch.sigmoid(quantum_gate(combined, self.q_weights_f)) i_gate = torch.sigmoid(quantum_gate(combined, self.q_weights_i)) o_gate = torch.sigmoid(quantum_gate(combined, self.q_weights_o)) c_new = f_gate * c_prev + i_gate * torch.tanh(self.candidate_layer(combined)) h_new = o_gate * torch.tanh(c_new) return h_new, c_new

这里的关键决策是:三个门各配一套量子权重,而不是共享一套再线性变换。原因是纠缠线路的输入输出关系高度非线性,三个门如果共享参数,梯度更新时彼此干扰会很大,训练反而更难收敛。代价是参数数量变成三倍,但在这个规模下(2层×4比特)总共才24个量子参数,远小于经典LSTM的矩阵参数量。

2.4 为什么测量结果要接sigmoid而不是直接用

量子线路输出的期望值在[-1, 1]之间,如果你直接把它当代价函数的输入,数值范围还凑合,但作为LSTM的门控值,负值会让细胞状态出现不稳定衰减。示例里对所有量子输出做了一个简单的仿射变换,再接sigmoid,相当于把期望值从[-1, 1]映射到[0, 1]:

def quantum_gate_activated(inputs, weights): raw = quantum_gate(inputs, weights) # 期望值在 [-1, 1] # 平移缩放:把值域映射到 [0, 1] 附近,sigmoid 再压缩 scaled = 0.5 * (raw + 1) return torch.sigmoid(scaled)

不要小看这一步,如果你直接把原始期望值灌给细胞状态更新公式,C经过几轮迭代后符号会来回翻转,loss曲线直接发散。这也是很多复现量子LSTM翻车的第一大原因。

3. 从Notebook到可训练模型:数据编码、降维与梯度计算

3.1 输入数据怎么进量子线路

从2.2节的代码可以看出,量子线路接收的是一个n_qubits维的向量。但在实际时序任务里,输入特征通常是几十维,比如经济指标、传感器读数、词向量片段。这个qlstm示例的做法是先加一层经典线性降维,再接量子线路:

self.input_proj = nn.Linear(input_size + hidden_size, n_qubits)

也就是把上一时刻的隐藏状态h_{t-1}和当前输入x_t拼接成一个向量,然后线性压缩到4维。这个设计的合理性在于:角度编码线路的表达能力受比特数限制,特征维度超过比特数时就只能丢弃信息,第一次做线性压缩等效于一个可训练的“特征筛选器”。

这里要留意的是:降维层和量子线路之间的梯度流是连续的,因为降维层是经典线性层,反向传播梯度可以从量子节点的输出一路传回降维层的权重。这是Pennylane的Torch接口自动处理的,你不需要手工写梯度传播逻辑。

3.2 参数初始化的玄学

量子线路的旋转角权重如果初始值设得太大,比如N(0, 1),初始测量期望值会分布得很散,经过sigmoid后门控值饱和在0或1附近,梯度直接消失。这个qlstm示例里用了0.1倍缩放初始化,这是我在实际试跑后认为最有价值的细节之一。

self.q_weights_f = nn.Parameter(torch.randn(n_qlayers, n_qubits) * 0.1)

原因:参数化量子电路的旋转角权重直接决定线路输出的梯度幅度。初始权重过大,线路工作在非线性的饱和区,梯度趋近于0;初始权重过小,输出接近某个固定值,同样梯度微弱。0.1这个量级让初始期望值大致分布在[-0.3, 0.3]区间,恰好处于敏感区。

3.3 batch训练时的量子线路状态管理

用Jupyter Notebook跑实验时有一个容易被忽略的坑:Pennylane的QNode默认是“每次调用重新构建线路”,所以单个样本的推理没有问题。但如果你按batch方式训练,比如一个batch 32条序列,你不能直接把32个样本同时传入QNode,因为线路构建是基于单个样本的。

# 错误示范:直接批量传入 # q_out = quantum_gate(batch_inputs, batch_weights) # 会报维度错误 # 正确做法:循环单样本调用,或用 qml.map_batch q_outs = [] for sample in batch_inputs: q_outs.append(quantum_gate(sample, weights)) q_outs = torch.stack(q_outs)

这个限制源于量子模拟器的状态向量机制:每次计算期望值时,整个量子态坍缩到测量基上,不同样本之间没有天然并行通道。你可以在Notebook里先把batch循环封装成一个函数,后续调用就不需要反复处理维度问题。

3.4 梯度计算方式选择与显存控制

在Jupyter里训练量子模型,最直观的梯度方式是parameter-shift,它对任何参数化量子线路都适用,不需要额外内存缓存中间态。但速度偏慢,因为每个参数需要额外计算两次线路期望值。qlstm示例用的是adjoint微分,它在反向传播时复用了前向传播的量子态,只计算一次伴随传播,速度更快。

dev = qml.device("default.qubit", wires=n_qubits) quantum_gate = qml.QNode( quantum_gate, device=dev, interface="torch", diff_method="adjoint" )

实际使用中,如果你发现显存或内存爆炸,优先把adjoint改成parameter-shift。代价是训练时间可能翻3到5倍,但至少不会OOM。如果你的线路深度超过6层、比特数超过8个,模拟器计算开销会指数上升,建议换回小规模配置先验证收敛性。

4. 训练一个最小可用的量子时序模型:里面的完整流程

4.1 数据准备:用简单正弦叠加而非复杂真实数据

这个qlstm示例给了足够的数据预处理模板,但我建议第一次跑的时候用合成数据,比如两个不同频率正弦波的叠加信号。原因有两点:合成数据标准答案明确,你能判断模型是否真的学到了时序规律;真实数据集(比如股票、传感器)噪声和维度问题会和技术坑混在一起,出了问题很难定位。

import numpy as np import matplotlib.pyplot as plt t = np.linspace(0, 20, 800) data = np.sin(0.5 * t) + 0.5 * np.sin(2.0 * t + 0.3) # 滑动窗口切分 seq_len = 8 X, Y = [], [] for i in range(len(data) - seq_len): X.append(data[i:i + seq_len]) Y.append(data[i + seq_len]) X = np.array(X).reshape(-1, seq_len, 1) Y = np.array(Y).reshape(-1, 1)

这里seq_len=8意味着模型每次看到8个历史时间步,预测第9步。太长的序列会加大量子线路的输入拼接维度,这个示例的4比特角度编码下,seq_len在4到12之间是安全区间。

4.2 从cell到完整模型:循环展开与输出层

单步QLSTM cell搭好后,要把它展开成一个多步模型。这个示例里没有用PyTorch自带的nn.LSTM循环展开机制,而是手动写了一个简单循环:

class QLSTM(nn.Module): def __init__(self, input_size, hidden_size, output_size, n_qubits=4): super().__init__() self.cell = QLSTMCell(input_size, hidden_size, n_qubits) self.output_layer = nn.Linear(hidden_size, output_size) def forward(self, x): batch_size, seq_len, _ = x.shape h = torch.zeros(batch_size, self.cell.hidden_size) c = torch.zeros(batch_size, self.cell.hidden_size) for t in range(seq_len): h, c = self.cell(x[:, t, :], h, c) out = self.output_layer(h) return out

这段代码的处理方式是只取最后一个时间步的隐藏状态做预测,倒推式训练。如果你做多步预测,可以把每个时间步的h都接输出层,然后和对应步的标签算loss。

4.3 训练循环与loss曲线判读

量子模型训练最怕的就是loss曲线看起来在下降但验证集一塌糊涂。这个示例里用的是均方误差L2Loss,优化器是RMSprop。选RMSprop而不是Adam的原因:量子线路梯度噪声相对较大,RMSprop对梯度幅值的归一化处理比较适合这种情况。

model = QLSTM(input_size=1, hidden_size=16, output_size=1, n_qubits=4) optimizer = torch.optim.RMSprop(model.parameters(), lr=0.01) loss_fn = nn.MSELoss() epochs = 200 for epoch in range(epochs): optimizer.zero_grad() pred = model(X_tensor) loss = loss_fn(pred, Y_tensor) loss.backward() optimizer.step() if epoch % 20 == 0: print(f"epoch {epoch}, loss {loss.item():.4f}")

我在拆这个Notebook时注意到一个细节:hsic隐藏维度设置为16,比量子比特数4大得多,这意味着量子线路负责提取非线性特征,隐藏状态的空间由经典层扩展。这个设计思路值得借鉴——量子部分不需要输出很宽的维度,经典层做维度扩展更高效。

4.4 训练过程中最常见的“假收敛”现象

训练量子LSTM时,我遇到过一个非常迷惑的情况:loss先快速降到0.05附近,然后卡住不动,但验证集误差持续震荡升高。排查后发现是量子门控值饱和——三个门的sigmoid输出都在0.99以上,相当于门一直全开,模型变成了一个退化的线性RNN。

解决办法是在loss里加一个轻度的门控正则项,惩罚门控值偏离0.5的情况:

gate_penalty = 0.01 * ((f_gate - 0.5) ** 2).mean() loss = loss_fn(pred, Y_tensor) + gate_penalty

加这个正则项后,门控值分布会逼向[0.2, 0.8]区间,模型有更强的非线性表达能力。如果你的任务里设计上不需要复杂的长期依赖,这项正则可以设小一点或直接去掉。

5. 避坑指南:量子LSTM常见问题与排查记录

5.1 训练loss卡在某个值不动,且门控值持续为1

现象:loss前20个epoch快速下降,之后纹丝不动;打印三个门的sigmoid输出,发现f_gate和i_gate都在0.99以上。

原因:量子线路参数的初始权重过大或过小,导致测量到的期望值经过sigmoid后饱和。线路输出的非线性表达能力没有被利用,模型退化成近似线性结构。

解决:检查q_weights初始化,把缩放因子从0.1降到0.03;同时把门控正则项加上。另外,检查输入编码前是否有做标准化,如果输入特征的量级过大,角度编码会超出[0, 2π]的有效映射范围。

5.2 反向传播报错:梯度无法连接到量子节点

现象:运行loss.backward()时报错,提示某个Tensor不是计算图的一部分。

原因:量子线路在Pennylane中需要用interface="torch"包装,并且调用方式必须是QNode的Torch兼容模式。如果直接在QNode上调用numpy接口(比如直接在Jupyter里跑了一次量子线路并保存了结果),之后把这个numpy结果放进Torch计算图,梯度链路就断了。

解决:确保每次前向传播都直接调用QNode,不要先把输入转成numpy再转回Tensor。检查QNode装饰器里是否写了interface="torch",没有的话加上。

5.3 Jupyter里重复运行cell导致线路堆叠

现象:同一个Notebook cell多次运行后,量子线路的构建时间越来越慢,甚至内存飙高。

原因:Pennylane的QNode在每次定义时会把异常信息记录在缓存里。如果在Jupyter中重复运行定义QNode的cell,旧的QNode对象没有被释放,但是线路构建的逻辑被重复注册。

解决:把QNode定义统一放到一个函数里,用@qml.qnode装饰器在函数内部定义,多次运行函数的开销是可控的。不要在一个cell里反复定义同一个名字的QNode。改用新的cell重新训练前,用del model释放旧的模型对象。

5.4 损失是NaN,梯度爆炸

现象:训练到第几十个epoch时loss突然变成nan。

原因:角度编码的输入超出了合理范围,导致某些旋转角的正弦余弦值剧烈震荡;或者学习率太大,参数更新跨越了量子的非线性区域。

解决:先将学习率从0.01降到0.001;检查输入特征是否做了MinMax缩放;如果问题依旧,把n_qlayers从2降到1、q_depth从6降到3,减小线路深度。

5.5 训练时间比经典LSTM慢太多

现象:同样规模的时序预测任务,这个量子模型的训练时间是经典LSTM的30倍以上,但精度并没有提升。

原因:模拟器上跑量子线路开销本来就大,如果你用default.qubit做2层4比特、外层循环跑200个epoch,一次训练需要几十分钟是正常的。问题往往出在你把n_qlayers或q_depth设得过大。

解决:先按示例的默认参数跑通,再逐步增加深度。另外,如果想验证效果,可以把adjoint微分方式保持默认,它的训练速度比parameter-shift快很多。如果项目里只需要验证模型设计,可以只跑50个epoch看趋势。

6. 进阶验证技巧:梯度检查与门控行为对照实验

6.1 梯度检查:验证量子线路反向传播是否正确

量子线路的梯度计算是黑匣子,尤其你第一次把QNode嵌入到自己的模型里时,先用数值梯度验证一下参数更新方向是否正确,能省去后面所有排查时间:

from pennylane import numpy as np def numerical_gradient(weights, inputs, eps=1e-4): """对权重参数做中心差分,验证反向传播梯度""" grad_list = [] for i in range(weights.shape[0]): for j in range(weights.shape[1]): w_plus = weights.copy() w_minus = weights.copy() w_plus[i, j] += eps w_minus[i, j] -= eps g = (quantum_gate(inputs, w_plus)[0] - quantum_gate(inputs, w_minus)[0]) / (2 * eps) grad_list.append(g) return np.array(grad_list)

跑一次数值梯度和模型反向传播的梯度对比,如果两者相差超过1%,检查你的数据流里是否混入了非Tensor计算,或者有没有在某个环节做了detach。

6.2 门控行为对照实验:同一数据跑经典LSTM做对照

这个qlstm示例最有教学价值的用法不是单独跑,而是和PyTorch内置的经典LSTM在相同数据上做同步实验。我曾经用第三章的正弦叠加数据,让两个模型在相同epoch数下训练,把隐藏状态h的轨迹打印出来对比。量子LSTM的h轨迹呈现明显的分段线性特征,经典LSTM的h轨迹则更平滑——量子门控因为测量期望值的离散本质,天然带有更强的非线性切换行为。

如果你要做这个对照实验,建议固定随机种子,确保两个模型从同样的初始状态出发。这个差异对理解“量子LSTM到底改变了什么”非常直观,是做方案评审时最有说服力的证据。

6.3 参数扫描:门控初始缩放因子的敏感度测试

在拆这个项目的过程中,我最后会做的一次实验是参数扫描:把q_weights的初始缩放因子从0.01扫到0.5,记录每个配置下的最终loss和收敛速度。结果是0.03到0.15之间最优,小于0.01时模型基本上不学习,大于0.3时门控饱和风险极高。这个实验成本不高,但对后续你自己改模型非常有参考价值。

从那以后,我每次拿到一个PQC相关的示例项目,第一件事不是跑通,而是把初始化缩放因子、微分方式、线路深度这三项列成一个对照表强制扫描一遍。这个习惯帮我避免了很多“demo能跑但换了数据就翻车”的尴尬。希望帮到你。

本文还有配套的精品资源,点击获取