NumPy数组核心机制与向量化性能优化实践
如果你写 Python 最终要落到数据上——清洗表格、跑模型、画图、把一堆传感器日志拼成矩阵——那NumPy这道坎基本绕不开。它不是一个可有可无的第三方工具,更像整个 Python 数据生态的地基:表格库的底层是它,绘图库拿到数据的第一件事是转成数组,机器学习库的 fit 接口要的就是二维数组,连读一张图片返回的也是数组。很多人学 NumPy 的方式是把文档从头翻一遍,结果看完np.xxx几百个函数,真到写代码还是老老实实敲for循环,跑得慢还不知道慢在哪。这篇我按“能写对”到“知道为什么慢”这条线重新排了一遍,覆盖数组的本质、索引与视图、广播、形状变换、向量化改造、内存控制,以及我这些年踩过的具体坑。刚入门的能照着抄,写过一阵子的能补上认知盲区,重点放在那些文档里写着但没人告诉你“为什么”的地方。
1. NumPy 到底解决了什么问题:整体思路与选型拆解
1.1 从 Python 列表的“装箱”说起
先把最核心的一件事说清楚:Python 的list存数字,存的不是数字本身,而是指向对象的指针。一个[1, 2, 3]在内存里是三个独立的小对象,外加一个装着三个指针的数组,每个指针 8 字节,每个小整数对象还有自己的头部开销。你遍历它、做加法、做乘法,每一次都要做类型检查、取出对象、调用方法、再装箱回去。这条路径对“三个数”完全没问题,对“三百万个数”就是灾难。
NumPy 的ndarray做法完全不同:它向系统申请一块连续的内存,把数值按固定的字节宽度一个挨一个排进去,比如float64就是每个 8 字节,中间没有指针、没有对象头、没有类型标记。配合这块内存,它额外保存几样元信息——数据类型、形状、步长,以及数组的起始地址。读第i个元素时,只需要“起始地址 + i × 8”,一次乘法一次加法就定位完成,CPU 的缓存预取还能顺着往下读,效率差出好几个数量级。
提示:这里的关键不是“NumPy 用什么语言写的”,而是“数据在内存里怎么摆”。同样的 C 代码,如果数据是散落的指针,也快不起来。
理解了这一点,后面很多设计就顺了:为什么切片常常不复制数据(它只是改了起点和步长)、为什么dtype选错会让内存翻几倍、为什么按行遍历和按列遍历速度能差一截(缓存命中率)、为什么“向量化”能快——因为整块内存可以交给编译好的循环一次性处理。
1.2 ndarray 的四个组成部分
把ndarray拆开看,只有四样东西:
| 组成 | 含义 | 出问题时的表现 |
|---|---|---|
| data 指针 | 数据块起始地址 | 视图共享它,改一个动全身 |
| dtype | 每个元素占几字节、怎么解释这些字节 | 精度丢失、整数溢出、内存暴涨 |
| shape | 每个维度多长 | 广播失败、维度对不上 |
| strides | 每个维度走一步跨多少字节 | 转置后“看起来变了、实际没动数据” |
strides是最容易被忽略的一个。对a = np.arange(12).reshape(3, 4),a.strides是(32, 8)——沿第 0 维走一步跨 4 个元素(32 字节),沿第 1 维走一步跨 1 个元素(8 字节)。而a.T的 strides 会变成(8, 32),数据一个字节都没搬,只是换了走法。这也解释了为什么转置几乎不花时间——它只改元信息。
这个认知直接决定一件事:切片和转置拿到的很可能是视图,不是新数组。后面第 3 章会专门讲这个坑有多贵。
1.3 学习顺序的取舍:先放下哪些内容
NumPy 的函数列表长到劝退,但日常真正高频的大概只有三四十个。我的建议是先吃透这四块,其余按需查:
- 数组创建与 dtype:
array、zeros、ones、arange、linspace、empty、full,加上astype。 - 索引与切片:基础切片、布尔索引、
np.ix_配合的花式索引。 - 形状变换与合并:
reshape、transpose、ravel、concatenate、stack、squeeze。 - 聚合与广播:
sum、mean、std、max配合axis和keepdims。
可以先放一放的:结构化数组、np.char系列字符串操作(真要做字符串处理,用专门的库更合适)、np.matrix(已不推荐使用,直接用二维ndarray)、各种np.ma掩码数组。等真遇到需求再回来翻,效率比通读高得多。
2. 环境准备与数组创建的实操要点
2.1 安装、验证与版本差异
安装没什么可说的,pip install numpy就行。如果你还要画图和做表格,一并装上pandas和matplotlib,它们会自动把 NumPy 带上。用 conda 的话装numpy会顺带把底层线性代数库一起配好,跑矩阵乘法时更快。
装完别急着写业务,先跑三行确认环境没歪:
import numpy as np print(np.__version__) print(np.show_config())第二行会告诉你底层用的是哪套线性代数实现、有没有并行加速。第三行是看配置,遇到“同样的代码在同事机器上快三倍”的时候,第一件事就是对比这一行。
版本差异上有几个地方必须注意。一是随机数:老写法np.random.seed(0)配np.random.rand()还能用,但新代码建议统一换成np.random.default_rng(0),拿到一个生成器对象,之后都调它的方法。新接口的算法更稳、不同版本间结果更一致,也不用担心全局状态被别的库改掉。
rng = np.random.default_rng(42) x = rng.normal(size=(3, 4))二是np.float_、np.int_这类别名在较新版本里已经陆续被移出主命名空间,新代码里直接用float64、int64全小写写法,别走别名。
2.2 dtype 与内存估算:先算账再建数组
这是新手最不重视、老手最先看的一环。数组占多少内存,公式是元素个数 × 每个元素字节数:
float64 -> 8 字节 float32 -> 4 字节 int64 -> 8 字节 int32 -> 4 字节 int8 -> 1 字节 bool -> 1 字节举个真实会遇到的账:一张 8000 × 6000 的彩色图,3 个通道,用uint8存是8000 × 6000 × 3 = 1.44 亿字节,约 137 MB,读进来毫无压力。但如果你在预处理时随手写了img.astype(np.float64),字节数乘 8,变成 1.15 GB。要是再叠加几个中间结果没及时释放,内存直接爆掉,报的还是那种看不出原因的MemoryError。
同理,一个 10000 × 10000 的浮点矩阵,float64是 800 MB,换成float32就是 400 MB。如果你的场景是图像、信号、神经网络前向计算这类对相对精度不敏感的任务,float32完全够用,速度还更快——因为同样的缓存行能装下两倍的元素。反之,做累计求和、长时间积分、金融金额计算,老老实实float64,别省这点内存。
注意:整数类型有范围。
int8上限 127,127 + 1会回到-128,而且不一定报错。用小的整型类型前,先确认你的数据不会越界。
估算完内存,建数组时还有个小技巧:如果你明确知道要填满整个数组,用np.empty再逐块赋值,比np.zeros快一点点,因为它省了清零那一步。但只在“确定每个位置都会被写到”的时候用,否则会拿到内存里的残留垃圾值,这种 bug 极其难查。
2.3 五种创建方式的适用场景
| 方式 | 典型用途 | 需要注意的点 |
|---|---|---|
np.array(seq) | 从已有列表/元组转换 | 会推断 dtype,混装类型会全部升级 |
np.zeros/ones/full | 预分配容器 | 大数据用empty更省一步 |
np.arange(start, stop, step) | 整数序列、索引 | 步长是浮点时个数不确定 |
np.linspace(start, stop, num) | 固定点数的等距采样 | 端点是否包含由endpoint控制 |
np.zeros_like(a) | 照着已有数组的形状建 | dtype 也照抄,可能不是你想要的 |
arange的浮点坑单独说一下。np.arange(0, 1, 0.1)你以为是 10 个元素,实际很可能是 10 个,也可能是 11 个,因为 0.1 在二进制里存不准,累加误差会让最后一步刚好卡在边界上。需要固定点数就用linspace:
xs = np.linspace(0, 1, 11) # 明确 11 个点,含两端需要固定步长且步长是小数时,用np.arange(0, 10) * 0.1这种“整数序列乘系数”的写法,点数可控、误差不累积。
3. 索引、切片、视图与广播:最容易踩坑的一段
3.1 三种索引写法的分工
NumPy 的索引有三套体系,用途完全不同,混着用是 bug 温床。
基础索引是方括号里给整数或slice,取出来的是原数组的一个区域,返回视图,改动会反向影响原数组。
a = np.arange(12).reshape(3, 4) b = a[1:, 2:] b[0, 0] = 999 print(a[1, 2]) # 999,原数组被改了布尔索引是给一个同形状的布尔数组,返回副本,是把满足条件的元素挑出来拼成一个新的一维数组。
mask = a > 5 print(a[mask]) # 一维,全是满足条件的值花式索引是给一个整数数组作为下标,同样返回副本。
idx = np.array([0, 2]) print(a[idx]) # 取第 0、2 行,形状 (2, 4) print(a[idx, idx]) # 取 (0,0) 和 (2,2),形状 (1,),容易误解最后这行是经典误解:a[[0,2], [0,2]]不是取 2×2 的子矩阵,而是把两个索引数组按位置配对,取(0,0)和(2,2)两个点。想要“行列交叉取子矩阵”,得用np.ix_:
print(a[np.ix_([0, 2], [0, 2])]) # 这才是 2×2 子矩阵3.2 视图与副本:改不动原数组的真正原因
判断规则其实很简单:基础切片是视图,布尔索引和花式索引是副本。记住这句,一半的“为什么我改了没生效”就有答案了。
真正坑的是这种写法:
sub = a[a > 5] # 副本 sub[:] = 0 # 改的是副本,a 一点没变而这样写是生效的:
a[a > 5] = 0 # 这是赋值操作,直接写回原数组区别在于前者先构造出一个副本再赋值,后者是__setitem__,直接落盘。同理,sub = a[0:2, :]; sub[:] = 0会改到a,因为那是视图。
需要强制断开联系时,用.copy()。判断两个变量是不是指向同一块内存,可以用np.shares_memory(x, y),比is靠谱得多:
print(np.shares_memory(a, a[0])) # True print(np.shares_memory(a, a[a > 5])) # False还有一个隐藏坑:reshape在数据布局允许时返回视图,在需要跨步拷贝时返回副本。同样一行a.reshape(...),换个输入就可能行为不同。如果你打算对 reshape 的结果做原地修改,要么先.copy(),要么先确认np.shares_memory(a, b)的返回值。
3.3 广播规则与三个典型翻车现场
广播在写起来舒服和出错难查之间找到了平衡点。它的规则其实只有三条:
- 从最后一个维度开始往前对齐。
- 每个维度上,两边相等,或者有一边是 1,就能广播;结果维度取较大的那个。
- 不满足就报
operands could not be broadcast together。
简单的例子:(3, 4) + (4,)能跑,因为 4 对 4;(3, 4) + (3,)会报错,因为 4 对 3。想按列加,得把(3,)升成(3, 1):
v = np.array([1, 2, 3]) print(a + v[:, None]) # (3,4) + (3,1) -> (3,4)三个常见翻车现场,我按踩过的频率排:
现场一:列向量撞上其他一维数组,悄悄变成方阵。(1000, 1)和(1000,)相加,结果是(1000, 1000),800 万个float64,64 MB。单次没事,放进循环里就是内存雪崩。解决办法是写广播前用x.shape打印一下,或者显式写成x[:, None]让意图清楚。
现场二:以为在做“逐行减均值”,实际在逐列。归一化时a - a.mean(axis=0)是每列减列均值,a - a.mean(axis=1)就报形状错,因为(3,4)减(3,)不合法。正确的写法是a - a.mean(axis=1, keepdims=True)。
现场三:以为广播会复制数据。广播不复制,它靠“步长为 0”来实现虚拟扩展。这意味着广播结果可能不是连续的,传给某些要求连续内存的底层接口(比如某些 C 扩展)会报错,得先.copy()或np.ascontiguousarray()。
3.4 axis 的方向感与 keepdims 的用处
axis记不住是很多人的心病。我的记法是:axis=k表示“消掉第 k 维”,也就是沿这一维往下压。对(3, 4)的数组,sum(axis=0)把第 0 维压掉,剩(4,),意思是“每一列的和”;sum(axis=1)剩(3,),是“每一行的和”。这个说法比“沿着行/列”清楚得多,因为后者在二维里还勉强,到三维就完全乱了。
keepdims=True是个被严重低估的参数。它保留被压掉的那一维、长度设为 1,于是结果能直接参与广播:
a = rng.normal(size=(3, 4)) a_norm = a / a.sum(axis=1, keepdims=True) # (3,4) / (3,1),广播完美不加keepdims就得手动[:, None],忘一次就是一个隐蔽的错误。做标准化、softmax、各类归一化时,养成加keepdims=True的习惯,能省掉大量调试。
还有个小提醒:对空数组求mean会得到nan并抛警告,np.mean([])就是这样。判空用arr.size == 0,别拿len(arr)判断多维数组。
4. 形状变换、合并与聚合的实用套路
4.1 reshape、ravel、transpose 的关系
这三个词经常被混用,实际分工很清楚。
reshape改形状不改数据顺序,参数里可以给一个-1让 NumPy 自己算。a.reshape(4, -1)对 12 个元素的数组得到(4, 3),算的就是12 / 4 = 3。这个用法面对可变批量时特别顺手,比如把(N, H, W, C)展平成(N, -1)。
ravel和flatten都是拉平成一维,区别在于ravel尽量返回视图、flatten一定返回副本。如果你只是要遍历,用ravel;如果后续要独立修改,用flatten或者显式.copy()。
transpose和.T是换轴,二维下就是行列互换。三维以上要注意默认是完全反转维度顺序,(2,3,4)转置后变(4,3,2),跟你想的“交换前两维”不一样。只想换两维用swapaxes:
x = np.zeros((2, 3, 4)) print(x.transpose(1, 0, 2).shape) # (3, 2, 4),显式给顺序最保险 print(x.swapaxes(0, 1).shape) # (3, 2, 4),同上np.moveaxis更适合“把通道维挪到前面”这种操作:np.moveaxis(img, -1, 0)把(H, W, C)变成(C, H, W),写起来直观,不容易搞错参数顺序。
4.2 stack、concatenate、split 的选择
concatenate是“沿已有维度拼接”,要求其他维度长度一致;stack是“新建一个维度堆起来”,要求所有输入形状完全一致。这两个搞混是高频错误:
a = np.zeros((3, 4)) b = np.ones((3, 4)) print(np.concatenate([a, b], axis=0).shape) # (6, 4) print(np.stack([a, b], axis=0).shape) # (2, 3, 4)从一堆单样本结果拼成 batch,用stack;把多批数据接起来,用concatenate。反过来拆,用split(等分)或array_split(不等分也能切)。array_split更宽容,除不尽时前面的块多一个,这个行为在分批处理里正好可以用。
提示:在循环里反复
concatenate是典型的性能杀手,因为它每次都要重新分配内存并全量拷贝。正确做法是先收集到列表里,循环结束后一次性拼。
4.3 聚合函数与 where、clip、select 的配合
聚合函数本身没什么好说的,值钱的是搭配。
np.where(cond, x, y)是三目运算的数组版,但它在两个分支都会求值,所以别在里面写会报错的表达式。简单条件替换够用;条件多了用np.select:
conds = [a < -1, a > 1] choices = [-1, 1] out = np.select(conds, choices, default=a) # 把越界的值夹到 [-1, 1]这种场景其实np.clip(a, -1, 1)一行就够了,性能还更好。clip专门做上下界裁剪,比手写where快,也更好读。遇到“分箱”需求,np.digitize比一堆where嵌套清爽。
再说两个常用的小工具。np.count_nonzero能统计非零元素,对于布尔数组就是数True的个数,比sum语义更清楚。np.any和np.all加上axis可以做按行/按列的存在性和全真性判断,做数据校验时很顺手,比如找出“整行都是 0”的坏样本:
bad_rows = np.where((x == 0).all(axis=1))[0]4.4 einsum:一行替代多层循环
np.einsum是 NumPy 里性价比最高的一个函数,学会了能把很多嵌套循环压成一行,而且性能不输手写优化。它的写法就是写下标,用逗号分隔输入。
矩阵乘法:
np.einsum('ij,jk->ik', A, B)按行求内积(每行自己和自己的乘积和):
np.einsum('ij,ij->i', A, A)取对角线:
np.einsum('ii->i', A)批量矩阵乘:
np.einsum('bij,bjk->bik', X, Y)其中的规则是:输入里出现、输出里没出现的下标,会被求和消掉;输出里的顺序决定结果维度顺序。刚开始会别扭,写三五次就顺了。判断用不用 einsum 的标准很简单——三个以上维度、两三个操作符组合的时候,用它比写一串transpose + sum更不容易出错。不过要说清楚,einsum并不总是最快的,做标准矩阵乘法时A @ B会走底层优化过的库,明显更快,所以常规矩阵乘还是用@。
5. 向量化改造与性能实测
5.1 把 for 循环改写成向量化的四步法
看到循环就想怎么向量化,是有套路的。我一般是这四步:
- 找出循环在算什么:是对每个元素做独立运算,还是元素之间有依赖(比如累加)。
- 没有依赖的,直接换成数组运算:
x**2 + 2*x + 1这种逐元素表达式,天然就该数组化。 - 有跨元素聚合的,换成带 axis 的聚合函数:
sum、cumsum、mean都有对应函数。 - 有条件分支的,换成
where/select/ 布尔索引。
举个具体的。原始写法:
out = [] for row in data: s = 0.0 for v in row: if v > 0: s += v out.append(s)这其实是“只对正数求和”。向量化后:
out = np.where(data > 0, data, 0).sum(axis=1)还有一类常见情况是“按类别分组累加”,本来要写字典加循环,用np.add.at或者np.bincount能一次搞定:
labels = np.array([0, 1, 0, 2, 1]) vals = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) sums = np.bincount(labels, weights=vals) # 按类别求和5.2 三种写法的耗时对比与原因分析
我拿同一台机器做过一组对照,100 万个浮点数求平方,三种写法:
| 写法 | 大致耗时 | 原因 |
|---|---|---|
| 纯 for + append | 120~180 ms | 每次 append 都可能扩容,逐个装箱 |
列表推导[x*x for x in lst] | 60~90 ms | 少了 append 开销,但仍是逐元素 |
arr ** 2(ndarray) | 2~4 ms | 整块内存交给编译好的循环,向量指令并行 |
这个量级在不同机器上会有波动,但倍数关系基本稳定,几十倍的差距。注意第三行比的是“已经是 ndarray 的数组”,从列表转数组的那次np.array(lst)本身要花时间,大约几十毫秒。所以正确姿势是尽早转换——数据一进来就变数组,别让它以列表的形式在代码里流浪。
再补一个容易被忽略的点:向量化的收益不只在 CPU 指令层面,还在于它省掉了解释器逐行执行的开销。Python 每执行一行循环体都要付出固定成本,一百万个元素就是一百万次,这部分开销在数组运算里完全没有。
5.3 内存布局与缓存友好
同样大小的数组,遍历顺序不同速度能差好几倍。因为 NumPy 默认是 C 顺序(最后一维变化最快),内存里就是按行连续排的。按行遍历时,CPU 读一个元素会顺带把后面几个拉进缓存,下次直接命中;按列遍历时,每次跳一大段,缓存全部失效。
# C 顺序,快 for i in range(n): row = a[i, :] ... # 跨列访问,明显变慢 for j in range(m): col = a[:, j] ...如果业务上确实需要频繁按列操作,有两个办法:一是转置后按行访问,二是创建时直接指定order='F'让数据按列存。后者在跟其他库交互时要注意,有些接口要求 C 连续内存,传 F 顺序的数组会被默默复制一份,反而更慢。
判断一个数组是否连续,用a.flags['C_CONTIGUOUS']。判断两个数组是否共享内存,前面提过用np.shares_memory。这两个检查在排查“为什么这段代码莫名慢”时非常有用。
5.4 大数组的内存控制:dtype、分块与 memmap
数据量上到几百兆之后,策略要从“怎么写快”切换到“怎么不爆”。我按优先级排三个手段。
第一,dtype 降级。前面算过账,float64换float32内存直接减半。图像、音频、特征向量这类场景基本都能降。但降之前先看数值范围,int32装得下的 ID 别用int64,纯粹浪费。
第二,分块处理。不要把 762 MB 的矩阵一次性读进来算,按行切块,逐块处理完再写出去。块大小的选择有个经验公式:目标占用控制在内存的 10% 以内,同时每块别小于几兆,否则函数调用和 IO 开销会盖过收益。
rows_per_chunk = 10000 for start in range(0, n_rows, rows_per_chunk): block = big[start:start + rows_per_chunk] result[start:start + rows_per_chunk] = process(block)注意big[start:start+k]是视图,不复制,这一点在这里帮了大忙。
第三,用np.memmap把磁盘文件当数组用。适合“文件比内存大、但每次只用到一部分”的场景,操作系统会按页把需要的部分读进来:
mm = np.memmap('big.bin', dtype='float32', mode='r', shape=(100000, 1000)) chunk = mm[0:1000] # 只加载这一段代价是随机访问会慢,因为每次都可能触发磁盘读。顺序扫描的话体验很好。用完记得del mm释放映射,否则文件句柄会挂着,Windows 上还会导致文件删不掉。
注意:分块处理后拼接结果时,最好预先用
np.empty分配好输出数组,再按块写入。用np.concatenate循环拼接的时间复杂度是平方级的,块数一多就卡死。
6. 常见问题与排查技巧实录
6.1 报错与异常速查表
| 现象 | 大概率原因 | 处理 |
|---|---|---|
operands could not be broadcast | 从尾部对齐后维度既不相等也不为 1 | 打印两边shape,用[:, None]或keepdims升维 |
| 改了切片原数组没变 | 拿到的是副本(布尔/花式索引) | 用赋值语法a[mask] = x,或先.copy() |
MemoryError | dtype 过大或中间结果叠加 | 降 dtype、分块、及时del |
结果为nan | 空数组求均值,或 0/0 | 用arr.size判空,检查分母 |
| 整数结果莫名其妙变负 | 小整型溢出 | 换int64,或先升 dtype 再算 |
打印被省略成... | 数组太大,显示阈值触发 | np.set_printoptions(threshold=100)临时放开 |
| 除以零只警告不报错 | 浮点除零返回inf,只发警告 | 用np.errstate(divide='raise')让它抛异常 |
np.errstate这个上下文管理器值得单独说,它能把浮点警告变成异常,让问题在发生点就暴露,而不是算完发现一堆nan再回头找:
with np.errstate(divide='raise', invalid='raise'): ratio = a / b6.2 浮点精度与比较
浮点数不能直接==比较,这个大家都知道,但实际写的时候还是会忘。0.1 + 0.2 == 0.3是False。数组层面用np.allclose和np.isclose:
np.allclose(x, y) # 默认 rtol=1e-5, atol=1e-8 np.isclose(x, y, atol=1e-6) # 逐元素比较,返回布尔数组这两个参数的含意要清楚:判定条件是|x - y| <= atol + rtol * |y|。也就是说,当一个数本身很大的时候,允许的绝对误差也跟着变大。如果你的数据量级跨度大(比如同时有 1e-9 和 1e9),光靠默认值会误判,得手动调atol。
nan的处理是另一个坑。nan != nan恒为真,所以判断缺失值只能用np.isnan,不能用==。做聚合时sum、mean遇到nan会传染,全变nan;要忽略缺失值,用np.nansum、np.nanmean这一族。但注意nansum对全nan的切片返回 0,这个默认值经常导致统计结果偏小,最好先统计一下np.isnan(x).sum()心里有底。
6.3 随机数、排序、去重的坑
随机数最常见的错误是在循环里重复设种子,导致每一轮拿到的随机序列一模一样:
for i in range(5): np.random.seed(0) # 千万别这么写 print(np.random.rand())正确做法是开头设一次,之后靠生成器对象持续产出。用default_rng更省心,因为它不依赖全局状态,多个模块互不干扰。
排序方面,np.sort(a)返回新数组,a.sort()原地排序省内存。需要拿到排序后的原始索引,用np.argsort;要拿最大的前 k 个,用np.argpartition比全排序快一个量级:
idx = np.argpartition(scores, -5)[-5:] # 前 5 名的下标,不保证内部有序 top5 = idx[np.argsort(scores[idx])] # 需要顺序的话再排一次小的去重函数np.unique默认会排序,这个行为有时是特性有时是负担。大数组上如果只想去重不在乎顺序,它其实是偏慢的。另外它有几个实用参数值得记住:return_counts=True拿频次,return_index=True拿首次出现的下标,做“去重但保留原始顺序”就靠这个组合:
_, first_idx = np.unique(a, return_index=True) dedup_in_order = a[np.sort(first_idx)]6.4 我自己踩过的五条避坑清单
别在循环里np.append。它每次都新建数组,循环 n 次就是 n 次全量拷贝。换成列表收集 + 最后一次np.array,或者预分配 + 下标赋值。
声明了float32,一算又变回float64。NumPy 在混合类型运算时会向“更宽”的类型提升,float32数组加一个 Python 的float标量,结果可能是float64,内存白省。如果要保持类型,标量也用np.float32(1.0),或者运算后用.astype转回来。
np.array拷嵌套列表时会“猜”类型。一个包含整数和None的列表,可能变成object数组,所有向量化操作全部失效、慢到怀疑人生。从外部读数据后先看x.dtype,如果是object,八成是数据里混了脏东西,先清洗再转。
切片赋值和切片读取不是一回事。b = a[:5]是视图,a[:5] = 0是写回。这两个语义不同,别因为长得像就以为是同一件事。我见过有人写b = a[:5]; b = 0,以为把原数组前五行清零了,其实只是把变量b指向了整数 0。
矩阵乘分别用*和@。*是逐元素相乘,@才是矩阵乘法。这个错在形状刚好对得上时不会报错,结果却完全不同,属于最难查的一类。代码里看到矩阵,先确认一下用的哪个符号。
np.dot和@在二维情况下等价,但三维以上行为不同:np.dot是“最后一个维度和倒数第二个维度相乘”的广义定义,@遵循批量矩阵乘的约定。多批量场景统一用@或np.matmul,别用np.dot。
7. 一点个人体会
NumPy 学到最后,真正的分水岭不是记住了多少函数,而是形成了两个直觉:一是看到数据先想它的形状和内存布局,二是看到循环先想能不能换成整块运算。这两个直觉建立起来之后,写代码时会下意识地避开那些慢写法,出问题时也知道该打印什么——shape、dtype、strides、flags,这四个信息基本上能定位八成以上的诡异现象。
我的建议是先别追求“精通”这个词。把第 3 章的视图与广播、第 5 章的向量化彻底跑通,日常工作中的数组操作就够用了。剩下的einsum、memmap、结构化数组这些,等真遇到对应场景再回来翻,带着具体问题学,一次就记住了。真正要警惕的是那种“代码能跑就行”的心态——数组操作写错往往不报错,只是结果悄悄偏了一点,等到后面分析阶段才发现,回溯成本高得多。