
先讲一个我自己的真实经历。去年做一批传感器特征数据的预处理矩阵是 2000 行乘 60 列每行是一条采样记录每列是一个物理量——温度、振动幅值、电流、转速什么的。我一开始没做任何归一化直接扔进 KNN 分类器结果准确率惨不忍睹大概只有六成出头。排查了半天发现罪魁祸首就是量纲电流这个特征的数值范围是 0 到 15而振动加速度的数值动辄几百甚至上千欧氏距离的计算几乎完全被大数值特征主导小数值特征形同虚设。把数据做了一次行方向的 L2 范数归一化之后同样一个分类器准确率直接拉到九成以上。从那以后先归一化再建模就成了我处理特征矩阵的铁律而这套思路在 MATLAB 里落地核心就是 norm、vecnorm 这几个函数怎么组合。这篇东西就是把我在 MATLAB 里做行/列 L-p 范数归一化的完整经验整理出来适合正在做数据预处理、特征工程或者信号处理的读者参考尤其是那些对归一化到底归在哪个方向仍然模糊的朋友。1. 特征矩阵为什么必须做范数归一化一个被量纲坑过的实例1.1 量纲差异如何摧毁你的距离计算先明确一个概念范数归一化本质是把一个向量无论是一行数据还是一个特征列的长度缩放到某个统一尺度最常见的做法是让向量的 L2 范数等于 1。为什么必须做这一步因为大多数基于距离的算法——KNN、K-Means、SVM 里的高斯核、甚至神经网络里的某些正则化项——都默认不同特征具有同等的数值重要性。可现实里特征矩阵的各个列往往来自完全不同的物理过程一个特征是年龄范围 20 到 60另一个特征是年收入范围 5 万到 200 万。计算两个样本的欧氏距离时收入差异贡献的平方项会把年龄差异完全淹没。我用一个具体数字说明。假设两个样本在归一化前的向量分别是样本 A: [25, 80000]样本 B: [35, 85000]欧氏距离平方等于 (35-25)² (85000-80000)² 100 25,000,000 25,000,100。其中年龄维度只贡献了 100占总量的 0.0004%。换句话说算法眼里这两个样本的年龄和 25 岁与 35 岁的差别毫无关系因为它只看得见收入的差异。1.2 从量纲灾难到范数统一到底归一化后发生了什么做范数归一化之后每个向量的长度L2 范数变成 1每个维度上的分量变成了该维度在向量中所占的相对比例。年龄差距还是 10收入差距还是 5000但二者在单位长度圆上的投影比例发生了根本性变化——年龄维度的贡献不再是绝对差值 10而是除以该向量长度之后的相对贡献。这里有个容易误解的点要提前说清楚范数归一化不等于缩放到 [0,1] 区间。前者除以的是向量的范数得到的是单位向量方向保留、长度归一后者通常用归一化Min-Max Scaling做得到的是固定区间。两者解决的核心问题不一样范数归一化L-p normalization统一向量的长度常用于样本间相似度比较、余弦距离场景。极差归一化Min-Max统一每个特征的数值范围常用于梯度下降类算法。这篇文章讨论的是前者也就是标题里明确点出的 L-p 范数归一化。理解这个区别后面所有代码操作才有方向。很多朋友把两者混为一谈写出的代码虽然能运行但背后的数学含义与业务目标早就偏了。2. L-p 范数的数学底细与 MATLAB 中的对应函数2.1 从数学公式说起L1、L2、L∞ 到底在算什么一个向量 x [x₁, x₂, ..., xₙ] 的 L-p 范数定义为||x||ₚ (∑|xᵢ|ᵖ)^(1/p)其中 p 是大于等于 1 的实数。实际使用中三个特殊取值最常用p 值数学含义几何直觉典型用途p1绝对值之和曼哈顿范数沿着坐标轴走的城市街区距离稀疏解、Lasso 正则、鲁棒性度量p2平方和开根号欧氏范数直线距离最常规的归一化余弦相似度的基础p→∞最大绝对值切比雪夫范数最大单一坐标差值边界约束、Chebyshev 距离计算注意 p1 和 p2 是光滑的p∞ 不是光滑函数这个特性在优化问题里会影响梯度行为。但在特征归一化这个任务里p 的取值主要影响最终向量的偏向性L1 归一化对离群值更鲁棒因为不像 L2 那样放大大的分量L2 归一化是默认首选L∞ 归一化则适合你只关心最大幅值维度的场景。2.2 MATLAB 的 norm 函数向量还是矩阵别搞混了MATLAB 里最基础的范数函数是 norm。语法很直观n norm(v) % 向量 v 的 L2 范数 n norm(v, 1) % L1 范数 n norm(v, inf) % L∞ 范数 n norm(v, p) % 任意 L-p 范数上面这些用法针对的是向量。但 norm 函数有个极易踩坑的行为——当输入是一个矩阵 A 时norm(A) 并不会逐行或逐列求范数而是计算矩阵的谱范数L2 诱导范数即 A 的最大奇异值。norm(A, 1) 返回的是矩阵列绝对值之和的最大值norm(A, inf) 返回的是矩阵行绝对值之和的最大值norm(A, fro) 返回的是 Frobenius 范数所有元素平方和开根号。这意味着直接写 norm(X) 想对整个矩阵归一化是完全错误的操作你得到的是一个大数用它去除矩阵里的每个元素得到的结果是一个标量缩放的矩阵而不是行/列方向上的单位向量化。这正是我最初踩的坑——我一度以为 norm(X) 返回的是每个特征列的范数实际差着十万八千里。2.3 vecnorm专为沿某一维度求向量范数设计的函数R2017b 版本开始MATLAB 引入了 vecnorm这个函数才是做行/列范数归一化的正主。语法forbidn vecnorm(A, p, dim)第三个参数 dim 指定沿哪个维度计算dim 1沿列方向计算得到的是一个行向量每个元素是某一列的向量范数。dim 2沿行方向计算得到的是一个列向量每个元素是某一行的向量范数。我和朋友交流时常用一个类比vecnorm 像是在矩阵里拿着尺子沿某一个方向量长度dim2 就是沿着每行从左到右量一遍dim1 就是沿着每列从上到下量一遍。搞懂这个方向概念行/列归一化就成功了一半。X [1 2 3; 4 5 6]; col_norms vecnorm(X, 2, 1); % 结果为 [4.1231, 5.3852, 6.7082] row_norms vecnorm(X, 2, 2); % 结果为 [3.7417; 8.7750]3. 行归一化还是列归一化先想清楚你的数据到底是什么3.1 行归一化每个样本都是独立单位向量当你的数据每一行代表一个观测样本、每一列代表一个特征时行归一化的目标是让每个样本的范数为 1。这样做之后原本长度差异悬殊的样本比如有的样本数值整体很大、有的很小统一到同一个长度尺度后续比较样本间距离时算法看到的更多是方向上的差异而不是幅度上的差异。这种场景常见于KNN/余弦相似度计算推荐系统用用户行为向量做的相似度匹配。文本数据TF-IDF 特征矩阵每行是一篇文档行归一化后直接用余弦距离。信号处理每行是一个采样窗口的特征片断归一化消除信号幅值差异。操作代码非常简洁R2016b 之后支持隐式扩展% L2 行归一化 X_norm X ./ vecnorm(X, 2, 2); % 加 eps 防除零 X_norm X ./ (vecnorm(X, 2, 2) eps);3.2 列归一化让每个特征拥有同等的初始影响力与行方向相反列归一化针对的是特征自身。每一列看作一个特征向量归一化的目的是让所有特征向量的范数统一为 1从而在新空间里每个特征对距离计算的贡献起始点相同。这种操作在以下场景更合理特征单位差异大但你不希望做 Min-Max 缩放时。稀疏特征矩阵每一列的取值密度差异大时列方向的 L2 归一化能在一定程度上平衡特征的真实扩展。做矩阵分解或 PCA 类算法前的预处理尽管 PCA 更常配标准化但部分场景用范数归一化也有效。代码同样简洁% L2 列归一化 X_norm X ./ vecnorm(X, 2, 1);3.3 方向选错会怎样一个典型的反面案例选择行还是列不是写代码的问题而是业务语义的问题。我见过一个真实的反面案例一位做电力负荷预测的朋友把负荷特征矩阵行是日期、列是不同站点的负荷用行归一化处理结果每个日期样本的负荷总长度变成了 1把不同站点间的绝对负荷差距完全抹平模型精度比不归一化还差。这就是典型的方向选反——该抹平的是不同站点量纲差异应该做列归一化不该抹平的是不同日期的总体负荷水平差异。判断方向我总结了一个一句话法则问自己——你希望哪些向量之间长得差不多如果答案是同一样本的不同特征维度之间的相对模式重要做行归一化如果答案是不同样本在同一特征上的取值尺度应该一致做列归一化。回到上面的例子他关心的是每个站点随时间变化的模式不同站点之间绝对负荷没可比性所以列归一化让每个站点特征列长度一致才是正确的。4. 三种实现路线实测vecnorm、bsxfun 与显式循环4.1 方案一显式循环最直观却最不该在生产代码里用最容易理解的方法是用 for 循环逐行处理% 行 L2 归一化循环写法 X_norm zeros(size(X)); for i 1:size(X, 1) X_norm(i, :) X(i, :) / norm(X(i, :)); end这段代码逻辑完全正确但有两个显著问题。第一是性能差每次循环调用一次 norm涉及逐行的函数调用开销在几百万行的大矩阵上慢得让人崩溃。我用 100 万行乘 50 列的随机矩阵实测循环版本耗时约 2.3 秒而 vecnorm 版本不到 0.05 秒差了四十多倍。第二是代码冗长可读性反而不好。在 MATLAB 这种原生向量化设计的语言里循环写特征归一化属于逆潮流而行。当然显式循环也不是毫无价值——当你的处理逻辑非常复杂比如每行归一化时还要附带不同的权重系数循环反而更灵活。但绝大多数常规归一化场景没必要用。4.2 方案二vecnorm 隐式扩展现代 MATLAB 的推荐写法这是我在实际项目中的首选方案。利用 R2016b 引入的隐式扩展Implicit Expansion可以直接把每行的范数向量和矩阵做逐元素除法% 行 L2 归一化 X_norm X ./ vecnorm(X, 2, 2); % 列 L2 归一化 X_norm X ./ vecnorm(X, 2, 1); % 行 L1 归一化 X_norm X ./ vecnorm(X, 1, 2); % 行 L∞ 归一化 X_norm X ./ vecnorm(X, Inf, 2);原理说明vecnorm(X, 2, 2) 返回一个 size(X, 1) 行 1 列的列向量隐式扩展会让这个列向量沿着水平方向复制和 X 逐元素相除。整个过程没有任何显式的 repmat 或 bsxfunMATLAB 在底层自动广播broadcast。代码只有一行语义清晰性能接近最优。这里额外补充一个我自己常用的技巧范数到底算出来是 0 怎么办比如某一行全为 0或者某一行只有一个非零元素以外全是 0L1/L2 范数很小除出来会造成数值爆炸。处理方式有两种% 方法一加一个极小的正数推荐 X_norm X ./ (vecnorm(X, 2, 2) 1e-12); % 方法二只对范数大于阈值的行做归一化全零行保持全零 norms vecnorm(X, 2, 2); valid norms 1e-12; X_norm(valid, :) X(valid, :) ./ norms(valid); X_norm(~valid, :) 0;方法二逻辑更严谨——全零行归一化后应该还是全零向量而不是变成一个几乎全是零、零星无穷大的坏向量。4.3 方案三bsxfunR2016b 之前的兼容写法如果你的 MATLAB 版本较老R2016a 及更早版本隐式扩展尚未实现需要退回到 bsxfun% 行 L2 归一化老版本兼容 X_norm bsxfun(rdivide, X, vecnorm(X, 2, 2)); % 列 L2 归一化老版本兼容 X_norm bsxfun(rdivide, X, vecnorm(X, 2, 1));bsxfun 的功能本质和隐式扩展一样——把两个不同形状的数组在指定维度上自动对齐并执行二元运算。但说实话现在还在用 R2016a 的机构已经很少了这个写法更多是给老代码维护的人看的。新代码里直接用隐式扩展即可没必要为了兼容性刻意回避现代语法。4.4 三种方案性能与选择对照方案代码行数性能100万×50矩阵适用版本推荐度for norm4约2.3秒所有版本低仅复杂逻辑时用vecnorm 隐式扩展1约0.05秒R2016b高首选bsxfun vecnorm1约0.05秒R2017bvecnorm 依赖中维护老代码时用注意 vecnorm 本身对版本有要求R2017b如果你的旧代码里只有 bsxfun 但没有 vecnorm可以用sqrt(sum(X.^2, 2))手工算 L2 范数效果一样% 老版本手工 L2 行范数 norms sqrt(sum(X.^2, 2)); X_norm bsxfun(rdivide, X, norms);5. 一组完整对比实验归一化如何改变 KNN 分类结果5.1 构造一个量纲失衡的合成数据集为了直观展示行/列 L2 归一化的效果我构造一个人为制造量纲差异的数据集。特征 1 和特征 2 是真正的分类信息分布在两个相对集中的簇里特征 3 是一个数值很大的噪声特征它的取值和类别基本无关rng(42); N 600; % 两个类别 X1 [randn(N/2, 1), randn(N/2, 1), randn(N/2, 1)*200 500]; X2 [randn(N/2, 1)3, randn(N/2, 1)3, randn(N/2, 1)*200 500]; X [X1; X2]; y [ones(N/2, 1); 2*ones(N/2, 1)]; % 划分训练集和测试集 cv cvpartition(y, HoldOut, 0.3); X_train X(training(cv), :); X_test X(test(cv), :); y_train y(training(cv), :); y_test y(test(cv), :);注意第三列被放大到 500 这个量级而且噪声标准差是 200它几乎完全淹没了前两列的分类信息。任何不做归一化的距离计算都会过度关注这一列噪声。5.2 不归一化、行归一化、列归一化三组对照用 MATLAB 自带的 fitcknn 训练一个简单的 KNN 分类器% 原始数据直接分类 mdl_raw fitcknn(X_train, y_train, NumNeighbors, 5); pred_raw predict(mdl_raw, X_test); acc_raw sum(pred_raw y_test) / numel(y_test); % 行 L2 归一化 X_train_row X_train ./ vecnorm(X_train, 2, 2); X_test_row X_test ./ vecnorm(X_test, 2, 2); mdl_row fitcknn(X_train_row, y_train, NumNeighbors, 5); pred_row predict(mdl_row, X_test_row); acc_row sum(pred_row y_test) / numel(y_test); % 列 L2 归一化 X_train_col X_train ./ vecnorm(X_train, 2, 1); X_test_col X_test ./ vecnorm(X_test, 2, 1); mdl_col fitcknn(X_train_col, y_train, NumNeighbors, 5); pred_col predict(mdl_col, X_test_col); acc_col sum(pred_col y_test) / numel(y_test); % 展示结果 fprintf(原始数据准确率: %.4f\n, acc_raw); fprintf(行归一化准确率: %.4f\n, acc_row); fprintf(列归一化准确率: %.4f\n, acc_col);我实际运行这组代码得到的结果是非常典型的原始数据准确率只有 0.65 上下因为模型的邻居判定几乎只看第三列的噪声差异行归一化后的准确率稳定在 0.95 以上因为每个样本的范数被归一为 1 后前两列的真实类簇结构重新主导了距离列归一化的准确率在中间水平大约 0.85 上下它虽然压制了第三列的绝对量纲但没有像行归一化那样同时调整样本内部的相对尺度。这个对比实验的核心结论是归一化方向的选择不是无所谓的它直接决定算法看见的数据结构。尤其是噪声特征虽然量纲大但与标签弱相关的场景行归一化往往比列归一化更能抹平这一列噪声的优势地位——因为它缩放的是整个样本向量的长度噪声列再大也只是一个维度。5.3 测试集归一化必须继承训练集的缩放参数上面代码里我特意用了两个不同的写法——行归一化是逐样本独立计算范数所以测试集可以也按自己的范数归一化但列归一化如果按测试集自身的列范数缩放严格来说会引入测试集信息泄漏正确的做法是使用训练集计算得到的列范数去缩放测试集。% 正确的列归一化用训练集统计量缩放测试集 train_norms vecnorm(X_train, 2, 1); X_train_col X_train ./ train_norms; X_test_col X_test ./ train_norms;这一点在做一个完整机器学习流水线时极其关键。特征归一化的参数无论是 Min-Max 的 min/max还是范数归一化的范数值都只能从训练集估计测试集只能被缩放不能参与统计量的计算。否则验证结果会偏乐观上线后真实效果大幅缩水。这也是数据竞赛里常说的数据泄漏问题最常见的一种来源。6. 常见误用与性能优化那些没人提醒你的细节6.1 误用一对稀疏矩阵做了隐式扩展归一化L2 范数归一化一个隐蔽的坑出现在稀疏矩阵上。假设你的特征是 TF-IDF 矩阵MATLAB 里以 sparse 类型存储。直接写X_norm X ./ vecnorm(X, 2, 2);这一步会把稀疏矩阵转变成稠密矩阵因为右侧的 vecnorm 返回的是非稀疏列向量而隐式扩展除法在 R2018a 之后的版本里对 sparse 和 dense 混合运算时结果往往自动变为 full。如果你的 X 是 100 万行乘 10 万列的稀疏矩阵这个简单的一行代码会直接把内存炸掉。对稀疏矩阵做行归一化正确做法是保持稀疏结构手工计算% 稀疏矩阵 L2 行归一化保持 sparse 输出 norms sqrt(sum(X.^2, 2)); % 注意 X.^2 仍保持稀疏 diag_inv spdiags(1 ./ (norms eps), 0, size(X, 1), size(X, 1)); X_norm diag_inv * X;这里的思路是用一个稀疏对角矩阵左乘原始矩阵每一行乘以该行范数的倒数。这样得到的结果仍然是 sparse 矩阵内存占用和后续计算效率都得到保障。这个方法是我处理文本数据时压箱底的技巧。6.2 误用二把列归一化与标准化Z-score混为一谈列方向的 L2 范数归一化每个特征向量长度为 1与标准化每个特征均值为 0、标准差为 1是两种不同的操作但很多人会混用% 标准化Z-score X_std (X - mean(X, 1)) ./ std(X, 1); % 列 L2 范数归一化 X_norm X ./ vecnorm(X, 2, 1);标准化的核心是让每个特征分布的中心和尺度统一保留特征的分布形态范数归一化的核心是让每个特征向量的欧氏长度统一不改变特征的均值位置。实践中如果后续算法是线性回归、神经网络这类依赖特征尺度统一的标准化更常用如果是计算样本间距离、余弦相似度范数归一化更直接。我的经验是先做标准化清洗异常量纲再做范数归一化让向量长度一致两步可以叠加使用并不矛盾。但你要清楚每一步在做什么而不是随手写个归一化就完事。6.3 性能优化当你的矩阵大到内存吃紧时vecnorm 虽然比循环快几十倍但它需要完整遍历矩阵两遍——一遍算范数一遍做除法。当矩阵大到十几 GB 时这种全内存操作不够友好。我的替代做法是分块处理% 分块行归一化逐块计算并写入文件/内存池 block_size 10000; n_rows size(X, 1); X_norm zeros(size(X)); % 预先分配 for start 1:block_size:n_rows idx start:min(start block_size - 1, n_rows); block X(idx, :); X_norm(idx, :) block ./ vecnorm(block, 2, 2); end分块的大小选多少合适我的经验值是让每个 block 的字节数在 100 MB 到 500 MB 之间既能避免频繁的内存分配开销又不会让单块内存占用过高。对 100 万行乘 50 列的矩阵block_size 取 20000 左右效果不错。分块之后性能几乎不受影响MATLAB 的向量化在块内依然生效但内存峰值显著下降。6.4 数值稳定性大数灾难与预防最后一个细节是数值稳定性。L2 范数计算sqrt(sum(X.^2, 2))在数值很大的情况下存在中间溢出风险。比如某一行包含 1e154 这种量级的数平方后直接爆掉 double 的上限约 1.8e308。教科书式的稳定算法是先求该行元素绝对值的最大值再对最大值归一化后的向量求范数% 数值稳定的 L2 范数对大数值向量 max_abs max(abs(X), [], 2); X_scaled X ./ max_abs; % 先缩放到最大值不超过 1 norms sqrt(sum(X_scaled.^2, 2)) .* max_abs; % 再乘回来严格说vecnorm 在 MATLAB 实现里已经做了内部稳定化处理对绝大多数合理数据不会溢出。但如果你的数据来自物理量超大场景比如天文计算或高能物理特征我的建议是写进自己的工具函数里一劳永逸。这段是我的实操体会特征归一化看起来是流水线里最简单的环节但它能成就一个模型也能毁掉一个模型。方向选错、稀疏结构被破坏、测试集统计量泄漏——每一个坑我都踩过。现在我把这套行/列 L-p 范数归一化的完整套路沉淀成了自己工具箱里的一个标准模块。你要是也在 MATLAB 里做特征处理建议先理清自己的数据是行语义还是列语义然后把 vecnorm 那三行代码熟练背下来。剩下的坑用一次真实数据的交叉验证就能帮你做出最终裁决。