做了几年数值计算相关的工作,Numpy算是我用得最熟的工具之一。但真正让我愿意停下来认真写一篇关于矩阵合并的文章,是因为我发现很多人在这个看起来无比基础的操作上,反复踩同样的坑——维度理解错、合并方向反了、数据量一大就奇慢无比。尤其是刚接触Numpy的朋友,很容易把合并操作当成“魔法”,照着别人的代码抄,一旦输出不对就完全没思路。
Numpy里的矩阵合并,本质上就做一件事:把两个或更多数组按某个方向拼在一起。听着简单,但它牵涉到的维度匹配、内存分配、轴(axis)理解,才是真正拉开水平差距的地方。这篇文章不打算只列函数签名,我会结合自己在实际项目中踩过的坑,把vstack、hstack、concatenate、stack、append这些函数的用法、区别、性能问题一次性讲透,帮你以后遇到合并需求时能直接做出正确的选型和排错。
1. 矩阵合并的底层逻辑:一切都在围绕“轴”转
1.1 合并的本质是沿着某条轴拼接
我们经常把Numpy数组想象成一个表格,说“上下合并”、“左右合并”,这当然没错。但落到代码层面,Numpy并不识别“上下”和“左右”这种人类语言,它唯一认的是轴。轴就是数组的每个维度,二维数组有两个轴:axis=0是行方向,axis=1是列方向;三维数组则有三个轴,展开来说就是“块”、“行”、“列”三个层次。
理解了这一点,再回头看合并就特别清晰:你要合并数组,必须先告诉Numpy“沿着哪条轴把数据拼起来”。用上下合并举例子,意思就是沿着行方向,也就是axis=0,把两个数组“摞”起来。左右合并则是沿着列方向,即axis=1,把两个数组“并排”起来。
可能有人会问:那为什么Numpy里还要专门提供vstack和hstack这两个函数?直接用concatenate指定axis不就行了?这里就牵扯到一个设计理念:vstack和hstack本质上是用更直观的方式封装了concatenate的常用场景,让代码更可读。vstack这个名字就是vertical stack的缩写,表意明确;hstack是horizontal stack,同样一看就知道在干嘛。这种封装对提升代码的可读性帮助很大,尤其是在团队协作时,不用翻上下文就能猜出意图。
1.2 一维数组没有“行”和“列”的概念
一维数组是最容易在合并时产生困惑的对象。它只有一条轴,axis=0,所以一维数组之间的合并本质上就是沿着唯一那条轴做拼接。举个例子:
a = np.array([1, 2, 3]),b = np.array([4, 5, 6])。如果用np.vstack((a, b)),得到的结果是[[1, 2, 3], [4, 5, 6]],形状变成了(2, 3)。原因很简单:vstack的实现内部会先把一维数组统一转换成二维的(1, n)数组,然后再做行方向的拼接。
如果用np.concatnp.concatenate((a, b), axis=0)会得到[1, 2, 3, 4, 5, 6],形状还是(6,)的一维数组。这里就引出一个值得特别注意的点:当你需要把一堆一维数组拼成二维矩阵时,vstack是更方便的。但如果你想保留一维数组的“原始性”做顺序拼接,concatenate更直接。两种场景在实际代码里出现频率都很高,需要自己判断。
1.3 二维以上数组合并时的形状要求
对于二维数组,合并的规则可以用一句非常粗浅的话概括:除了要合并的那条轴,其他所有轴的维度必须完全一致。
也就是说,沿着axis=0合并时,要求两个数组的列数相同。因为你要上下拼,上面的宽度和下面的宽度必须一样,否则拼出来的矩阵不会是规则的矩形。同理,沿着axis=1合并时,要求两个数组的行数相同。
这个规则推广到三维、四维也一样:指定合并轴之外的维度都必须相等。这句话才是合并操作的核心判断依据,比死记“行为要一致”更通用、更准确。
2. vstack、hstack、concatenate、stack、append:五个函数的选型差异
2.1 vstack和hstack的使用场景
vstack最经典的使用场景就是把多个行向量拼成一个矩阵。比如你在做机器学习特征工程时,从每条样本里提取了一个特征向量,训练集有10000条样本,那自然就是循环收集10000个行向量,最后用vstack拼成一个(10000, n)的矩阵。这个操作非常高频,属于那种写着写着就自然出现的代码。
hstack的使用场景则正好反过来。当你手里有几组特征矩阵,列数不同,但样本数量相同,想把这几个矩阵横着并起来作为最终的特征矩阵,那就是hstack的活。比如说一个特征矩阵是形状(10000, 3)的速度特征,另一个是形状(10000, 5)的加速度特征,hstack之后就得到(10000, 8)的完整特征。这叫特征拼接,在数据处理流水线里十分常见。
对比之下有个容易混淆的点:vstack的输入往往倾向于用元组包起来,很多教程也这么写。但传入列表效果完全一样。np.vstack([a, b])和np.vstack((a, b))没有任何差别。真正关键的是传给它的是一堆待合并数组,而不是“数组 + 特别参数”那种感觉。函数内部会遍历这个元组或列表,依次把每一个数组沿对应方向堆叠。
2.2 concatenate才是最通用的合并接口
concatenate是五个函数里最底层、最灵活的一个。它接受一个待合并数组的序列,再接受一个axis参数,默认是axis=0,完全由你来控制合并方向。
举几个例子:
import numpy as np a = np.array([[1, 2], [3, 4]]) b = np.array([[5, 6], [7, 8]]) # 上下合并,结果形状(4, 2) c = np.concatenate((a, b), axis=0) # 左右合并,结果形状(2, 4) d = np.concatenate((a, b), axis=1)上面的代码输出结果应该很好预测。c变成4行2列,d变成2行4列。axis=0这个参数我建议每一位初学者都打印出来看一看,而不是在心里默背。因为在三维数组里,axis的理解才是真正的分水岭。
假设有一个数组形状是(2, 3, 4),你可以把它想象成两块形状为(3, 4)的数据叠在一起。axis=0合并意思是往“块”方向摞,也就是增加块数;axis=1合并是每块内的行方向拼;axis=2合并是每块内的列方向拼。维度升上去之后,单纯用“上下左右”去理解就会失效,但用“沿着第几条轴”去理解就永远不会错。
2.3 stack:不改变维度本质的“分组拼接”
stack和前面的函数有个根本区别:它会在合并时增加一个新的维度。这是它和concatenate最大的分水岭。
np.stack((a, b), axis=0)的意思,是把a和b作为两个独立个体,放进一个新的数组里,新数组的第一个维度长度等于待合并数组的数量。举例来说,两个(3, 4)的二维数组用stack合并,axis=0会得到(2, 3, 4)的三维数组;axis=1会得到(3, 2, 4)的三维数组;axis=2会得到(3, 4, 2)的三维数组。
这种操作看名字就能猜出它的用途:把一组同形状的数组“堆成一批”。比如你在做图像批处理时,每个样本是(32, 32)的灰度图,你想把1000个样本组织成一个(1000, 32, 32)的batch,stack就是最合适的工具。直接把每个图像作为独立个体摞起来,保留它们的分组边界。
如果你问:为什么不能用concatenate配合reshape?答案是可以,但步骤繁琐得多。stack的意义就是把“扩展维度 + 拼接”这个组合操作封装成一步,既少了出错的可能,又让意图更清晰。
2.4 append:看似方便,实则最容易出错
append这个函数在Python的原生列表里特别常用,很多人带着这个习惯转到Numpy,第一反应就是找np.append。但我要提醒你,np.append的语义和原生列表并不完全一样。
np.append(arr, values, axis=None)在没有指定axis的时候,会先把arr和values拉平成一维数组,再顺序拼接。这意味着np.append(np.array([[1, 2], [3, 4]]), np.array([[5, 6]]))不会得到你想象中的二维扩展,而是会得到[1, 2, 3, 4, 5, 6]这种一维数组。很多人踩的第一个坑就在这里——append以后维数莫名其妙降了。
指定axis之后,append的行为其实跟concatenate非常接近,只是它把“待合并数组的序列”变成了“arr和values两个参数”的语法。所以我的建议很直接:如果真的要用append,务必显式传入axis;如果原本就是两个数组要拼接,直接上concatenate,别让append把代码搞得不清不楚。
3. 合并操作的隐藏规则:广播与维度变换对结果的影响
3.1 广播机制为什么会让合并变得“反直觉”
Numpy的广播机制本来是用于算术运算的,让不同形状的数组能进行计算,但它在合并时也偶尔跑出来捣鬼。常见的一种错觉是:既然广播能让(3, 1)和(1, 4)的数组相加得到(3, 4),那感觉上它们也能被合并成(3, 4)。这个想法是错的,因为合并和算术运算走的是两套规则。
广播处理的是“维度不对齐但要参与运算”的情况,合并处理的是“维度必须精确匹配,只沿着指定轴增加长度”的情况。二者本质不同。所以在做矩阵合并前,不要下意识地认为“广播能解决的事情合并也能解决”。合并更严格,它要求待拼接的各数组除了拼接轴以外的所有维度严格相等,这个条件没有任何“自动补全”机制。
我遇到过不少同学在跑深度学习数据预处理时,想要把不同长度的时间序列合并成一个大矩阵,觉得Numpy应该“像广播一样自动把短的补成和长的一样”。这就是典型的混淆。Numpy的合并不会自动填充,它只会直接报错。如果需要填充,你要自己先手动处理对齐问题,比如用pad补齐长度,然后再合并。
3.2 reshape在合并前的黄金用法
由于合并要求形状严格匹配,你在正式合并之前,大概率需要做一次维度整理。这里最常用、最重要的操作就是reshape和newaxis扩展维度。
举个例子。假设你有多个一维数组,想用concatenate沿着axis=1合并成二维矩阵。直接执行np.concatenate((a, b), axis=1)在旧版Numpy中会报错,因为一维数组没有axis=1。解决办法是先把每个一维数组通过a[:, np.newaxis]变成( n, 1)的二维数组,再去做concatenate。
更优雅的一种方案是用np.expand_dims,或者直接用np.column_stack这种专门处理列方向合并的函数。column_stack会内部自动把一维数组转成列向量再合并,省去了手动reshape的麻烦。同理,row_stack是vstack的别名,用来处理“拼行”的情况很顺手。
我自己的经验是,当你发现合并代码里要写一堆reshape时,先停下来想想:是不是选错了合并函数?换一个更贴合需求的函数,往往能省掉一半的维度处理代码。
3.3 dtype不匹配时的隐式转换
合并时另一个容易被忽略的问题是数据类型。Numpy数组是有严格dtype的,两个数组的dtype不同时,合并并不会报错,但会在背后做隐式类型提升。
以一个很实际的现象为例:一个int32数组和一个float64数组合并,最终结果会变成float64。因为float64精度更高,Numpy在合并时会自动选择能同时容纳两种数据类型的“更大”类型。这种隐式转换平时你不会察觉,但如果处理的是大规模数据,内存占用会突然翻倍涨,性能敏感场景下尤其需要注意。
还有一种隐患是你原本以为自己做的是“整数运算”,结果因为某个数组混入了浮点数,后面所有输出都变成了浮点数,最终结果跟预期不符,还很难定位。排查方法很简单:合并后看一眼result.dtype,确认是否符合预期,这在强数据类型要求的项目里是一个值得养成的习惯。
4. 性能与内存:为什么不要用循环拼接
4.1 每次合并都会产生全新数组
Numpy数组和Python列表最大的不同之一是:Numpy的数组合并总会生成一份全新的数据,而不是在原数组上“追加”。这意味着每一次np.concatenate都会申请新的内存,把旧数据复制过去,再释放旧的内存。
如果你在循环里一次次地做合并,比如总共要合并10000个小数组,那就会申请10000次新内存,复制的数据量越来越大。第一轮复制一个小数组,第二轮复制两个小数组的大小,第一百轮复制一百个。循环越多,累计复制的数据量就越大。这个复杂度其实是O(n^2)级别的,数据量小的时候没感觉,数据量一大,肉眼可见地卡顿。
这个性能陷阱,我在实际项目里踩得刻骨铭心。当时我在处理一批传感器数据,每个时间窗口生成一个小数组,我需要把它们全部拼成一个完整矩阵。最初的代码就是循环里np.append,数据量大概是5万多个窗口,结果程序跑了好几分钟还没结束。后来改成先收集到Python列表,最后一次性concatenate,耗时直接降到零点几秒。两者差距悬殊到让人怀疑是不是写错了。
4.2 正确的姿势:先收集列表,再一次性合并
这里说的“正确姿势”其实是一个很经典的优化策略:先用Python原生列表暂存所有待合并数组,利用列表的append操作,最后只用一次np.concatenate或np.vstack完成合并。
为什么这样效率高?因为Python列表的append是原地操作,只负责追加指针引用,并不会复制整个数据。而最后的一次性concatenate,哪怕要复制全部数据,总共也只复制一遍。对比循环里疯狂重复复制,这个优化收益非常可观。
下面是一段对比示例,帮助理解:
import numpy as np import time # 低效写法:循环内用 np.append arr = np.zeros((0, 128)) start = time.time() for i in range(10000): row = np.random.randn(1, 128) arr = np.append(arr, row, axis=0) print("循环 append 耗时:", time.time() - start) # 高效写法:列表收集 + 一次性合并 chunks = [] start = time.time() for i in range(10000): row = np.random.randn(1, 128) chunks.append(row) result = np.vstack(chunks) print("列表收集 + vstack 耗时:", time.time() - start)实跑下来,低效写法在数据量大的时候能慢几十倍甚至上百倍。这是一个值得刻进肌肉记忆的性能准则:合并的调用次数越少越好。
4.3 终极优化:预分配数组加切片写入
如果你的场景里数组数量是提前知道的,那么最极致的做法是预分配一个目标大小的空数组,然后循环往里填数据。这个做法不依赖任何合并函数,只靠Numpy的切片赋值。
n_rows = 10000 result = np.zeros((n_rows, 128)) for i in range(n_rows): result[i] = np.random.randn(128)这样做的好处是只分配一次内存,不产生任何临时大数组,同样不存在重复复制。它的执行效率比“列表收集 + vstack”通常还要略高一点,因为vstack内部也要经历一次完整的数据复制,而预分配方案省掉了这次复制的来源组织过程。
不过,预分配方案也有自己的局限:它要求你提前知道最终的行数。实际操作中如果行数不确定,可以用先收集列表再合并的方案,灵活性更高。两类方式组合起来,基本能覆盖绝大多数合并需求。
5. 我踩过的合并坑:异常信息与排查全过程
5.1 最常见的ValueError:维度不一致
第一次运行合并代码时,我相信每个人都见过类似这样的报错:
ValueError: all the input array dimensions except for the concatenation axis must match exactly
这个报错说的是:除了拼接轴之外,其余维度必须严格相等。它是合并操作里最常见的运行时错误。但报错信息其实不会告诉你具体是哪个维度对不上,所以你得自己排查。
我一般会用一个非常笨但有效的方法:把每个待合并数组的shape逐个打印出来。尤其是在批量处理数据时,可能某个数组形状异常,其他几百个数组都是正常的。如果只盯着报错那次看,花半天也未必找得到问题源头。写代码时加上一段临时的shape检查:
for i, arr in enumerate(arrays): print(i, arr.shape)跑一轮就能定位到异常数组,效率极高。这个习惯帮我省下了大量时间。
5.2 axis参数理解错误导致结果形状不对
还有一个更隐蔽的坑,就是axis选错导致的“结果能跑但不符合预期”。它不会报任何错误,因为维度条件都满足,只是合并方向错位了。
举例说明:有两个形状都是(3, 3)的矩阵,你原本想左右合并得到(3, 6),但axis写成了0,结果得到(6, 3)。如果后续代码没有对形状做强校验,这个错误很可能一路传播到最终结果,直到出图或输出文件时才暴露,到时候定位成本就非常高了。
我的建议是:在手写合并操作时,先心算一遍结果的shape,再打印确认。result.shape是一个比任何断言都简单的检查手段。如果你经常搞混,可以把常用场景写成一个小的辅助函数,把函数的意图和合并方向绑定在一起,减少手滑的可能。
5.3 一维数组与二维数组合并的“伪失败”
处理非结构化数据时,经常会遇到一维数组和二维数组之间的合并需求。直接拿一维数组和二维数组做concatenate,如果axis=1,Numpy会报维度错误,因为一维数组没有第二个维度。
这种情境下很多人的第一反应是reshape,但我觉得更顺手的方案是借助vstack或row_stack。因为它们内部会自动把一维数组转成二维行向量,省去手写reshape的步骤。不过要留意结果形状是否符合预期:一个形状(3,)的一维数组如果用vstack和形状(2, 3)的二维数组合并,最终结果是(3, 3),因为一维数组被当成了一行。
如果你想让一维数组变成一列参与合并,就得主动用arr.reshape(-1, 1),或者用arr[:, np.newaxis]扩展维度。这种场景下没有哪个函数能自动判断你是想“变行”还是“变列”,需要靠人的理解去指定。
5.4 多数组合并数量与内存扩展的边界
一次性合并几千个数组,在性能上比循环合并好很多,但也需要注意输入序列本身的内存占用。比如你有一个列表,里面存了几万个中等尺寸的Numpy数组,这列表本身就已经占了不少内存。再把这些数组交给concatenate,concatenate还需要额外分配一块最终结果大小的内存,于是同时存在“原始数组内存 + 待分配结果内存”两份数据。
如果机器内存本来就不太宽裕,这种操作有可能直接触发内存错误。碰到这种情况,我会改用分块合并策略:先把数组每1000个分成一组,合并得到中间结果,再把中间结果合并成最终结果。这样即使在内存受限的环境下,也能稳定完成大规模合并任务,只是代码会稍微多几行。
6. 更进阶的合并思路:从具体问题反推函数选择
6.1 按需求维度选函数
现在把前面讲的内容汇总成一张选择表,实际开发时可以直接对照着做判断。
| 需求特征 | 推荐函数 | 合并后的维度变化 |
|---|---|---|
| 多个行向量拼成矩阵 | vstack / row_stack | (n,) 系列变 (N, n) |
| 多个列向量拼成矩阵 | column_stack / hstack | (n,) 系列变 (n, N) |
| 沿指定轴拼二维数组 | concatenate | 指定轴相加,其他不变 |
| 保持分组边界堆成新维度 | stack | 新增一个维度 |
| 简单追加单个数组 | append(必须指定axis) | 与concatenate一致 |
| 不需要马上合并,待数据齐了再拼 | 先列表收集,再一次性concatenate | 与选择的基础函数一致 |
这张表只列了常见场景。碰到比较罕见的维度组合,回到第1章讲的“轴”概念去推演,比硬套表格更可靠。
6.2 合并顺序会影响性能吗
当需要合并的数组数量非常多时,除了“一次合并”还是“分多次合并”的选择,合并顺序也会影响临时内存占用。
如果你先合并大的数组再添加小的数组,那么在合并过程中,Numpy需要为“大数组+小数组”的结果分配一块几乎和大数组一样大的新内存,再加上原来的大数组,内存就翻倍了。如果分批做,虽然时间上不会快太多,但瞬时内存峰值明显降低。
我在内存有限的工作站上处理过一批总量接近几个GB的数据,当时的做法是:按时间先后切成几十个分片,每一个分片内部先合并成中等数组,最后再把几十个中等数组合并成最终结果。整个过程内存占用很平滑,没有出现任何OOM(内存不足)报错。
6.3 合并之后数据视图与副本
合并操作返回的新数组,通常是原始数据的副本,而不是视图。这意味着你对合并结果做修改,不会影响原来的数组。这与NumPy里切片操作返回视图的逻辑完全不同,是一个容易让人迷惑的点。
a = np.array([[1, 2], [3, 4]]) b = np.array([[5, 6], [7, 8]]) c = np.concatenate((a, b), axis=0) c[0, 0] = 999 print(a[0, 0]) # 仍然是 1,a 没变很多人会因此浪费不必要的内存,因为副本无法共享底层数据。如果你只是想临时把两个数组组织在一起做批量处理,可以考虑用列表包住它们,而不是真的拼接出全新数组。这样能在不需要数据合并时省掉一次完整拷贝,这个技巧在内存敏感场景里很实用。
7. 写在最后:合并只是起点,学会控制内存才算入门
回头再看矩阵合并这件事,它真正难的地方从来不是函数参数怎么写,而是你能不能清楚地知道每次操作背后发生了什么。合并会复制数据,复制就带来时间和内存开销;axis决定方向,方向错了结果就彻底错;dtype决定精度,精度变了结果可能悄悄偏离预期。
我自己在代码评审里最常说一句话:先确认shape,再讨论性能。很多合并相关的bug,只要打印出中间结果的shape就能立刻暴露。所有藏着的问题都没有那么神秘,要么是维度没对上,要么是方向没搞对,要么是触发了几何级数的内存复制。
如果你正好在写数据处理流程,我建议你做一个小实验:把自己代码里所有np.append替换成“列表收集+一次性concatenate”,再看一遍执行时间和内存占用。我保证你会回来感谢这次重构。合并是Numpy操作里的基本功,但基本功做扎实了,后面才能扛得住更大规模的数据。