ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Numpy矩阵分割实战:np.split/array_split用法与避坑指南

Numpy矩阵分割实战:np.split/array_split用法与避坑指南

Numpy 里的矩阵分割,也就是np.split和它的一批兄弟函数,是我日常处理数据时用得相当频繁的一类操作。很多人一上来就背 API,结果到真正面对一批形状怪异的数组时,不是报“equal division”的错,就是把 axis 传反,分割出来的子块形状跟预期完全对不上。这篇博文我想换个思路,不从函数文档开始,而是从“你什么时候需要分割矩阵”这个业务场景讲起,把分割背后的原理、常见坑和实际排查经验一起聊透。如果你正在做深度学习的数据分批、图像 patch 切分、时间序列滑窗,或者只是想把一个大数组切成几段分别处理,这篇文章应该能帮上忙。

1. 为什么需要矩阵分割:写代码前先想清楚场景

1.1 一个让我记忆深刻的“分割事故”

有一次我需要把一批 3000 张训练图片分成每批 32 张的小批量喂给模型。当时我图省事,直接写了个循环,每次用切片取data[i*32:(i+1)*32],看起来没什么问题,但后来因为索引计算错误,最后一批漏掉了不少数据,而且整个循环代码又长又难读。后来改成np.array_split(data, 94),一行就搞定了,按顺序切成 94 份,最后一份照样会被照顾到。

类似场景在工作中很常见:把一个大数据集切成训练集、验证集、测试集;把一张大图切成多个小 patch 再逐块处理;把一个时间序列切成多个窗口。这些事情本质上都是同一个操作:沿着某个轴,把一个大矩阵拆成多个小矩阵。Numpy 里split系列函数就是为这些需求准备的。

1.2 分割、切片、reshape 三者不是一回事

很多新人会把“切片”“分割”“reshape”混在一起,实际完全是三件事:

  • 切片:取原数组的一个子区域,结果是单个数组。比如a[1:3, :]。
  • 分割:把原数组切成多段,结果是一个列表,每个元素都是一个子数组。比如np.split(a, 3, axis=0)得到 3 个数组。
  • reshape:改变数组的维度结构,但数据总量和顺序不变。比如把(4, 4)变成(2, 8)。

我习惯把分割理解为“沿某个方向连续切几刀”。切完之后,所有子块组合起来,应该能还原出原始矩阵。这也是我们在写代码时用来验证分割结果是否正确的核心思想。

1.3 哪些场景不该用分割

分割不是万能的。如果你只需要取其中一块数据,直接用切片更高效,完全没有必要生成一个全是子块的列表。比如要取数组的前 10 行,a[:10]干净利落;但如果要同时拿到前 10 行、中间 20 行、剩下所有行,并且后续分别处理,np.split(a, [10, 30], axis=0)才是合适的方案。

另外,如果你的“分割”其实是把一维数组重排成二维再切,比如把(100,)变成(10, 10)再切行,那核心操作其实是reshape+ 切片,而不是单纯的分割。想清楚场景再动手,能省很多不必要的 debug 时间。

2. 基础切割工具一览:np.split / np.vsplit / np.hsplit / np.dsplit

2.1 np.split 的两个核心参数

np.split(ary, indices_or_sections, axis=0)的核心是indices_or_sections,它有两种传法:

  • 传一个整数:表示把数组沿指定轴均分成多少份。
  • 传一个列表:表示在哪些索引位置下刀,分段数量等于“列表长度 + 1”。

axis指定沿哪个轴切。这是最容易踩坑的地方,我的记忆方式是:“axis 指向的是我要切分的那个维度”。比如 shape 是(100, 8),我想按样本数切,100 是第 0 维,所以axis=0;我想按特征列切,8 是第 1 维,所以axis=1。

我给一张最简单的图景:把矩阵想象成一条面包,你握住的方向决定了切出来的片长什么样。垂直于行方向切,出来的每片是若干行;垂直于列方向切,出来的每片是若干列。

2.2 三个便捷别名背后的规则

Numpy 提供了三个包装函数,本质上就是预设了 axis:

  • np.vsplit(a, n):等于np.split(a, n, axis=0),垂直方向切,切成多块行。
  • np.hsplit(a, n):等于np.split(a, n, axis=1),水平方向切,切成多块列。
  • np.dsplit(a, n):等于np.split(a, n, axis=2),沿第三维深度方向切,要求数组至少是三维。

