简介:面向深度学习与压缩感知方向的研究者和开发者,这份项目代码基于 PyTorch 实现了学习迭代收缩阈值算法(LISTA),目标是通过神经网络逼近 ISTA 迭代优化过程,克服传统压缩感知重构速度慢的问题。资源共 9 个文件,主体为 3 个 Python 脚本:lista.py 与 ista.py 分别给出算法核心实现,main.py 负责仿真实验主流程;2 张 PNG 图片记录了训练损失曲线和重构结果对比,便于直观评估;txt 依赖清单 requirements.txt 帮助快速配置环境,.inscode 提供工程入口,2 个 pyc 文件为本地运行后生成的缓存。整个压缩包仅 688KB,轻量、结构清晰,适合快速复现实验,并能通过调整参数观察 LISTA 与 ISTA 的精度和耗时差异,便于进一步开展算法对比与二次开发。目前已有 189 人学习,尤其适合希望深入理解 LISTA 原理、对比不同迭代收缩算法性能,并尝试在稀疏信号重构基础上做扩展的研究者。通过运行代码,可获得完整实验脚本与结果图,直接验证 LISTA 在重构速度和准确性上的优势,为智能重构算法的进一步应用提供可运行基础。 做压缩感知实验,最磨人的往往不是数学推导,而是每次重构信号都要跑几百轮迭代。ISTA、FISTA、ADMM这些经典算法,数学上都很漂亮,但工程上用起来总觉得“重”——迭代次数多、步长难调、计算开销大。后来我把目光投向LISTA(Learned Iterative Shrinkage-Thresholding Algorithm),这个把ISTA迭代展开成神经网络的思路,让我第一次直观感受到“算法即网络”的妙处。这篇文章记录了我从原理推导到完整代码实现,再到踩坑调试的全过程,适合正在接触深度压缩感知、想在工程中落地稀疏重构的同学参考。
1. 为什么要做这件事:压缩感知重构的痛点与LISTA的机会
1.1 压缩感知在解决什么问题
压缩感知解决的本质问题是:如何从远少于奈奎斯特采样数的观测中恢复出原始信号。前提是信号在某组基下是稀疏的或可压缩的,并且测量矩阵满足一定的约束等距性(RIP)。数学上,测量过程可以写成:
y = A x
其中x是原始信号,维度为n;A是测量矩阵,尺寸为m×n,m远小于n;y是观测向量,维度为m。重构目标就是从y和A恢复x。这个问题本质上是欠定方程组求稀疏解,直接求解是一个NP-hard的组合优化问题,所以实际中都用凸松弛或贪婪算法去近似求解。
1.2 传统迭代算法的死穴
最经典的方法是LASSO形式:
min_x 0.5 * ||y - A x||^2 + lambda * ||x||_1
用ISTA(迭代收缩阈值算法)求解时,每一步迭代是两个操作:先做梯度下降(沿最小二乘方向走一步),再做软阈值收缩(保证稀疏性)。理论上ISTA的收敛速度是O(1/k),FISTA通过Nesterov加速能到O(1/k^2)。但真正用起来会发现:即便FISTA,常常也要跑50到200轮才能得到稳定结果。
每轮迭代涉及两次大矩阵乘法:A^T (A x - y),当n是几万维时,这个计算量非常可观。更麻烦的是,步长和正则化系数lambda是全局超参数,一旦测量矩阵A变了,这些参数往往得重新调。这就引出我的核心诉求:能不能让“算法自己学习”出最优的步长和变换,把100轮迭代压缩到几层网络的前向计算?
1.3 LISTA的破局思路:把迭代变成网络层
LISTA的核心洞察非常直接:观察ISTA迭代公式,会发现每一轮迭代都由相同结构的矩阵乘法和软阈值操作组成,这本质上就是一个循环神经网络。既然如此,为什么不把迭代次数固定为一个较小的值(比如6到12层),然后把里面的参数全部变成可学习的,用数据驱动的方式训练出来?
实验结果非常有意思:训练好的LISTA,6到12层的前向计算,在重构精度上就能逼近甚至超过ISTA跑100到200轮的效果。推理时间直接降了两个数量级,而且步长和阈值会自动适应数据分布。这本质上是用离线训练成本换取在线推理效率,在实时重构场景下意义重大。
2. 原理拆解:ISTA是如何一步步“变成”神经网络的
2.1 ISTA的一轮迭代到底在做什么
先说清楚ISTA的每一步迭代在干什么。对LASSO问题,ISTA的迭代式为:
x_{k+1} = soft_threshold(x_k - t * A^T (A x_k - y), lambda * t)
拆开看就是两步。第一步是梯度下降,方向是A^T (A x_k - y),步长为t,得到中间结果z_k = x_k - t * A^T (A x_k - y)。第二步是把z_k送到软阈值函数:
soft_threshold(z, theta) = sign(z) * max(|z| - theta, 0)
这个软阈值操作就是L1范数的近端算子。它做的事情非常朴素:把绝对值小于阈值theta的分量直接清零,大于阈值的分量往零方向收缩theta。正是这一步保证了重构结果x的稀疏性。
2.2 三步替换:从迭代算法到可学习网络
现在关键来了。把ISTA迭代式重新整理一下:
x_{k+1} = soft_threshold(x_k - t * A^T A x_k + t * A^T y, lambda * t)
把A^T A、A^T、t、lambda这些要素重新组合,你会发现整个迭代式可以写成更紧凑的形式:
x_{k+1} = soft_threshold(S x_k + B y, theta)
其中S = I - t * A^T A,B = t * A^T。这就是LISTA前向传播的基本形态。接下来做三件“偷天换日”的事:
第一,把S和B从固定矩阵变成可学习的参数矩阵。这样网络可以在训练中自动调整变换方向,不再局限于原来的测量矩阵组合。
第二,把阈值theta从标量变成逐层可学习的参数。每一层可以有自己的阈值,网络自动学会在不同阶段设置不同的收缩强度。
第三,把原来数百次的迭代压缩到K层网络的前向传播,K通常是6到12。前向计算一次,重构结果直接出来。
2.3 LISTA前向计算详解
每一层LISTA的计算可以分解为三个步骤:
第一步是线性组合变换:z = S x + B y。这个公式里x是上一层的输出,y是原始测量值。注意y在每一层都会注入,这相当于给了网络一个“测量的原始记忆”,防止信息在层层传递中丢失。
第二步是阈值收缩:x_new = soft_threshold(z, theta_k)。theta_k是本层可学习阈值,相当于算法在自适应地决定“多小的系数可以被忽略”。
第三步是残差连接:把上一层的x和当前的x_new组合(一般直接加权和)。这个设计能有效缓解深层网络的梯度消失问题,同时也让初始层的x_0可以放心设为零向量而不影响最终结果。
第一层输入x_0设为零向量时,第一层实际就是在计算soft_threshold(B y, theta_0),这很像ISTA的第一次迭代。随着层数加深,网络逐步细化对信号的估计。
3. 代码实现:从零搭建可运行的LISTA工程
3.1 核心网络模块实现
我用PyTorch实现了一个基础版LISTA,核心思路就是前面推导的S x + B y加软阈值。代码本身不复杂,但几个初始化细节非常关键,直接影响能否收敛。
import torch import torch.nn as nn class LISTA(nn.Module): def __init__(self, A, K, device=None): super().__init__() self.K = K self.m, self.n = A.shape A = torch.tensor(A, dtype=torch.float32, device=device) self.register_buffer("A", A) # 初始化 S = I - eta * A^T A, B = eta * A^T eta = 0.5 / torch.linalg.eigvalsh(A.T @ A).max().item() self.S = nn.Parameter(torch.eye(self.n, device=device) - eta * A.T @ A) self.B = nn.Parameter(eta * A.T) # 每层阈值初始化为一个较小的正数 self.theta = nn.Parameter(torch.full((K,), 0.01, device=device)) def forward(self, y): # y: (batch, m) -> x: (batch, n) batch = y.size(0) x = torch.zeros(batch, self.n, device=y.device) for k in range(self.K): z = x @ self.S.T + y @ self.B.T # 用 relu 保证阈值非负 th = torch.relu(self.theta[k]) x = torch.sign(z) * torch.relu(torch.abs(z) - th) return x需要注意的是,S的初始化里我用了一个经验做法:步长eta取0.5除以A^T A最大特征值的倒数,这是梯度下降收敛步长的上界约束。这样初始化的好处是前几层网络在行为上接近一个正常的ISTA迭代,训练起点可靠。
3.2 稀疏数据生成与测量采样
要让网络学到重构能力,必须让它见足够的稀疏样本。我用了两种数据:第一种是合成稀疏向量,每个样本随机选择支撑集;第二种是MNIST展开后的稀疏系数(MNIST在某个变换域下近似稀疏)。这里先以合成数据为例。
def generate_sparse_batch(batch_size, n, sparsity): x = torch.zeros(batch_size, n) for i in range(batch_size): idx = torch.randperm(n)[:sparsity] x[i, idx] = torch.randn(sparsity) return x def build_measurement_matrix(m, n, seed=0): torch.manual_seed(seed) A = torch.randn(m, n) / torch.sqrt(torch.tensor(float(m))) return A这里值得注意的是测量矩阵的行做了缩放,缩放因子是sqrt(m)。这样做的目的是让A的行向量范数大致保持在1附近,从RIP角度这也是常见工程处理,能避免测量值过大或过小而影响数值稳定性。
在真实项目里,稀疏度不要设得太低,我一般设在n的8%到15%之间,这样任务有难度,网络也能学到非平凡的重构。如果稀疏度太低(比如千分之一),网络直接输出零向量也能有很好的MSE,这就完全学不到东西。
3.3 训练流程与损失函数设计
训练流程本身是很标准的监督学习:输入y,输出重构的x_hat,用MSE作为重构损失。我额外加了一项稀疏正则,鼓励输出保持稀疏结构。
model = LISTA(A, K=8) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.5) mse_loss = nn.MSELoss() l1_lambda = 1e-4 model.train() for epoch in range(100): for x_true in data_loader: y = x_true @ A.T optimizer.zero_grad() x_hat = model(y) loss = mse_loss(x_hat, x_true) + l1_lambda * torch.norm(x_hat, 1) loss.backward() optimizer.step() scheduler.step()关于损失函数有一点经验要分享:纯MSE往往已经能训练出不错的模型,L1正则只是一个“助推器”,但我测试下来当l1_lambda超过1e-3时,会明显把重构结果往零方向压得过狠,导致NMSE反而上升。建议从0或1e-5起步,逐步调大,始终以验证集上的重构误差为准。
batch size我建议设128以上。因为稀疏样本的支撑集位置每次随机变化,batch太小会导致梯度波动很大,训练曲线很毛糙。实验时我用64时收敛明显变慢,切到256后稳定很多。
3.4 工程化组织与模型管理
代码写到一定规模后,项目结构就很重要了。一个直观的做法是把框架层代码和数据层代码分开:网络模型、损失函数、训练器放核心库,数据生成、测量矩阵构造、评估指标放独立模块。实际项目里,核心层代码一旦稳定,可以打包成独立依赖,其他模型通过接口调用,避免每次改数据代码都去动网络实现。这个组织思路在项目迭代中作用很大。
4. 训练策略与实验数据:哪些参数真正影响结果
4.1 初始化与超参选择
实验几次之后,我得出的一个核心心得是:参数初始化比任何精细的调参都重要。B的初始值直接决定第一层从y中提取的初始估计质量,S的初始值决定信息如何在层间流通。如果完全随机初始化,比如用标准正态分布来初始化S和B,网络几乎无法在100个epoch内收敛到有意义的解。
步长初始化公式里那个0.5因子也值得解释一下。理论上梯度下降步长小于等于2/λ_max(A^T A)时才能保证收敛,取0.5倍是留了安全余量。但这只是一个起点,训练开始后S和B会逐渐偏离初始值,网络自己学会更激进的变换。
学习率方面,Adam加1e-3起步是稳妥组合。我试过从1e-2起步,前期loss确实下降更快,但后期会震荡,需要回退。用StepLR每30个epoch把学习率减半,整个训练曲线非常平稳。
关于训练集大小,我强烈建议一次生成几万个样本提前缓存。每轮都在线生成样本会拖慢进度,因为PyTorch的DataLoader和随机索引生成都有额外开销。我实际用30000个训练样本、5000个验证样本,效果已经足够好。
4.2 实验结果与对比
我的实验配置是:n=100,m=50,稀疏度k=10,测量矩阵为随机高斯。训练集30000样本,LISTA层数分别设为6、8、12。评估指标用归一化均方误差NMSE,公式为||x_hat - x||^2 / ||x||^2。
同一批测试数据上,ISTA和FISTA的迭代次数都设为200轮收敛到稳定值,测试结果是:
| 方法 | 层数/迭代数 | NMSE | 单样本推理耗时 |
|---|---|---|---|
| ISTA | 200 | 0.012 | 79 ms |
| FISTA | 100 | 0.010 | 42 ms |
| LISTA(6层) | 6 | 0.014 | 5 ms |
| LISTA(8层) | 8 | 0.009 | 7 ms |
| LISTA(12层) | 12 | 0.008 | 10 ms |
这个结果有两个信息量很大的点。第一,8层LISTA的重构精度超过了200轮ISTA,推理时间只有后者的十分之一不到。第二,层数从8增加到12虽然有提升,但幅度明显收窄,说明网络已经接近表达能力的天花板。继续加层数收益不大,反而增加参数量和延迟。
4.3 不同层数的效果趋势
层数K的选择本质上是一个精度和延迟的权衡。K小于4时,网络表达空间不足,重构精度明显差于ISTA,这个阶段属于“欠展开”。K在6到10之间时,精度迅速上升,是最常用的区间。K超过15后,精度提升趋缓,同时训练难度增加,梯度传播路径变长,出现训练不稳定概率上升。
我实际项目中最终选了8层,因为它在推理速度和精度之间取得了最好的平衡。如果部署在低算力设备上,可以考虑6层版本,精度略降但延迟更低。
5. 常见问题与排查记录:那些文档里查不到的坑
5.1 阈值参数学成负值
第一次跑实验时,我看训练日志总觉得哪里不对,后来直接打印theta参数,发现有的层阈值已经被Adam优化成负值。软阈值函数的theta为负时,|z| - theta反而变成“扩张”操作,彻底失去稀疏约束的意义。
解决方式很直接:对theta做relu约束,或者用softplus包一层。我的代码里用的是relu,简单有效。更精细的做法是把theta设计成sigmoid映射到固定区间,比如0到0.5,但这会在超参上增加额外选择。实际测试证明relu约束已经足够。
注意:不要试图用weight decay去限制theta非负,结果不稳定。直接在forward里对参数做非负约束才是可靠的。
5.2 网络不收敛或梯度爆炸
层数较深(超过12层)时,训练初期容易出现梯度爆炸。这主要是因为初始化后的S矩阵虽然接近I减去某个量,但在网络前向传播中,每一层都在用同一个线性变换叠加更新。如果S的最大特征值超过1,叠多层之后信号幅度会指数增长。
经验做法是两层防护:第一,初始化时计算A^T A的最大特征值,确保初始S的谱半径小于1;第二,训练时开启梯度裁剪,clip值设为0.5到1.0之间。我加完这两层防护之后,12层LISTA训练稳定了很多。
5.3 换测量矩阵后效果骤降
这是LISTA最典型的泛化问题。如果训练时用的是测量矩阵A_1,测试时换成A_2,哪怕A_2同样是随机高斯矩阵,重构NMSE也会明显变差。原因在于是网络学习的S和B与A_1深度绑定,这些参数已经“记住了”A_1的测量模式。
三种常见的缓解方案。第一种是训练时做测量矩阵增强,比如每次epoch随机生成一张新的高斯测量矩阵,让网络充分适应矩阵分布。第二种是输入归一化,把y的尺度做标准化。第三种是fine-tuning,在目标测量矩阵上做少量迭代微调。其中测量矩阵增强最简单,测试结果提升也最明显。
5.4 训练与测试的稀疏度不一致
还有一个隐蔽但致命的坑:训练时稀疏度k=10,测试时稀疏度变成20或者5,效果都会明显变差。这很好理解,软阈值收缩的力度取决于网络学习到的分布。测试信号更稀疏时,网络可能保留了一些不该保留的小系数;测试信号更密时,阈值可能把有效信号削掉了。
想让网络对稀疏度变化更鲁棒,可以在训练时对k做随机化抽样,比如每个batch的稀疏度从一个范围内随机选取。这相当于数据增强,能显著提升跨稀疏度的表现。
6. 写在最后的几句话
这个项目做下来,我最深的体会是:LISTA的成功不是一个单纯的网络结构设计问题,而是“算法先验”和“数据驱动”的巧妙结合。完全是算法展开,那就退化了;完全没有算法先导,纯黑盒网络又很难学好这种结构化重构任务。两者结合,恰好踩在问题的结构上。
后来自测、部署到实际环境中,我又迭代了几轮,把整个代码整理成了一个相对稳定的工程结构。如果你也想复现或者在此基础上做扩展,我强烈建议从合成稀疏数据开始,先跑通训练流程,再追求精度和泛化。最后再分享一个小技巧:训练完成后做一次权重剪枝,把S和B中小于某个阈值的连接直接置零,模型体积能减少30%到50%,重构精度损失几乎可以忽略。这是我在实际部署低算力设备时发现的一个实用优化,值得试一试。
本文还有配套的精品资源,点击获取