写代码这些年,见过太多人在mean这个函数上栽跟头。原因倒不是它难——难的东西大家反而谨慎,会去翻文档、会写测试;恰恰是因为它太简单,简单到所有人都觉得"求个均值而已,一行搞定",然后在后面几十行代码里被一个转了向的维度折磨一下午。matlab 数组的均值这件事,本质上考的不是你会不会敲mean(A),而是你对数组维度结构、数据类型转换、脏数据分布的理解到底有多深。这篇就按我平时带新人、以及自己在项目里反复踩坑的顺序,把这件事从头掰开讲一遍。
内容会覆盖几个层面:mean到底返回什么形状、dim参数怎么理解才不别扭、NaN 和整数类型会怎样悄悄改变结果、大数组下怎么算才不把内存撑爆,以及那些不报错但结果明显不对的静默错误怎么排查。刚入门的朋友可以把它当成一份避坑指南,有几年经验的老手也能在性能和边界条件那几节里找到点东西。所有结论都来自实际跑过、被坑过的场景,不是照着帮助文档抄一遍。
1. 一个函数三副面孔:先弄清 mean 最后交给你什么
1.1 为什么数组均值比想象中更容易出错
很多人对mean的心理预期是"把所有元素加起来除以个数",也就是数学课本里那个标量结果。但 MATLAB 的mean默认行为并不是这样,它把数组看成一组沿着某一维排列的数据向量,然后对这一维做压缩,返回一个维度少一的数组。这个设计对做信号处理、做统计分析的人非常友好——一列数据代表一个通道,一个通道出一个均值,天然符合直觉。可对刚上手的人来说,这就是第一个认知断层:你以为在算一个数,它却还给你一串数。
真正麻烦的地方在于,这种"还给你一串数"的行为往往不会报错。如果你的下游代码恰好又对结果做了广播运算,形状不对也可能跑得通,只是数值全错。我见过最典型的一个例子是计算某个指标相对均值的偏差,代码写成B = A - mean(A),作者以为mean(A)是标量,结果在较老的版本里直接维度不匹配报错,在新版本里因为隐式扩展能跑通,但算出来的是"每个元素减去所在列的均值",和作者想要的"减去整体均值"完全不同。代码不报错,图也画出来了,只是结论是错的,这种 bug 最难查。
所以第一件事必须先固定下来:mean对输入的每一个维度分别做了什么。把这件事想清楚,后面 90% 的困惑都不会发生。
1.2 mean 的返回形状由谁决定
规则其实就一句话:mean沿指定的维度做压缩,被压缩的那一维长度变成 1(在默认情况下直接被移除),其他维度原样保留。不指定维度时,它挑第一个长度不为 1 的维度下手。
对最常见的二维矩阵来说,A是 m×n,那么mean(A)沿着第 1 维压缩,结果是 1×n 的行向量,每个元素是原来一列的均值。想要每行的均值,得显式写mean(A, 2),得到 m×1 的列向量。
A = [1 2 3; 4 5 6]; mean(A) % 1x3 -> [2.5 3.5 4.5],每列的均值 mean(A, 2) % 2x1 -> [2; 5],每行的均值 mean(A, 'all') % 标量 3.5,全数组均值行向量是个特别容易出事的输入。假设x是 1×1000 的行向量,你写mean(x),因为第 1 维长度是 1,函数会跳过它去找第 2 维,返回一个标量,看起来"对了"。但如果x是 1000×1 的列向量,mean(x)返回的同样是标量。两种情况都对,于是你形成了"mean返回标量"的印象,等某天输入变成矩阵,这个印象立刻失效。我的建议是:凡是写mean,除了明确的标量场景,一律显式带上维度参数或者'all'。多敲几个字符,省下的是排查时间。
还有一点值得单独说,'all'这个选项是较新版本才有的语法。如果你维护的是老代码,或者需要兼容旧环境,全数组均值的老写法是mean(A(:)),先把数组展平成一列再求均值。两种写法结果一致,A(:)会额外产生一份数据副本,在超大数组上要留意内存开销。至于mean(mean(A))这种"两次求均值"的写法,只有在行列数相等或者你确实想要"均值的均值"时才等价于整体均值,否则就是彻头彻尾的错误,这是我见过出现频率最高的错误写法之一。
2. dim 参数:所有维度问题的总开关
2.1 mean(A) 默认沿着第一维走,不是整体
把dim理解成"我要把哪一维揉成一团"是最省事的记忆方式。dim = 1表示把行方向压掉,得到列的汇总;dim = 2表示把列方向压掉,得到行的汇总。对三维数组,dim = 3就是把页方向压掉。
这个心智模型的好处是它可以直接推广到任意维数。一个 4×5×6 的数组,mean(A, 3)返回 4×5,每个位置是原来沿第 3 维那 6 个数的均值。你不需要去记"返回的维度顺序是什么",只要记住"被压缩的那一维消失了"。
A = rand(4, 5, 6); size(mean(A, 1)) % 1x5x6 size(mean(A, 2)) % 4x1x6 size(mean(A, 3)) % 4x5 size(mean(A)) % 1x5x6,默认走第 1 维这里有个细节:mean(A, 1)和mean(A)结果一样,但返回都是 1×5×6 而不是 5×6,第 1 维被保留成了长度 1 而不是被删掉。这个差异在做squeeze之前会一直存在,也是很多"维度明明是 5×6 为什么 size 显示 1×5×6"疑问的来源。如果后续要做矩阵乘法或者写入固定形状的预分配数组,用squeeze(mean(A,1))或者提前 reshape 一下更稳妥。
2.2 把 dim 想成被压扁的那一维
我习惯用"压扁"这个说法,因为它同时解释了"哪些元素被聚合到一起"和"结果缺了哪一维"两件事。沿着dim方向的那些元素被视为同一组,它们被合成一个数,这一维就塌了。
对照表可以这样记:
| 写法 | 被压扁的维度 | 二维矩阵下的结果形状 | 语义 |
|---|---|---|---|
mean(A) | 第 1 维 | 1×n | 每列均值 |
mean(A, 1) | 第 1 维 | 1×n | 每列均值 |
mean(A, 2) | 第 2 维 | m×1 | 每行均值 |
mean(A, 'all') | 全部 | 标量 | 整体均值 |
mean(A, [1 2]) | 第 1、2 维 | 标量 | 整体均值(等价 all) |
一旦把"压扁"想明白,你会发现自己写代码时不再纠结,甚至开始主动利用这个特性。比如做归一化,想让每一列减去自己的均值再除以标准差,直接(A - mean(A)) ./ std(A)就完事;想按行归一化,就把维度换成 2。这套写法从较新版本开始支持隐式扩展,老版本得用bsxfun,这也是维护老项目时要特别注意的兼容点。
顺带提一个容易忽略的事实:mean(A)的结果维度顺序和输入是一致的,不会因为压缩了第 1 维就把后面的维往前挪。这个性质对写通用函数很重要,你可以放心地把mean的输出直接塞回同样形状的数组里做减法,只要那个维度还是长度 1 或者你用squeeze处理过。
2.3 'all' 与 vecdim:高维数组的省事写法
处理三维、四维数组时,最烦的就是算"沿某几个维度的均值"。老写法要连着调好几次:
% 想要对前两维求均值,保留第 3 维 M1 = squeeze(mean(mean(A, 1), 2));这种嵌套写法的毛病是中间会产生一个临时数组,而且嵌套层数一多就完全没法读。较新版本提供的vecdim参数直接解决这个问题:
M2 = mean(A, [1 2]); % 直接沿第 1、2 维求均值,结果 1x1x6结果形状是 1×1×6,前面两个维度保留成长度 1,想拿到 6 个数的向量再squeeze一下。同样的语法也能用在sum、std、var、max、min上,属于一套通用的维度约定,学一次到处能用。
提示:用
vecdim的时候,第二个参数是维度编号的向量,顺序无所谓,[1 2]和[2 1]结果一样。但如果维度编号超出ndims(A),会直接报错,这一点在做动态维度计算时要防一手。
还有一个更省事的写法适合"我就要整体一个数"的场景,那就是mean(A, 'all')。相比mean(A(:)),它不需要展平,对大数组更友好。实测在几千万元素的数组上,两者时间差别不大,但A(:)那一步的内存峰值会明显高一截,内存紧张的时候能省一点是一点。
3. 数据不干净时,均值会怎么骗你
3.1 NaN 的传染性
真实数据里几乎不可能没有缺失值,而 NaN 在均值运算中的行为是"传染":只要参与运算的元素里有一个 NaN,结果就是 NaN。原因很朴素,NaN 表示"不是一个数",那么一组包含未知项的数据,其均值当然也是未知的。
X = [1 NaN 3; 4 5 6]; mean(X) % [2.5 NaN 4.5]这个行为本身没毛病,但它带来的连锁反应很隐蔽。假设你有一张 1000×50 的表,某一列只有一个 NaN,mean之后这一列的均值变成 NaN,然后你把它参与归一化,整列变成 NaN,再画图就一片空白,最后报错信息可能出现在十万八千里之外的地方,提示"XX 维度不匹配"或者"图中无可绘制数据",你根本想不到根源是那一个缺失值。
我的习惯是,拿到任何要算均值的数据,先跑两行体检:
fprintf('NaN 个数:%d,Inf 个数:%d\n', nnz(isnan(X)), nnz(isinf(X))); colBad = any(isnan(X), 1); % 哪些列含 NaN这两行成本极低,能挡掉后面一大半莫名其妙的问题。特别是Inf也要查,因为Inf参与均值会把结果变成Inf,比 NaN 更容易被忽略,有些人不把Inf当缺失值看,但它对均值同样是毁灭性的。
3.2 omitnan 与 nanmean 怎么选
知道了 NaN 会传染,接下来就是怎么绕开。MATLAB 提供了两条路,一条是mean的'omitnan'选项,一条是专门的nanmean函数。
mean(X, 'omitnan') % 沿默认维度,跳过 NaN mean(X, 1, 'omitnan') % 显式指定维度和选项 nanmean(X) % 老函数,等价于 omitnan两者数值结果一样,区别在于工程上的取舍。'omitnan'是新语法,把它和dim、vecdim组合起来更自然,比如mean(X, [1 2], 'omitnan'),一行解决高维聚合加缺失值跳过。nanmean是历史遗留,在早期版本里是唯一选择,现在更多见于存量代码。
真正需要警惕的不是用哪个,而是跳过 NaN 之后分母变了。mean([1 NaN 3], 'omitnan')等于 2,参与计算的是 2 个数不是 3 个。这在算"有效率"或者做加权统计时会埋雷:如果某一列有 90% 都是 NaN,剩下 10% 算出来的均值虽然数值合法,但统计意义已经很弱了。稳妥的做法是同时统计有效样本数,设个阈值,低于阈值的结果标记为无效。
validCount = sum(~isnan(X), 1); m = mean(X, 1, 'omitnan'); m(validCount < 5) = NaN; % 有效样本太少的结果作废注意:
'omitnan'会跳过 NaN,但不会跳过Inf。含Inf的切片求均值仍然是Inf或NaN(正负无穷同时出现时是 NaN)。要彻底干净,得先把Inf替换掉再算。
3.3 整数、单精度和逻辑型输入的隐式转换
这一节是我最想强调的部分,因为它制造的 bug 往往不报错、数值看起来还挺合理。
MATLAB 的整数类型是有饱和和溢出行为的,比如int8(100) + int8(100)不会得到 200,而是停在 127。如果mean直接拿整数做累加,稍微大一点的数组必然溢出。所以mean的内部策略是:整数输入默认用双精度累加,返回 double。这个设计救了命,但也带来一个副作用——你以为输入是整数输出也是整数,实际上不是。
I = int8([100 100 100; 100 100 100]); class(mean(I)) % double mean(I) % 100,数值正确数值上没问题,问题出在类型上。如果这个结果要作为索引去访问别的数组,MATLAB 会因为索引必须是整数类型而报错;如果要写回一个整数类型的预分配数组,赋值时会做四舍五入,可能引入你看不见的偏差。较新版本提供了'native'选项,让输出保持输入类型,但那时候就要自己承担溢出的风险了。
mean(I, 'native') % int8,输出类型与输入一致逻辑型数组也值得提一句。mean作用在logical上会返回 double,数值等于true所占的比例。这个特性非常好用,比sum(x)/numel(x)更简洁,统计命中率、通过率的时候我几乎都这么写。但同样要注意结果类型是 double,别拿去做逻辑索引之外的整数运算。
单精度输入的情况是另一回事。single输入会返回single,这个没问题,问题在于累加精度。单精度只有 7 位左右有效数字,几万个元素累加下来误差会明显累积。如果你手上是单精度的大数组,又对精度有要求,先double(X)再算,或者用分块累加控制误差,别指望单精度能给你小数点后六位的准确结果。
3.4 空切片与全 NaN 切片的返回值约定
空数组求均值返回NaN,这个约定很多人第一次遇到会愣一下。逻辑上 0/0 确实没有定义,返回 NaN 是合理的,但代码里如果没防这一手,NaN 会继续往下传染。
mean([]) % NaN mean(zeros(0,3)) % 1x3 的 NaN 行向量 mean(zeros(3,0), 2) % 3x1 的 NaN 列向量空切片的出现频率比想象中高,尤其是在分组统计和条件筛选之后。比如按某个标签分组,其中一组恰好没有数据,取出来就是一个 0×k 的空数组,求均值得到 NaN,后面画柱状图就会出现缺口。这类情况我一般会在分组循环里加一个长度判断,空组直接跳过或者填一个业务上有意义的缺省值,而不是让它变成 NaN 流到下游。
全 NaN 切片和空切片的表现类似,用'omitnan'之后结果同样是 NaN——因为没有任何有效数据参与计算。这两种情况在某些业务语义下应该区分开:空切片是"没有数据",全 NaN 是"有数据但都缺失",报表上可能需要不同的标记。实现上可以用isempty和all(isnan(...))分别判断,别图省事一律当 NaN 处理。
4. 跑得动也要跑得省:性能与内存实操
4.1 内存布局决定快慢
MATLAB 的数组是列优先存储的,同一列的元素在内存里连续排布。这意味着沿第 1 维做归约时,CPU 可以顺着连续内存一路读下去,缓存命中率高;沿第 2 维做归约时,每次都要跨过一整列去取下一个元素,跳着访问,缓存效率低。
这个差异在中小规模数组上体现不出来,但数据量一大就非常明显。我的实测经验是,在几千万元素的方阵上,mean(A, 1)通常比mean(A, 2)快一到两倍,具体倍数取决于缓存大小和数据规模。如果某段代码需要频繁算行均值,把矩阵转置一次再沿第 1 维算,往往比直接沿第 2 维算更快。
m2 = mean(A, 2); % 沿第 2 维,跨步访问 m2b = mean(A.', 1).'; % 转置后沿第 1 维,连续访问这里有个必须提醒的坑:转置一定要用A.'而不是A'。单引号是共轭转置,输入是实数时两者等价,输入是复数时单引号会把虚部符号翻转,结果完全变了。这个坑我在处理复数矩阵时踩过一次,排查了半天才反应过来是转置符的区别。养成习惯,做纯数据搬运的转置一律写.'。
另外,转置本身也有代价,它会复制一份数据。数据量不大的情况下省下的计算时间可能还不够贴补复制的开销,所以要不要转置最好实测一下,别盲目优化。
4.2 大矩阵的分块求均值
当数组大到接近内存上限时,直接mean的风险不在于慢,而在于那一下内存峰值。虽然mean本身不会把整个输入复制一遍,但如果你写的是mean(A(:))或者中间带了A.'转置,临时副本就出现了,几 GB 的数组很可能直接把内存顶满。
稳妥的做法是分块累加。以计算每列均值为例,按行分块读取,累加和,最后一次性除以总行数:
[nRow, nCol] = size(A); blockSize = 2000; colSum = zeros(1, nCol); for k = 1:blockSize:nRow idx = k:min(k + blockSize - 1, nRow); colSum = colSum + sum(A(idx, :), 1); end colMean = colSum / nRow;这个写法把内存峰值控制在"一个行块"的规模,blockSize可以根据可用内存调整。需要注意的是A(idx, :)这次切片本身会产生一份副本,所以块不能切太大;另外如果数据是单精度,累加用的colSum建议显式用 double 初始化,避免精度损失。
分块还有个隐藏好处:可以在同一个循环里顺便算出方差、最大值、最小值,甚至统计 NaN 的个数,一次遍历拿齐所有指标,比分别调mean、std、max走三遍要快得多。这个技巧在处理日志数据、传感器数据时特别省时间。
4.3 增量均值与流式数据
如果数据是逐条到达的,比如实时采集的信号,攒不成一个大数组,那就得用增量更新公式:
m = 0; k = 0; for i = 1:numel(x) if isnan(x(i)) continue; end k = k + 1; m = m + (x(i) - m) / k; end这个公式的含义很直观:每来一个新数据,就在当前均值的基础上,按"新数据与当前均值的差除以已有个数"做一次修正。它的好处是永远只占一个变量的内存,且数值稳定性比"先累加求和再除以个数"要好,因为避免了和越加越大导致的精度损失。
实测在几百万条数据上,增量公式和一次性mean的结果在小数点后十来位才出现差异,日常分析完全够用。但要注意,增量公式对 NaN 的处理需要自己做,if isnan那一行如果漏了,一次缺失值就会把后面的均值全部污染,而且不可逆——因为公式里m已经被 NaN 覆盖了。跳过 NaN 之后计数器k也要跟着跳过,别出现分母偏大导致结果偏小的问题。
4.4 加权均值与复数均值的边界
加权均值在业务里用得很多,比如按样本量加权、按置信度加权。较新版本支持直接把权重向量传给mean:
x = [1 2 3 4]; w = [1 1 2 2]; mean(x, w) % (1*1 + 2*1 + 3*2 + 4*2) / 6 = 17/6老版本的写法是mean(x, 'weights', w),两种都行,看环境。自己手写sum(w .* x) / sum(w)也可以,但要注意权重全为 0 时会得到 0/0 = NaN,以及权重里含 NaN 时同样会传染。
复数均值的处理其实很自然,mean对实部和虚部分别求平均,等价于直接对复数做线性运算,这一点不用担心。真正容易出问题的是前面提过的转置符,以及后续做abs、angle时的取值。复数数组求完均值之后,结果的实部虚部可能都很小,接近 0 的时候会出现数值噪声,判断"是否为零"时要用容差而不是== 0。
提示:加权均值里如果权重是整数类型,MATLAB 会先转成 double 再算,不用担心溢出。但如果你手动写
sum(w .* x),w .* x这一步的结果类型由两者共同决定,整数乘整数可能溢出,该转 double 就老老实实转。
5. 报错与静默错误排查实录
5.1 会报错的那几类
报错反而是好事,至少它告诉你出问题了。常见的几类:
第一类是维度参数越界。mean(A, 3)而A只有两维,会直接报错说维度超出范围。这类错误在处理动态维度时最常见,比如代码里按ndims(A)循环,某次输入维度变少了就炸。
第二类是'omitnan'和'all'之类的选项写错位置。mean(A, 'omitnan')和mean(A, 1, 'omitnan')都是合法的,但如果写成mean(A, 'omitnan', 1)顺序反了就会报错。我的建议是固定一种写法,别在不同文件里混着来。
第三类是版本兼容问题。'all'、vecdim、直接传权重向量这些语法都是较新版本才有的,在老环境里跑会报"参数无效"。维护跨版本项目时,这类错误上线前一测就能发现,但要提前规划好替代写法。
5.2 不报错但结果离谱的几类
这才是真正折磨人的部分。列几个我踩过的:
维度方向搞反。本来要每行均值,写成了mean(A),得到每列均值。如果行列数恰好相等,形状看起来也对,错误完全隐形。防御办法是养成显式写dim的习惯,或者在关键节点加一行assert检查结果形状。
NaN 未处理。前面说过很多次,这里只补一句排查技巧:如果某列的均值是 NaN 但你不确定原因,先看这一列有没有 NaN,再看有没有 Inf,最后看是不是空切片。按这个顺序查,基本一击命中。
整数类型导致的索引错误。mean返回 double,拿去做下标就会报"索引必须为正整数"。这个其实会报错,但错误信息往往指向的是下游那行,容易让人往错误方向查。看到索引类报错,先看看这个索引变量是不是从mean来的。
复数转置用错符号。A'和A.'在实数下没区别,一旦数据里混进复数(哪怕只是某个位置有虚部),结果就变了。这类错误在小数点后一位开始偏离,肉眼极难发现。
单精度累加精度不足。结果看起来"差不多",但和 double 版本一比就有偏差,而且偏差随数据量增大。如果你发现同一份数据两次算出来不一样,先查数据类型和求和顺序。
5.3 排查速查表
下面这张表是我平时排查问题的顺序,按"先看形状、再看数值、最后看类型"组织:
| 现象 | 最可能的原因 | 快速验证方式 | 处理建议 |
|---|---|---|---|
| 返回值是 1×n 而不是标量 | 默认沿第 1 维压缩 | size(mean(A)) | 显式加'all'或指定dim |
| 结果维度与下游不匹配 | 压缩后维度变成长度 1 | ndims/size对比 | 用squeeze或预分配匹配形状 |
| 整列均值全是 NaN | 该列含 NaN 或 Inf | any(isnan(A))/any(isinf(A)) | 'omitnan',并单独处理 Inf |
| 数值偏小或偏大一点点 | 有效样本数被跳过 | sum(~isnan(A))对比行数 | 检查分母,必要时设阈值 |
| 结果是小数,拿去做索引报错 | 整数输入默认返回 double | class(mean(I)) | round或取整后再索引 |
| 复数段结果符号不对 | 转置误用共轭转置 | 检查代码里的' | 统一改成.' |
| 大数组求均值时内存告警 | 中间副本过大 | 用whos看峰值 | 分块累加,避免A(:) |
| 两次计算结果不一致 | 单精度累加误差 | class(A)检查类型 | 转 double 或用分块 |
| 分组统计出现空洞 | 空组或全 NaN 组 | 检查每组元素个数 | 空组单独填充标记 |
这张表里的"快速验证方式"是我自己最常用的几行,基本上一两行命令就能定位到问题区间。真正的排查效率来自"知道往哪看",而不是"能跑多少测试"。
最后再补几个我自己反复踩到、但很少在文档里看到的细节。一是mean对稀疏矩阵的行为值得单独验证,稀疏输入在某些操作下会返回满矩阵,内存占用可能比预期高出一大截,处理大规模稀疏数据时别想当然。二是当数组里有Inf和-Inf同时出现时,均值是 NaN 而不是 0,很多人直觉上觉得会"抵消",实际不会。三是mean和sum配合numel手算的时候,numel数的是全部元素包括 NaN,而mean的'omitnan'分母是有效元素数,两个结果对不上属于正常,别以为是函数出错了。我这几年逐渐养成的习惯是,凡是涉及到均值的代码,写完立刻用一个带 NaN、带整型、维度不对称的小数据集跑一遍,这三类边界条件一测,绝大多数隐藏问题当场就露馅了,比上线后再去翻日志省事得多。