需要注意,hsplit对一维数组也有效,比如np.hsplit(np.array([1, 2, 3]), 3)会得到三个一维数组。这个冷门行为在把一维序列均匀切段时很有用。

2.3 一个可以直接跑起来的最小例子

import numpy as np a = np.arange(12).reshape(3, 4) print("原始数组:") print(a) parts = np.split(a, 2, axis=1) print("沿列方向均分成 2 份:") for idx, p in enumerate(parts): print(f"第 {idx} 块形状:{p.shape}") print(p)

输出大概是两块(3, 2)的矩阵。如果你沿axis=0切成 3 份,会得到三个(1, 4)的矩阵。这里的关键是:切分份数必须能整除该轴的长度,否则会抛ValueError: array split does not result in an equal division。

我在最开始学习的时候,习惯拿np.arange(24).reshape(2, 3, 4)这种“小玩具数组”先跑一遍,把每种axis的输出形状都看一遍。这一步花不了两分钟,但对理解多维分割帮助极大,建议你也试试。

3. 均分有余数时怎么办:np.array_split 的不均匀分配策略

3.1 np.split 等在整除时的尴尬

np.split虽然用起来直观,但对长度要求很苛刻。我处理过的数据里,样本量不能被 batch size 整除的情况太常见了,比如 1000 个样本想切成 3 份,np.split(data, 3)直接报错,因为1000 / 3除不尽。

这类问题的标准解法是np.array_split。它和np.split的关系,简单说就是:允许每段长度不一样,余数会按一定规则分配。

3.2 array_split 的余数到底怎么分

np.array_split(data, 3)对长度为 10 的数组,实际切出来是[4, 3, 3]三段。它的分配规则是:先算出每段的基础长度n // sections,然后把余数n % sections一个一个地分给前面的段。所以长度为 10 的数组分成 4 段时,结果是[3, 3, 2, 2]。

import numpy as np a = np.arange(10) parts = np.array_split(a, 4) print([p.shape[0] for p in parts])

输出[3, 3, 2, 2]。这个行为我觉得挺合理的:它尽量保证各段长度接近,同时把多出来的元素优先放到前面的段,而不是集中在最后。在做交叉验证时,这种策略比“最后一段特别短”要友好得多。

3.3 用 array_split 优雅地写 K 折交叉验证

假设有一个矩阵X,形状是(n_samples, n_features),我想做 5 折交叉验证。以前有同事写了一大堆索引计算代码,其实用array_split可以写得很清楚:

import numpy as np X = np.random.rand(100, 8) y = np.random.rand(100) k = 5 folds = np.array_split(np.arange(len(X)), k) for i in range(k): val_idx = folds[i] train_idx = np.concatenate([folds[j] for j in range(k) if j != i]) X_train, X_val = X[train_idx], X[val_idx] y_train, y_val = y[train_idx], y[val_idx] # 下面就可以开始训练和验证了

这里核心是把样本索引切成了 5 段,再通过索引矩阵取数。整个过程没有手算边界,代码不容易因为索引越界而出错。这个写法在我工作里用了很多次,非常稳。

4. 按索引位置任意开刀:np.split 的第二种用法

4.1 传列表时切在哪些位置

np.split(a, [2, 5, 8], axis=0)表示在行索引为 2、5、8 的位置各切一刀,得到四段:[0:2]、[2:5]、[5:8]、[8:]。请注意,这里是分段数 4 个,不是 3 个。很多人第一次用会默认“三个分隔点 = 三段”,结果检查输出长度时才发现多了最后一段。

这种写法非常适合把数据按比例划分。比如你想把 1000 个样本分成 60% 训练、20% 验证、20% 测试,那么直接:

train_data, val_data, test_data = np.split( data, [600, 800], axis=0 )

得到data[:600]、data[600:800]、data[800:]。清晰、直观、索引完全不会算错。

4.2 想从中间分成两半时的常见翻车

还有一个我见过很多次的错误写法:想从一个(100, 3)的矩阵中切出前 50 和后 50,有人会直接写np.split(data, 50, axis=0)。这个写法确实能分成 50 段,每段 2 行,完全不是预期。正确的写法有两种:

  • 如果确实要均分成两份:np.split(data, 2, axis=0)。
  • 如果要按照某个位置切:np.split(data, [50], axis=0)。

有时候想切前 60 后 40,np.split(data, 2)就不行了,因为除不尽,这时用np.split(data, [60], axis=0)一步到位。我自己的经验是:当你明确知道“切断点在哪”的时候,就传列表;当你只想要 N 份的时候,才传整数。

