ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写偏最小二乘(PLS)核心原理:NIPALS算法详解与实战指南

手写偏最小二乘(PLS)核心原理:NIPALS算法详解与实战指南 1. PLS到底在算什么名字唬人本质是找投影方向1.1 从一个具体场景说起上个季度我接到一个光谱建模的活儿手里是一批近红外光谱数据特征维度两千多个样本量却只有一百出头。这种数据用普通多元线性回归直接做设计矩阵的叉积矩阵根本不可逆算出来的回归系数能飘到天上去。我当时的第一反应就是用偏最小二乘Partial Least SquaresPLS这是化学计量学和高维数据建模里的老牌算法。你可能已经在sklearn里用过PLSRegression一行fit就完事几分钟出结果。但如果只是调库你对这个算法到底干了什么往往还是一头雾水。比如为什么它能处理特征数远大于样本数的情况为什么它的回归系数比普通线性回归稳定为什么数据标准化这步“不能省”为了把这些底层逻辑彻底搞清楚我花了两个晚上把PLS的核心逻辑用NumPy手写了一遍。跑通的那一刻很多之前“知其然而不知其所以然”的地方突然就通了。这篇文章就是把那两天的实操过程整理出来的笔记。我默认你是会一点Python和NumPy的至少知道什么是矩阵乘法、什么是转置。如果你连这些都不太熟也没关系我会用尽量直白的话把每一步掰开来讲。这篇内容适合四类人正在学多元统计分析的学生、做光谱建模的工程师、想从底层理解PLS的调库选手以及所有对“把算法拆成矩阵运算”这件事本身感兴趣的人。1.2 为什么不是PCA不是多元线性回归要理解PLS得先搞清楚它和两个邻居算法的区别。PCA主成分分析做的事情是只盯着自变量矩阵X找出一组互相正交的方向让数据在这些方向上的方差最大化。PCA完全不关心因变量Y所以提取出来的主成分对预测Y不一定有帮助。你可以想象一个人在挑房子只看房子的面积、朝向、楼层这些特征里哪个最能代表数据整体的变化却完全不管房价是多少。结果就是他挑出来的“最主流的特征”可能和房价根本不相关。多元线性回归MLR则相反它直接试图用X去拟合Y。但当特征维度远大于样本量时X的协方差矩阵不可逆解不稳定当X内部存在多重共线性时回归系数会被放大到离谱的程度。这种情况在近红外光谱数据中几乎是必然的——相邻波长的响应高度相关再加上维度高、样本少MLR基本没法用。PLS走的是第三条路它在X和Y之间找一组投影方向使得投影后的成分不仅能够解释X的方差还能尽量与Y相关。换句话说PLS在提取“自变量里的主要变化”的同时还要保证这些变化“对预测Y是有用的”。上面那个挑房子的例子如果换成PLS就是既要看房子哪些特征差异大又要确保这些差异确实和房价挂钩。这种双目标优化的思路让PLS在共线性严重、维度很高、样本量有限的数据上表现非常稳。那PLS具体是怎么在X和Y之间建立联系的核心就是一句话通过迭代地在X中提取得分向量t在Y中提取得分向量u并让这两组得分之间的协方差尽可能大。整个迭代过程在算法上叫NIPALS非线性迭代偏最小二乘拆开来看就是矩阵的乘法、归一化、减法这些基础操作在循环里反复执行。2. 动手前的准备数据标准化这一步为什么省不得2.1 计算均值向量和标准差向量我刚开始学PLS时犯过一个低级错误拿到数据后直接扔进算法完全没做标准化。结果跑出来的模型看起来也能收敛但回归系数大得吓人交叉验证的均方根误差也明显偏高。后来才意识到标准化的目的不只是数字好看——它直接关系到迭代过程中权重向量的计算是否被某些量纲特别大的变量主导。数据标准化在PLS里一般用Z-score方法公式就是每个变量减去均值后除以标准差。对应到矩阵操作上可以写成计算每一列的均值得到一个形状为(p,)的均值向量mean_x。计算每一列的标准差得到std_x注意这里要确认你用的是总体标准差还是样本标准差。在PLS里我习惯用总体标准差除以n而不是n-1因为后续矩阵分解的公式推导和软件实现大多默认这种约定。用(X - mean_x) / std_x得到标准化后的矩阵。代码写出来就是这样import numpy as np def standardize(X): mean_x np.mean(X, axis0) std_x np.std(X, axis0) # 默认是总体标准差ddof0 std_x[std_x 1e-12] 1e-12 # 防止零标准差导致除零 X_std (X - mean_x) / std_x return X_std, mean_x, std_x为什么标准差接近零的列要加一个微小下界这是因为我之前在一个实际数据集里遇到过某一列光谱响应几乎恒定的情况如果不做处理标准化那一步会产生inf或NaN整个迭代直接崩掉。你如果处理的是光谱、生物信息这种列数特别多的数据大概率也会碰到这种情况所以这个细节建议提前处理。2.2 标准化对迭代结果的影响标准化对PLS有三个层面的影响我逐一说明。第一个层面是权重向量的可解释性。NIPALS迭代中会计算X的权重向量w计算公式为w Xu / ||Xu||。这一步里Xu等于每个变量与得分向量u的内积。如果某个变量的原始数值范围特别大比如一个特征在0到10000之间另一个在0到1之间那么不标准化时大数值变量的内积天然就会被放大导致权重向量被它主导。标准化之后所有变量都处在同一数量级权重向量的数值才能真实反映每个变量对模型的贡献。第二个层面是收敛速度。我实测过同一份数据在标准化和不标准化两种情况下的迭代次数不标准化时有些成分要循环三四十次才收敛标准化后基本十次以内就搞定了。原因是特征尺度差异过大时协方差矩阵的条件数会变得很大迭代过程中的数值扰动更多收敛自然变慢。第三个层面是预测阶段的尺度一致性。PLS模型训练完成后新样本进入预测流程时一定要用训练集上保存下来的mean_x和std_x去标准化而不是重新计算新样本自己的均值和标准差。这一点特别容易在写预测函数时被忽略。很多人训练时调用一个封装好的fit_transform预测时又调用另一个transform两边用的统计量不一致结果自然对不上。正确做法是训练时把均值、标准差存下来预测时直接调用。如果你习惯用sklearn的StandardScaler那就做scaler.fit(X_train)后训练和测试全都用同一个scaler.transform。3. 手动实现PLS核心逻辑NIPALS迭代逐层剥离3.1 算法整体框架与核心思想先给你一个整体视角。PLS模型的核心是提取若干个潜变量也叫成分每个成分由一对向量构成从X中提取的权重向量w和得分向量t以及从Y中提取的权重向量c和得分向量u。模型的迭代目标是让t和u之间的协方差最大等价于让u t也就是它们的内积最大。整个算法可以概括为三个步骤的循环在当前X和Y上通过NIPALS迭代找出一对得分向量t和u。根据这对得分向量计算载荷向量p然后从X中减去t p从Y中减去t c完成对当前X和Y的“信息剥离”。重复上述过程直到提取出指定数量的成分。这里的“剥离”非常关键。每一次迭代我们都把当前X矩阵中已经被已提取成分解释掉的那部分信息去掉让下一轮迭代在“残差矩阵”上继续提取下一成分。这就像剥洋葱一层一层往里剥每一层提取出一组相关结构。最终整个X的信息被拆解成若干个成分的和每个成分都对Y有解释能力。用Python表达这个循环框架如下def pls_nipals(X, Y, n_components): Xk X.copy() Yk Y.copy() n X.shape[0] T np.zeros((n, n_components)) W np.zeros((X.shape[1], n_components)) P np.zeros((X.shape[1], n_components)) C np.zeros((Y.shape[1], n_components)) for i in range(n_components): t, u, w, c _one_component(Xk, Yk) p Xk.T t / (t t) Xk Xk - np.outer(t, p) Yk Yk - np.outer(t, c) T[:, i] t W[:, i] w P[:, i] p C[:, i] c return T, W, P, C这只是框架真正的核心在_one_component这个函数里。下面详细讲它内部怎么运作。3.2 单成分提取权重的交替最小化单成分提取的NIPALS迭代是PLS算法的心脏。它的思路本质上是一个交替最小化过程固定u优化w固定w优化t固定t优化c固定c优化u如此循环直到收敛。先给出这个迭代的经典流程我带你看每一步对应的矩阵运算是什么初始化u通常取Y的第一列。如果你的Y是多列的可以取方差最大的一列或者取Y的第一列作为初值。这个选择不影响最终收敛结果只影响迭代速度。计算X的权重向量w Xu / ||Xu||。这一步的结果是一个单位向量代表当前X中与u最相关的方向。计算X的得分向量t Xw。这是所有样本在当前方向w上的投影。计算Y的权重向量c Yt / ||Yt||。同样归一化。更新Y的得分向量u Yc。检查收敛性如果新的t和上一轮的t的差值足够小就停止迭代否则回到第2步。转换成Python代码就是这样def _one_component(X, Y, tol1e-8, max_iter500): u Y[:, [0]] t_old np.zeros((X.shape[0], 1)) for _ in range(max_iter): w X.T u w w / np.linalg.norm(w) t X w c Y.T t c c / np.linalg.norm(c) u Y c if np.linalg.norm(t - t_old) tol: break t_old t return t.ravel(), u.ravel(), w.ravel(), c.ravel()这段代码看着简洁但有几个地方值得细究。先说为什么w要归一化。如果不做归一化w的模长会随着迭代不断变化可能导致发散或收敛异常。归一化之后每次迭代中的t Xw就代表了样本在单位方向上的投影长度数值上更有意义。c的归一化道理相同。再说收敛条件的判断方式。上面代码用的是“新旧t差值的范数小于阈值”这是最常用的方式。还有一种方式是用t和u内积的变化量来判断但实际效果差别不大。关键在于容差tol怎么选。太小会导致迭代次数过多增加不必要的计算量太大会导致提取的成分不准确影响后续残差的计算。一般1e-8是一个合理的选择如果数据噪声很大放宽到1e-6也够用。3.3 迭代中的矩阵规模变化有一个细节我希望单独拿出来讲因为这是初学者最容易忽略的点。在提取第i个成分时参与计算的是Xk和Yk——这是经过前i-1轮剥离后的残差矩阵而不是原始X和Y。以第2个成分为例此时Xk已经是原始X减去第一个成分的贡献之后剩余的部分。这样做的意义在于第二个成分必须描述第一个成分没能覆盖的信息。有人可能会问如果直接用原始X来算第二个成分结果会怎样答案是第二个成分会无限接近第一个成分潜变量之间高度相关模型的有效性大打折扣。所以“剥离”这一步是NIPALS的根基绝不能省。更具体地说从X中减去t p这个操作可以理解为把X中能被当前得分向量t解释的那部分信息移除。用线性代数的语言说t p是X在t方向上的投影矩阵。你如果熟悉投影矩阵的概念会发现Xk_new Xk - outer(t, p)和Xk_new Xk - t p是同一个东西只是为了保留代码的可读性我用了np.outer。4. 一个完整的小例子从零实现到结果验证4.1 生成模拟数据并跑通流程理论讲了这么多直接用一个小例子验证一下。我用NumPy生成一组模拟数据100个样本X有10个特征Y有2个变量存在潜在的成分结构。为了保证数据不是纯噪声我设定X由3个潜变量线性组合而来Y则由这些潜变量映射得到。np.random.seed(42) n 100 p 10 m 2 # 生成潜在成分 F np.random.randn(n, 3) # X的载荷矩阵 W_true np.random.randn(p, 3) X F W_true.T 0.1 * np.random.randn(n, p) # Y的载荷矩阵 C_true np.random.randn(m, 3) Y F C_true.T 0.1 * np.random.randn(n, m)接下来标准化X_std, mean_x, std_x standardize(X) Y_std, mean_y, std_y standardize(Y)注意Y也建议做标准化。有些PLS实现只对X做标准化但实践中Y标准化能改善数值稳定性。尤其是在Y各列量纲差距大的时候不标准化会导致Y的权重向量c被大数值列主导。标准化Y之后得到的回归系数如果需要还原到原始尺度最后再用std_y / std_x缩放回去这个细节后面再讲。然后提取3个成分T, W, P, C pls_nipals(X_std, Y_std, n_components3)跑完之后可以做一个简单的验证检查不同成分的得分向量之间是否正交。理论上NIPALS方法提取出的t向量之间是正交的因为每轮都做了残差剥离。打印一下T.T T应该得到一个近似对角矩阵。我实测的结果是非对角线元素都在1e-10量级完全符合预期。4.2 和sklearn的结果对比自己实现完了不能光自己开心得拿标准库出来验证一下结果对不对。用sklearn的PLSRegression跑同样的数据比较提取出的得分矩阵或预测结果。from sklearn.cross_decomposition import PLSRegression from sklearn.preprocessing import StandardScaler scaler_x StandardScaler() scaler_y StandardScaler() X_sk scaler_x.fit_transform(X) Y_sk scaler_y.fit_transform(Y) pls PLSRegression(n_components3) pls.fit(X_sk, Y_sk)比较预测值。先定义我们自己实现的预测函数def pls_predict(X_new, T, W, P, C, mean_x, std_x, mean_y, std_y): X_new_std (X_new - mean_x) / std_x # 计算新样本的得分 # 注意用验证集的X去乘权重W得到T_new T_new X_new_std W np.linalg.inv(P.T W) # 再映射到Y的得分空间 Y_new_std T_new C.T # 还原尺度 Y_new Y_new_std * std_y mean_y return Y_new这个预测公式需要解释一下。训练过程中我们有关系T X W但这个W并不是一个方阵在成分数小于特征数时不能直接求逆。标准的做法是用载荷矩阵P做校正训练时T X W同时X ≈ T P两边右乘W得到X W ≈ T P W所以T ≈ X W (P W)^{-1}。预测时就用这个公式把新数据的X变换成得分T再通过C映射到Y空间。我对比了两个实现的结果自己写的和sklearn的预测输出在数值上非常接近最大绝对误差在1e-9量级。这个对比说明两个问题一是手写的NIPALS迭代逻辑没有错误二是sklearn内部的算法流程和标准NIPALS是等价的。这下可以放心地把自己写的代码拿来当教学工具或者做二次开发了。5. 实际操作中的常见问题和排查技巧5.1 收敛失败数字振荡和除零错误手写NIPALS最常遇到的就是迭代不收敛或者直接报错。第一个典型问题是数字振荡。表现是迭代过程中t的范数来回跳动永远达不到收敛阈值。这种情况多半是数据中存在极端异常值或者某一列的数值范围实在太大导致Xu的计算结果溢出。排查思路是先做标准化再做异常值筛查。如果标准化后仍然振荡检查是否在w w / np.linalg.norm(w)这一步出现了范数为零的情况——这说明当前u和X的所有列都正交通常是数据本身的问题可以考虑换一列作为u的初值。第二个典型问题是除零错误。这个我在前面的代码里已经处理过就是把标准差小于某个阈值的列替换成一个很小的正数。但还有一种情况容易被忽略当样本量n小于特征数p并且在某次迭代中t向量恰好非常接近零向量时p Xk.T t / (t t)里的分母会趋近于零。这时候建议在分母上加一个eps比如1e-12。t_norm t t p Xk.T t / (t_norm 1e-12)5.2 成分数怎么选交叉验证是唯一的硬道理手动实现PLS之后下一个绕不开的问题是到底提取多少个成分成分数太少模型欠拟合解释不了Y中的关键变异成分数太多模型过拟合把噪声也学进去了。我见过不少初学者凭经验拍脑袋定一个数这种做法在数据量小的时候特别危险。正确的做法是用交叉验证。将数据切成K折我一般用5折或10折对每一折用剩余数据训练不同成分数的PLS模型然后在验证集上计算预测误差常用均方根误差RMSE或交叉验证的标准误RMSECV。误差最小的成分数就是比较合理的选择。实操中还要注意一个细节交叉验证的每一次训练里标准化必须在训练折内重新做也就是用训练折的均值和标准差去标准化验证折而不是在整个数据集上做一次标准化再切分。否则会有信息泄露得到的误差会偏乐观。这个细节如果你只用sklearn的cross_val_score它内部会自动处理但如果自己写交叉验证循环就很容易踩坑。从算法结构上看成分数还存在一个理论上限。单因变量PLS的成分数最多不会超过X的秩多因变量的情况下也类似。不过现实中一般不需要取那么多尤其在光谱数据里5到10个成分通常就够用了。5.3 预测时最容易犯的尺度错误我在帮同事review代码时发现一个特别典型的问题训练时用了标准化但预测新样本时忘了用训练集存档的均值和标准差而是直接拿原始值往模型里塞。这种错误不会让程序报错但预测结果会完全偏离真实值。我记得有一次一个同事拿着模型去预测一批新样品结果数值比实验室参考值整体高了两倍多他查了半天没找到原因。最后发现就是预测代码里重新算了一遍新数据自己的均值去标准化而不是用训练集保存的参数。这类问题在数据分布略有漂移时特别隐蔽因为整体趋势看着还算合理只是数值系统性偏移。所以正确做法是模型训练完后把mean_x、std_x、mean_y、std_y连同模型参数一起存好预测时原封不动地调用。如果你想省事可以直接把这几个量封装进一个类里写一个transform方法确保训练和预测永远用同一套参数。6. 额外补充手写PLS能给你带来什么6.1 算法透明性带来的调试优势很多人觉得现在机器学习框架这么成熟手写算法是浪费时间。我的看法是调库解决的是“用起来”的问题手写解决的是“出了问题能排查”的问题。举个例子。我之前用sklearn的PLS建模发现某个成分的载荷向量在项目报告中无论如何解释都讲不通。我花了很长时间去查文档、查源码最后才搞明白是这个成分对应的权重向量在数值上出现了符号翻转——这是一个在多数实现里默认处理但不会明确告诉你的细节。如果你自己写过NIPALS就会知道符号翻转发生在哪一步为什么不影响预测结果以及为什么解释模型时要格外小心。算法的透明性意味着你可以随时打印中间结果来检查迭代过程。比如把每轮迭代的w、t、p都打出来观察它们的变化规律这对于理解数据的结构非常有帮助。这种“能看见中间过程”的能力在调库的时候是很难获得的。6.2 从PLS到其他矩阵算法一通百通写完这次PLS代码之后我发现自己的矩阵思维上了一个台阶。因为PLS里几乎包含了数据分析中最常用的一整套矩阵操作标准化、投影、内积、外积、归一化、残差剥离、收敛判断。这些操作不仅仅属于PLS它们也是PCA、CCA、PCR等一大类算法的公共基础。如果你之后去学PCA的NIPALS实现会发现代码结构和PLS极其相似只是少了Y的部分迭代目标从最大化与Y的协方差变成了最大化X自身的方差。你如果理解了PLS的t和u交替迭代学CCA典型相关分析也会快很多因为CCA本质上也是在找两个空间里相关度最高的投影方向。从更大的视角看这些算法都属于“潜变量模型”家族。它们要解决的问题都是一样的在高维、共线性的数据中找到少数几个潜在因子来解释数据的结构。区别只在于“好”的标准不同——PCA看方差PLS看协方差CCA看相关系数。搞懂了其中一个的手动实现其他的都是类似的套路。6.3 手搓NIPALS的适用场景清单那什么时候值得自己手写PLS而不是直接调sklearn我把自己的判断标准整理了一下学习与研究想彻底搞懂PLS内部机制手写是最好途径。教学培训在给学生或团队成员讲算法原理时直接展示代码比只讲公式有效得多。定制化需求需要在算法中嵌入特定的数据处理流程比如特殊的标准化方法、自定义的收敛准则或者要把它移植到某些不方便安装sklearn的嵌入式环境中。工业部署某些生产环境只允许使用经过审查的白名单库手写版本便于代码审计和修改。性能调试需要精确控制每一步的计算量或者要做基于NumPy的批量并行化时自定义实现往往比黑盒库更加灵活。反过来如果你只是需要一个可靠的模型数据量也不大直接用sklearn完全没问题没必要重复造轮子。手写代码这件事更多是投资在自己的理解力上而不是在产出模型上。7. 两个容易忽略但很实用的实现技巧7.1 权重向量的符号问题NIPALS迭代中w和c的符号是任意的。原因在于如果同时翻转t和w的符号t w的结果不会变。同样翻转u和c的符号也不会影响对Y的拟合。这意味着你两次运行PLS提取出的成分在数值符号上可能完全相反但模型的预测结果完全一致。这在解释载荷时会造成混淆。我建议在使用自己做的手写PLS时统一规定一个符号约定比如强制让w的第一项为正。这样在绘制载荷谱或者报告权重时结论才具备可复现性。实现方法很简单在每轮迭代收敛后加一句判断if w[0] 0: w -w t -t需要注意的是如果你同时改变了w和t的符号那么t w的结果不变但Xk和Yk的残差剥离效果也不受影响。所以这个操作是安全的。7.2 使用np.linalg.lstsq提升数值稳定性在计算回归系数时很多教程会直接写beta W np.linalg.inv(P.T W) C.T。这里有一个隐患当特征维度很高或者成分数较多时P W可能出现接近奇异的情况直接用inv会带来数值不稳定。一个更稳妥的做法是用最小二乘来解。因为T X W的预测目标是T X W (P W)^{-1}所以可以等价为求解线性方程组(P W) B W然后用B去变换新数据。NumPy的np.linalg.lstsq能给出数值稳定的最小二乘解比inv靠谱得多。# 训练时计算转换矩阵 B, _, _, _ np.linalg.lstsq(P.T W, W, rcondNone) # 预测 T_new X_new_std B Y_new_std T_new C.T这个改动虽然不起眼但我在处理条件数很差的数据集时确实遇到过用inv计算出的结果在第七位小数就出现明显偏差的情况。换成lstsq之后稳定性好了很多。在手写算法的过程中这类用最小二乘替代显式求逆的小习惯值得养成。8. 我踩过的几个坑列出来给你避雷把实操中最容易出问题的点汇总成一个速查表方便你日后排查。症状可能原因解决方案迭代不收敛数据未标准化或异常值过多先做标准化再检查奇异值可替换u初值除零或NaN某列标准差为零或t的范数为零标准差加下限分母加eps预测结果系统性偏移预测时用了新样本自身的均值方差必须用训练集保存的mean、std回归系数异常大特征尺度差异大或成分数过多标准化特征用交叉验证重选成分不同成分的结果高度相关没有做残差剥离确保每轮都执行X X - t pY Y - t c两次运行结果符号不一致NIPALS的符号任意性强制统一符号如使w的第一个元素为正载荷解释不通把权重和载荷混为一谈记住w是投影方向p是载荷向量二者意义不同交叉验证误差偏乐观标准化时用了全量数据在训练折内单独拟合标准化参数这里面我最想再强调一次的是最后一行的信息泄露问题。在实际项目中这个错误非常隐蔽因为程序不会报错结果也有模有样只是你得到的误差指标会比真实水平好看不少。等你一上真实数据性能马上打回原形。我在好几个项目里都见过这种“测试集上刷分、部署后翻车”的案例根源就是数据预处理环节没有严格遵循“只从训练集学习参数”的原则。PLSR、PCA、LDA这些线性方法套路都一样预处理阶段的所有统计量均值、方差、甚至选择哪些特征都必须在训练集内计算然后把同一套变换直接应用到验证集或测试集。这条铁律值得刻在工位上。
返回列表