2. 数组形状与维度基础
广播机制之所以能“自动对齐”两个形状不同的数组,前提是 NumPy 对每个数组的形状信息有精确的描述。理解 shape、ndim、size 这三个属性,以及 reshape 与 ravel 这两个形状变换操作,是读懂广播规则的第一步。本节围绕“如何准确描述一个数组的形状”和“如何在不改变数据的前提下重排形状”展开,为后续的广播规则判断打下基础。
三个属性:形状、维数、元素总数
NumPy 数组的形状信息由三个互相配合的属性给出。shape 是一个元组,按“从外到内”的顺序列出每个维度上的长度;ndim 是这个元组的长度,即数组的维数(轴的数量);size 是所有维度长度的乘积,即元素总个数。三者满足关系:size == prod(shape),ndim == len(shape)。
import numpy as np
a = np.arange(24)
print(a.shape, a.ndim, a.size) # (24,) 1 24
b = a.reshape(2, 3, 4)
print(b.shape, b.ndim, b.size) # (2, 3, 4) 3 24
c = np.array([[1, 2, 3], [4, 5, 6]])
print(c.shape, c.ndim, c.size) # (2, 3) 2 6
注意 shape 的书写顺序与索引顺序一致:对 b 而言,b.shape == (2, 3, 4) 意味着 b[i, j, k] 中 i 取 0~1、j 取 0~2、k 取 0~3。这一点在判断广播时非常关键,因为广播规则是从尾部维度(最右侧)开始逐轴比较的,而不是从最左侧开始。若把 shape 的顺序记反,广播判断就会整体错位。
shape 是 (n,) 而不是 (n, 1) 或 (1, n)。这个“单元素元组”的写法常被初学者忽略,而 (n,) 与 (n, 1) 在广播中的行为完全不同:前者可与 (m, n) 逐列对齐,后者则会与 (m, n) 在最后一轴上冲突(除非 n == 1)。reshape:只改形状,不改数据顺序
reshape 的作用是在元素总数不变的前提下,把数组重新解释为新的形状。它遵循“按 C 顺序(行优先)读取原数组元素,再按新形状依次填充”的规则,因此数据在内存中的线性顺序保持不变,改变的只是“如何切分这些元素”。
x = np.arange(12)
y = x.reshape(3, 4)
print(y)
# [[ 0 1 2 3]
# [ 4 5 6 7]
# [ 8 9 10 11]]
z = y.reshape(2, 2, 3)
print(z.shape) # (2, 2, 3)
print(z[1, 0, 2]) # 8
上面 z[1, 0, 2] 的结果是 8,可以这样验证:按 C 顺序,元素 8 在原一维数组中的线性下标是 8;在形状 (2, 2, 3) 下,下标 (1, 0, 2) 对应的线性位置为 1*2*3 + 0*3 + 2 = 8,两者一致。这说明 reshape 并没有搬动数据,只是换了一套“坐标系统”。
实际使用中有两个高频技巧。其一是用 -1 让 NumPy 自动推算某一维的长度:
v = np.arange(24)
print(v.reshape(4, -1).shape) # (4, 6)
print(v.reshape(-1, 3).shape) # (8, 3)
print(v.reshape(2, -1, 4).shape) # (2, 3, 4)
其二是注意 reshape 返回的是视图还是副本。当新形状与原数组的内存布局兼容时,reshape 返回视图,修改视图会影响原数组;当需要对非连续数组(例如经过转置或切片后的数组)重排时,NumPy 可能返回副本。判断方法是查看 y.base is x 或使用 np.shares_memory(x, y)。这一点在后续做原地运算和性能优化时很重要。
reshape 要求新旧形状的元素总数严格相等,否则抛出 ValueError: cannot reshape array of size N into shape (...)。遇到该错误时,先核对 size,再检查是否漏写或多写了某一维;若使用了 -1,还要确认剩余维度能否整除总数。ravel 与 flatten:拉平为一维的两种方式
ravel 把任意形状的数组按 C 顺序拉平为一维数组,返回的通常是视图(内存连续时),因此开销很小;flatten 则总是返回一份副本。二者结果值相同,但在“是否共享内存”上有本质区别:
m = np.arange(6).reshape(2, 3)
r = m.ravel()
f = m.flatten()
r[0] = 99
print(m[0, 0]) # 99,ravel 是视图,改动了原数组
print(f[0]) # 0,flatten 是副本,不受影响
print(np.shares_memory(m, r)) # True
print(np.shares_memory(m, f)) # False
如果只需要读取拉平后的数据做统计或传给某个要求一维输入的函数,用 ravel 更省内存;如果后续要独立修改拉平结果而不污染原数组,则应使用 flatten 或 ravel().copy()。此外,ravel(order='F') 可按列优先顺序拉平,这在处理来自 Fortran 风格数据的场景中会用到,但默认的 C 顺序与 reshape 的填充规则一致,日常使用中保持默认即可。
形状信息如何决定广播是否成立
有了上述基础,就可以用一句话概括广播的形状判断:把两个数组的 shape 右对齐,从最右一轴开始逐轴比较,每一轴上两长度相等、或其中之一为 1、或其中之一缺失(视为 1),则该轴兼容;所有轴都兼容才能广播,结果形状取每轴的最大值。下图把这一判断过程与本节涉及的属性、操作放在同一条链路上,便于对照理解。
以图中示例验证:A.shape = (2, 3, 4)、B.shape = (3, 1)。右对齐后,B 只有两轴,最左轴视为缺失(长度 1)。最右轴 4 与 1 兼容;中间轴 3 与 3 相等;最左轴 2 与缺失的 1 兼容。因此 A + B 合法,结果形状为 (2, 3, 4),B 会沿最左轴被“复制”2 份、沿最右轴被“复制”4 份参与运算。若把 B 改成 (3, 2),最右轴 4 与 2 既不相等也不为 1,广播立即失败并抛出 ValueError: operands could not be broadcast together。
实践中的检查习惯
在写涉及多数组运算的代码前,建议先打印参与运算各数组的 shape,而不是凭直觉假设。一个实用的排查顺序是:先看 ndim 是否一致(不一致时短的一方左侧补 1),再看右对齐后每一轴是否满足“相等或为 1”,最后用 np.broadcast_shapes(a.shape, b.shape) 直接得到预期结果形状,与报错信息对照。对于需要临时对齐的场景,可用 reshape 显式补维,例如把 (3,) 变为 (3, 1) 或 (1, 3),让意图在代码中清晰可见,而不是依赖隐式广播去“猜”。
最后提醒一个易错点:reshape 与 ravel 改变的是形状描述,不改变元素的线性顺序;而广播改变的是参与运算时的“逻辑复制”方式,同样不真正复制数据。二者共同构成了 NumPy 高效处理多维数据的形状基础——先能准确描述形状,再能安全地重排形状,最后才能正确判断广播是否成立。