4.3 和普通切片对比:什么时候使用 split 更划算

如果只是切一次,普通切片确实够用。但当你需要同时对多个轴切分、或者需要一批子块时,np.split的可读性和代码整洁度都更高。

还有一个细微的技术点:np.split的索引分段方式是基于连续切片实现的,因此结果通常共享原数组的内存,不会立刻复制大数据。而如果你用np.arange加上高级索引去掏数据,比如data[[0, 2, 5]],那大概率会生成一个拷贝。这个差异在内存紧张时会很明显,后面第 6 节我会专门展开。

5. 多维矩阵分割与 axis 的魔法:从一个 3D 案例讲透

5.1 别把轴号和“通道”绑定死

很多做图像的朋友看到np.dsplit就说“按通道切”,但通道到底在第几个维度,取决于你的数据布局。如果是(batch, height, width, channels),通道在 axis=3,np.dsplit根本切不了;如果是(batch, channels, height, width),通道在 axis=1,要用np.split的axis=1去切。

我习惯先打印a.shape,再决定用哪个轴。这一步看起来多余,实际上能规避绝大多数形状错乱问题。

给一个三维例子:

import numpy as np a = np.arange(24).reshape(2, 3, 4) print("原始 shape:", a.shape) # 沿第三维(索引 2)切两半 r = np.dsplit(a, 2) print("dsplit 后数量:", len(r)) print("每块 shape:", r[0].shape, r[1].shape)

输出结果会得到两个(2, 3, 2)的数组。这里第三个维度的长度是 4,能被 2 整除,所以没问题。

5.2 把一张大图切成四宫格

实际图像预处理中,把一张图切四宫格很常见。比如有一张(480, 640, 3)的图,想切成上下左右四块,我会这样写:

import numpy as np img = np.random.rand(480, 640, 3) top, bottom = np.vsplit(img, 2) # 沿行方向,各 240 行 top_left, top_right = np.hsplit(top, 2) bottom_left, bottom_right = np.hsplit(bottom, 2) print(top_left.shape, top_right.shape) print(bottom_left.shape, bottom_right.shape)

输出四个(240, 320, 3)的子块。这里vsplit和hsplit都用了整数 2,所以要求对应维度必须刚好能被 2 整除。如果图片尺寸是奇数,就必须改用索引列表或者先crop到偶数尺寸。

切完 patch 再处理是图像领域常见流程,比如逐块去噪、逐块检测,最后可能还要把结果拼回去。拼回时用np.concatenate沿原轴反向操作即可。

restore_top = np.concatenate([top_left, top_right], axis=1) restore_img = np.concatenate([restore_top, bottom_right_holder], axis=0)

不过要注意,如果每块处理完后尺寸发生了变化,比如卷积下采样让宽高变小,那就不能简单concatenate还原了,这属于“有限处理再拼接”,要自己记录坐标信息。

5.3 大数组拆 patch 的循环技巧

在一个模拟项目中,我需要把一张大遥感影像切成很多个小 patch。如果图像大小是(4800, 4800, 3),patch 是(256, 256, 3),直接用np.split并按整数份数切,几乎不可能,因为 4800 除以 256 有余数。我的做法是:

  1. 先计算能整除的份数,比如沿行方向切4800 // 256 = 18份。
  2. 剩余的区域单独切片处理,或者先 pad 成可整除尺寸。

这种方法比起单纯依赖split更灵活。Numpy 的split只是工具,具体的裁剪策略还得结合你的业务边界条件。

6. 分割之后不是终点:视图、拷贝与内存开销

6.1 split 返回的到底是视图还是拷贝

这个问题一度让我在调试时吃了大亏。你可以做个实验:

import numpy as np a = np.arange(12).reshape(3, 4) p0 = np.split(a, 2, axis=1)[0] p0[0, 0] = 999 print("修改子块后,原数组第一个元素变成:", a[0, 0])

你会发现原数组也被改成了 999。因为np.split传整数或列表时,底层走的是基础切片路径,返回的是视图,和原数组共享同一块内存。

这本身是特性不是 bug,它让分割过程不会立刻复制一份大数组,节省内存。但隐患也很明显:如果你在某个子块上做了 in-place 操作,比如归一化、数据增强、填充,原数组会被悄悄污染。我在某个项目里就出现过这种“幽灵修改”,排查了很久才发现是子块视图的问题。

