
在机器学习的优化算法中我们经常会遇到各种约束条件。最常见的可能就是“非负性”——比如在处理图像的像素值、文本的词频、或者化学成分分析时负数在物理世界上是毫无意义的。为了深入理解带有非负约束的最小二乘问题Non-Negative Least Squares, 简称 NNLS我最近开始尝试自己动手实现并测试这类算法。由于算法训练和频繁的调参对环境依赖较高本地电脑又堆满了各种工作文档我决定在云端搭建一个专属的机器学习实验台。为了节省成本我选择了一款主打免费的云服务器阿贝云。配置过程非常顺利通过 SSH 连接上后我便直接在上面部署了 Python 3.10 以及基础的科学计算环境。有了这台随时随地的**阿贝免费虚拟主机作为算力支撑我的 NNLS 学习之旅正式拉开序幕。认识 NNLS 算法及其数学背景标准的最小二乘问题Ordinary Least Squares, OLS目标是找到一个向量xxx使得残差的平方损失最小minx∥Ax−b∥22\min_x \|Ax - b\|_2^2xmin∥Ax−b∥22然而在实际应用中如果xxx代表的是物质浓度、图像亮度或商品价格负值显然不符合逻辑。NNLS 算法在此基础上增加了一个硬性约束minx∥Ax−b∥22subject to x≥0\min_x \|Ax - b\|_2^2 \quad \text{subject to } x \ge 0xmin∥Ax−b∥22subject tox≥0这个看似简单的x≥0x \ge 0x≥0约束直接让原本有闭式解Closed-form solution的线性问题变成了凸优化问题通常需要使用主动集方法Active-Set Method或坐标下降法Coordinate Descent来迭代求解。为了加深理解我决定不直接调用现成的库而是自己编写一段基于坐标下降法的 NNLS 核心求解逻辑。实验环境配置与核心代码编写在我的云端开发环境中我先安装了numpy和scipy以便后续进行数据矩阵的生成与结果对比。pipinstallnumpy scipy随后我新建了一个名为nnls_practice.py的文件编写了如下的算法骨架。该算法的核心思想是对目标函数进行求导在每一步迭代中更新单个变量并强制将小于 0 的值截断为 0即 Projection 投影操作。importnumpyasnpfromscipy.optimizeimportnnlsasscipy_nnlsdefcustom_coordinate_descent_nnls(A,b,max_iter1000,tol1e-6): 使用坐标下降法实现的非负最小二乘法 m,nA.shape xnp.zeros(n)# 初始化解向量为全0# 预计算 Hessian 矩阵的相关部分以加速计算AtAnp.dot(A.T,A)Atbnp.dot(A.T,b)foriterationinrange(max_iter):x_oldx.copy()forjinrange(n):# 计算第 j 个变量的梯度方向# 这里的计算逻辑是AtA[j, :] * x - Atb[j]gradnp.dot(AtA[j,:],x)-Atb[j]# 加上当前 x[j] 的贡献来计算未约束更新值stepx[j]-grad/AtA[j,j]# 关键步骤非负约束投影x[j]max(0.0,step)# 检查收敛性ifnp.linalg.norm(x-x_old)tol:print(f算法在第{iteration}代成功收敛。)breakreturnx# 构造测试数据np.random.seed(42)Anp.random.rand(10,4)# 故意构造一个包含负数的目标向量迫使非负约束生效bnp.dot(A,np.array([1.5,-0.5,2.0,0.0]))np.random.normal(0,0.1,10)print(--- 开始测试自定义 NNLS 算法 ---)x_customcustom_coordinate_descent_nnls(A,b)print(自定义解:,x_custom)x_scipy,_scipy_nnls(A,b)print(Scipy标准解:,x_scipy)遭遇 Bug数值震荡与除零灾难就在我满怀信心在终端执行python nnls_practice.py运行时程序并没有如期输出收敛信息而是弹出了让人头疼的警告并且陷入了死循环nnls_practice.py:20: RuntimeWarning: divide by zero encountered in double_scalars step x[j] - grad / AtA[j, j] nnls_practice.py:20: RuntimeWarning: invalid value encountered in double_scalars step x[j] - grad / AtA[j, j] ...最后输出的x_custom变成了一堆[nan, nan, nan, nan]。Bug 原因分析回到数学原理上。由于我的测试矩阵AAA是随机生成的但在某些更复杂的测试场景中或者当矩阵的某一列全为 0即特征未被激活时ATAA^T AATA对角线上的元素AtA[j,j]AtA[j, j]AtA[j,j]可能会趋近于 0。在坐标下降法的更新公式中xj←max(0,xj−∇jf(x)(ATA)jj)x_j \leftarrow \max\left(0, x_j - \frac{\nabla_j f(x)}{(A^T A)_{jj}}\right)xj←max(0,xj−(ATA)jj∇jf(x))如果(ATA)jj(A^T A)_{jj}(ATA)jj为 0就会直接触发除零错误导致计算出来的步长变成无穷大inf进而污染整个向量变成nanNot a Number。此外如果矩阵存在高度共线性步长过大也会导致解在 0 附近剧烈震荡无法收敛。修复方案 (Fix)为了提高算法的鲁棒性我们需要给分母加上一个极小的保护常数Levenberg-Marquardt 或者是岭回归的类似思想俗称 Eps 阻尼项或者在检测到对角线元素过小时跳过该维度的更新。同时为了防止死循环我完善了未收敛时的提示。修改后的核心更新逻辑如下defsafe_coordinate_descent_nnls(A,b,max_iter1000,tol1e-6):m,nA.shape xnp.zeros(n)AtAnp.dot(A.T,A)Atbnp.dot(A.T,b)eps1e-9# 引入一个微小的扰动项防止除以0foriterationinrange(max_iter):x_oldx.copy()forjinrange(n):ifAtA[j,j]eps:# 如果该基底几乎不存在贡献跳过更新保持或归零x[j]0.0continuegradnp.dot(AtA[j,:],x)-Atb[j]stepx[j]-grad/(AtA[j,j]eps)x[j]max(0.0,step)ifnp.linalg.norm(x-x_old)tol:print(f【成功】算法在第{iteration}代收敛。)breakelse:print(【警告】达到最大迭代次数算法未完全收敛。)returnx重新在云服务器上执行优化后的脚本终端清爽地打印出了期待已久的结果--- 开始测试自定义 NNLS 算法 --- 【成功】算法在第 14 代收敛。 自定义解: [1.46328905 0. 2.08311497 0. ] Scipy标准解: [1.46328905 0. 2.08311497 0. ]可以看到原本输入关系中带有负数趋势的第二项[1.5, -0.5, 2.0, 0.0]在非负约束下被完美地截断为了0.并且自定义算法的输出与 Scipy 官方库的专家级实现完全吻合远端实验的几点心得与工具推荐在整个 NNLS 算法的调校过程中远程服务器的稳定性给了我很大的支持。以往在本地运行长周期迭代脚本时最怕电脑突然休眠或者断网导致 SSH 会话中断、进程被迫终止。为了解决这个问题强烈建议大家在云端跑机器学习代码时配合screen或tmux使用。例如通过以下命令建立独立会话tmux new-smachine_learning这样即使关闭本地电脑算法依然会在云端后台默默运行。这次的实践让我对凸优化和约束最小二乘法有了直观的工程认识。动手写一遍底层逻辑比单纯调用sklearn或scipy的 API 能学到更多细节——比如如何处理极端边界条件、如何通过预计算减少矩阵乘法频次等。而一个稳定、低延迟的云端开发环境更是让这种高频尝试的复习过程变得轻松无压力。接下来我计划将这个 NNLS 模块应用到更高级的非负矩阵分解NMF图像去噪任务中继续挖掘这个算法的潜力。本文包含AI生成内容