如果你的业务要求各个子块彼此独立,请在分割后显式调用.copy()。比如:

parts = [p.copy() for p in np.split(a, 3, axis=0)]

6.2 reshape 子块时为什么突然多了一次拷贝

视图有一个特点:它有自己的 shape、stride 信息。当你对视图做reshape,如果新形状能通过调整 stride 表达出来,那依然是视图;但在分割场景下,子块往往是非连续的内存片段,reshape可能无法用纯 stride 实现,于是 Numpy 会默默复制一份数据。

换句话说,你对np.split的子块做reshape,可能并没有在原内存上改,而是在副本上改。这时候如果原数组不希望被改动,反倒是一件好事,但如果你期望“既能切又能 reshape 后还共享内存”,就会踩坑。

我的建议是:不要把视图和拷贝的逻辑依赖在“以为 Numpy 会自动优化”上。凡是跨函数操作、需要长期持有的结果,统一显式 copy,这样代码行为最可控。

6.3 大批量分割时的性能体会

有段时间我需要把一个很大的数组按行切分成 1000 段,再分发给多进程处理。直接调np.split(a, 1000, axis=0)会生成一个包含 1000 个视图的列表,这个过程很快,因为没有真实数据拷贝。但如果我继续对每个子块做.copy(),内存立刻就会翻倍。

所以我当时的做法是:进程内只保留索引范围,真正需要数据时才切片。对于 Numpy 而言,内存分配往往比计算更昂贵,尽量延迟 copy,或者只在必要时 copy,是一个很实用的优化思路。

如果数据量已经大到单机内存放不下,通常就不会走np.split了,而是考虑把数据存成多个文件,或者用 Numpy 的 memmap 机制按需读取。分割思路仍然成立,只是“分块”的动作落在了文件层面,而不是内存数组层面。

7. 分割相关的常见报错与排查经验

7.1 “array split does not result in an equal division”是什么鬼

这个报错出现的原因很简单:你在np.split里传了整数份数,但该轴的长度不能被份数整除。

例如:

a = np.arange(10) np.split(a, 3)

会逛一下抛ValueError。这其实是 Numpy 在保护你,避免你拿到一堆长度不一致的段却不自知。

排查步骤:

  1. 打印a.shape,确认对应轴长度。
  2. 计算该长度是否能被份数整除。
  3. 如果不能,改用np.array_split(a, 3),接受长度略有不均;
  4. 如果必须严格等长,先截取可整除的部分,再单独处理剩余。

我在实际项目里通常优先用np.array_split,除非有明确理由要求每一段绝对等长。因为它能处理更普遍的情况,代码不用频繁改。

7.2 axis 传反导致的形状错乱

比如一个(100, 8)的矩阵,你可能想按列切成两半,但写成了:

left, right = np.split(data, 2, axis=0)

这样得到的是上下两块,每块(50, 8),而不是左右。如果你后面的代码假设left的列数是 4,就会引发一连串形状不匹配的报错。

排查方法特别简单,打印结果形状:

res = np.split(data, 2, axis=0) print([r.shape for r in res])

看到形状不符合预期,第一时间就能意识到轴传错了。我建议所有分割代码,调试阶段都先打印一下结果列表里的形状,而不是直接往下写处理逻辑。

7.3 分割后如何验证“没有弄丢任何数据”

分割最怕的是:以为切干净了,其实中间漏了一段。验证策略有两个:

验证 1:长度守恒

axis = 1 total = sum(p.shape[axis] for p in parts) assert total == original.shape[axis], "分割后总长度不守恒!"

验证 2:拼接还原

restored = np.concatenate(parts, axis=axis) assert np.allclose(restored, original) print("分割可逆,验证通过")

如果parts里的子块是通过视图得到的,np.concatenate大概率能完美还原。如果中途做了 copy、修改、裁剪,自然恢复不了,这时不能怪 Numpy,而是要想清楚自己的操作链路。

最后一个我自己常用的排查小习惯:遇到任何分割问题,先拿一个 5x5 或者 3x3 的小数组复现,把中间结果打印出来,看清楚了再上真实数据。小数组一眼能看穿规律,大数组只会增加迷惑性。

分割这个操作看起来简单,但背后的维度、视图、边界条件其实相当考验基本功。希望这篇内容能让你下次面对“把一个矩阵切成若干份”的需求时,能少翻点文档、少掉点坑,直接写出预期内的代码。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进