1. 为什么要写这份NumPy笔记
我在不少社群和答疑帖里看到过同一个问题:Python基础语法都学完了,列表也能用得挺顺,为什么一接触到NumPy就犯迷糊?其实不是大家学不会,而是大多数人拿到手的资料都是"官方文档翻译版",一上来就给你讲np.array、np.arange、广播机制,但完全没告诉你这些工具在解决什么问题、为什么非得用它。
这份笔记的定位很明确:给那些已经会写Python但不了解NumPy的人,提供一条从零到能独立完成数据分析、科学计算任务的学习路径。它也适合正在准备面试、刷题时遇到数组操作就卡壳的朋友。我会从最底层的设计思想讲起,把数组为什么比列表快、广播到底是怎么回事、索引和变形有哪些坑,全部拆开揉碎说清楚。看完之后,你至少能看懂网上的NumPy代码片段,也能自己写出一套完整的数组处理流程。
我自己最早接触NumPy是被导师逼着处理一批实验数据,那时候完全是碎片化学习,今天学个索引明天学个拼接,结果是学完就忘,写代码时全靠查手册。后来我把整块知识重新梳理了一遍,发现NumPy其实是一个非常自洽的工具库,只要抓住"核心数据结构"和"几个核心思维模式",绝大部分功能都能顺势推导出来。这份笔记就是我当时梳理成果的升级版,里面的案例全是我实际用过的场景,不是教科书式的示例。
2. 先搞懂数组对象ndarray
2.1 从列表到数组,到底改变了什么
Python原生的list非常灵活,能装任何类型的对象,任意嵌套。但这个灵活性是要付出代价的——列表里存的其实是对象的引用,而不是数据本身。比如[1, 2, 3]这个列表,内存里存的是三个指针,每个指针指向某个整数对象。如果你要计算每个元素加上1,Python会先把指针取出来,找到整数对象,创建一个新的整数对象,再把新对象的引用放回列表。这个过程每一步都有类型检查、对象分配的开销。
NumPy的ndarray则完全不同。它是一块连续的内存区域,里面存的就是纯数值数据,每个元素占固定大小的字节数。类型也统一了,整个数组只有一个dtype。这样做的好处有两个:第一,存取元素不需要查引用、做类型检查,直接按偏移量计算内存地址就能拿到值;第二,可以对整块内存进行向量化操作,一次性计算所有元素,而不是逐元素循环。
我们可以用一个简单的对比实验来感受差距。创建一个1000万个元素的列表和同样大小的ndarray,分别做加1操作,测试结果通常列表需要好几秒,而NumPy只需要几十毫秒甚至更快。这个差距在数据量越大时越明显。所以记住一句话:ndarray是"数据本体",list是"对象索引列表"。
2.2 dtype是根基,别用默认值走天下
创建数组时很多人喜欢直接写np.array([1, 2, 3]),然后就不管了。这时候dtype是int64或int32,取决于你的平台。如果数组里混入浮点数,比如np.array([1, 2.5]),整个数组会变成float64。这是NumPy的隐式类型转换规则:高精度类型会吞掉低精度类型。看起来很方便,但很多隐蔽的bug就是这么来的。
举个例子,图像处理中像素值通常是0到255的整数。如果你不小心把数组dtype变成float64,后续做某些处理时可能会得到不符合预期的值。又比如计算平均值时,整数数组也会变成浮点数,这个倒正常,但有些场景你可能希望保持整数类型。养成好习惯:创建数组时显式指定dtype,比如np.array([1, 2, 3], dtype=np.int8)。
dtype的种类非常多,常见的有整数类型的int8、int16、int32、int64,无符号的uint8,浮点型的float16、float32、float64,还有复数、布尔、字符串、对象类型。选择什么dtype直接决定内存占用和计算精度。比如一份10亿像素的灰度图,用uint8只需要1GB,但用float64则需要8GB,差距巨大。
查看数组dtype可以用arr.dtype,查看内存占用可以用arr.nbytes。这两个属性是我调试性能问题时最先检查的两项。如果发现nbytes异常大,大概率是dtype选择不当。
2.3 维度、形状、轴——这三个概念必须刻进脑子里
ndarray有ndim(维度数量)、shape(每个维度的大小)、axis(轴索引)三个核心属性。很多人学NumPy学到后面越来越混乱,问题就出在没把"轴"这个概念想清楚。
简单理解:轴就是维度方向。一维数组只有一个轴,轴0就是沿着数组元素的方向。二维数组有两个轴,轴0是行方向,轴1是列方向。三维数组有三个轴,轴0是"页"方向,轴1是"行",轴2是"列"。很多操作,比如sum、mean、concatenate,都要指定axis参数。如果axis参数理解错了,结果完全对不上。
我教新手的一个土办法:看到np.sum(arr, axis=k),你就想象把你的拇指按在k这个方向,然后把这个方向上的每个"切片"压扁求和,剩下几个维度结果就是几个维度。举个例子,arr形状是(2, 3, 4),np.sum(arr, axis=1)的结果形状是(2, 4),相当于沿着中间那根轴把3个4元素的行向量加在一起。多试几组数据,这个手感自然就有了。
还有一个容易混淆的点:shape是元组,里面数字的顺序对应轴的优先级。reshape时顺序是从外到内的,也就是先固定第一个轴,再固定第二个轴。(2, 3, 4)表示2个3x4矩阵,而不是3个2x4矩阵。理解这一点,后面学转置、广播都会顺很多。
3. 数组创建的完整兵器谱
3.1 从已有数据创建:array、asarray、frombuffer
最基础的是np.array,它可以把列表、元组、嵌套列表、其他数组转换成ndarray。它默认会复制数据,也就是说新数组和原数据不共享内存。np.asarray则不同,如果传入的已经是ndarray,它不会复制,直接返回原对象。这在小数据量时无感,但大数据量或需要保持原数组不变时很关键。
np.frombuffer是从字节流创建数组,通常用于读取二进制文件或与底层库交换数据。比如你有一段音频的原始字节,可以用np.frombuffer(data, dtype=np.int16)一次性解释成数组,效率远高于逐字节循环。这个功能在图像、音频处理中非常实用。
3.2 规则序列生成:arange、linspace、logspace
np.arange类似于内置的range,但返回的是ndarray,且支持浮点数步长。比如说np.arange(0, 1, 0.1)会生成0.0到0.9的数组。注意浮点数步长可能导致最后的边界点不符合预期,所以如果是要生成固定数量的等间距序列,推荐用np.linspace。
np.linspace(start, stop, num)会生成包含num个点的等差数列,并且最后一个点一定包含stop。比如np.linspace(0, 1, 5)得到[0.0, 0.25, 0.5, 0.75, 1.0]。这在做绘图坐标轴、采样点时几乎必备。np.logspace则生成对数刻度上的均匀序列,适合绘制对数坐标图。
我这里给出一个实际选择建议:如果你知道步长,用arange;如果你知道数量,用linspace;如果你处理的是跨越几个数量级的数值,用logspace。三者对应了三种常见的需求场景,记住了就不会犹豫。
3.3 快速填充数组:zeros、ones、full、empty
np.zeros(shape)生成全0数组,np.ones(shape)生成全1数组,np.full(shape, value)生成指定值填充的数组。还有一个比较特殊的是np.empty(shape),它只分配内存,不初始化内容,所以里面的值是不确定的(通常是内存中的残留数据)。如果你马上要往数组里填值,用empty可以省掉一次初始化开销,但如果忘了填就直接用,很可能得到莫名其妙的结果。我个人建议新手尽量别用empty,除非你明确知道在干什么。
这类函数通常会带一个dtype参数,创建时可以一起指定。比如np.zeros((3, 4), dtype=np.float32)。
还有几个常用变体:np.eye(n)生成n阶单位矩阵,np.identity(n)类似,np.diag(v)可以从一维数组生成对角矩阵,也可以提取矩阵的对角线。这些在线性代数计算里是标配。
3.4 随机数组:吃透random模块
np.random是整个模块,功能非常多。常用的函数包括np.random.rand(d0, d1, ...)生成[0,1)均匀分布的数组,np.random.randn(d0, d1, ...)生成标准正态分布的数组,np.random.randint(low, high, size)生成整数随机数组。此外还有np.random.seed(seed)用来固定随机种子,保证实验可复现。
值得注意,新版NumPy推荐使用Generator接口,即rng = np.random.default_rng(seed),然后rng.random(size)、rng.normal(...)、rng.integers(...)。旧接口虽然还能用,但官方已不推荐,而且底层算法不同。从长期维护和学习的角度,我建议直接学新接口。我在自己做模拟实验时,统一用default_rng管理随机状态,避免多线程下种子污染问题。
随机数组的典型应用包括:初始化神经网络权重、生成模拟数据、蒙特卡洛模拟、数据集的随机采样等。选对分布函数很重要,均匀分布、正态分布、泊松分布、指数分布各有各的用途,别图省事全用rand。
4. 索引与切片:进阶玩法与常见翻车点
4.1 基础索引与切片规则
ndarray的索引和切片语法与Python列表类似,但有一些额外的语义。基本使用方式:
arr[i]取第i个元素;arr[i:j:k]切片,i为起始索引,j为结束索引(不含),k为步长;- 负索引从末尾开始计数;
- 多维数组可以用
arr[i, j]方式同时索引多个轴,这里的逗号分隔等同于arr[i][j],但效率更高。
切片得到的是原数组的一个视图,而不是拷贝。这是NumPy与Python列表最重要的区别之一。如果你修改切片数组的内容,原数组也会跟着变。这个特性在需要提取子块并修改时很有用,但如果不小心,会带来"为什么原数据变了"的困扰。想要强制拷贝可以用arr.copy()。
4.2 花式索引:用整数数组索引
除了基本的切片,NumPy支持用一个整数数组作为索引。比如indices = np.array([0, 2, 4]),然后arr[indices]会取出对应位置的元素。这种索引方式得到的通常是拷贝而不是视图,因为它无法有效映射为连续内存块。花式索引可以用来从数组中随机抽取样本、按指定顺序重排行等。
多维花式索引稍微复杂些。比如arr[[0, 1], [2, 3]]并不是取(0,2)和(1,3)两个点吗?实际上它会分别从第一个索引数组和第二个索引数组的对应位置配对,所以确实取的是(0,2)和(1,3)两个元素。如果想取整个子区域,应该用np.ix_或者使用冒号。这个坑我踩过不止一次。
还有一个特别有用的花式索引技巧:用布尔数组作为掩码。arr[arr > 5]会返回所有满足条件的元素组成的一维数组。这是数据过滤的利器,比写循环筛选干净得多,速度也快得多。布尔掩码也可以用于赋值操作,比如arr[arr < 0] = 0把所有负数清零。
4.3 索引中的视图与副本问题
判断一个操作返回的是视图还是副本,是NumPy新手最常问的问题。大致规律是:
- 基础切片:视图
- 整数数组索引:副本
- 布尔掩码索引:副本
- 使用
...和冒号的组合:视图 np.take和np.compress:副本
如何验证?可以对结果赋值,或者利用np.shares_memory函数来判断。这个函数检查两个数组是否共享底层内存。在我的实际工作中,如果遇到无法确定的情况,就直接运行np.shares_memory(result, original)查一下,不用靠猜。
4.4 一个实战示例:股票数据的滑动窗口提取
假设你有一段股票收盘价序列,长度为100,窗口大小为10,想提取所有连续10天的窗口。用索引实现了:
import numpy as np prices = np.random.default_rng(42).random(100) * 100 # 方式一:用循环拼接(不推荐) windows = np.array([prices[i:i+10] for i in range(91)]) # 方式二:用滑窗函数 from numpy.lib.stride_tricks import sliding_window_view windows = sliding_window_view(prices, window_shape=10)sliding_window_view返回的是原数组的视图,不复制数据,非常适合大数组滑动窗口操作。这个函数看起来冷门,但处理时间序列、信号滤波时很常用。当然也可以用as_strided,但那是危险操作,稍不留神就会越界访问内存,不推荐新手碰。
5. 形状操作:reshape、transpose、concatenate 全指南
5.1 reshape 与 resize:变形的本质
reshape是让数组按特定顺序重新解释维度,总元素数必须一致。比如形状(12,)的数组可以变成(3,4)或(2,6)或(2,2,3),但不能变成(5,2)。默认按C顺序(行优先)填充,即最后一个轴变化最快。所以才会有reshape时元素排列的规律:[1,2,3,4,5,6]变成(2,3)得到[[1,2,3],[4,5,6]]。
reshape返回视图还是副本?如果源数组是连续内存且形状兼容,返回视图;否则返回副本。简单判断方法:如果变换前后的形状能够通过改变步长来实现,就是视图。不过一般不用纠结,如果你需要独立数据,手动.copy()即可。
np.resize则更加灵活,它允许输出形状与原来不同,并在元素不足时重复原数组填充。但这里有个坑:np.resize和ndarray.resize的行为不一致,前者返回新数组(可能重复),后者原地修改且填充0。我建议普通场景用reshape就够了,resize用不好会带来意外。
5.2 transpose与swapaxes:轴的重新排列
transpose用于交换轴的顺序,参数如果是空,则反转所有轴。比如二维数组arr.T就是转置,相当于arr.transpose(1,0)。三维及以上的数组,transpose需要传入一个轴顺序元组,比如arr.transpose(0,2,1)就把轴1和轴2互换。
swapaxes是更简单的轴互换操作,只需要指定两个轴索引。如果只是交换两个轴,用swapaxes更直观。这两个操作返回的是视图,不复制底层数据,但轴的顺序变了,内存布局也变了。后续操作时要注意是否需要np.ascontiguousarray来换成连续的数组,某些C扩展或复杂操作需要连续内存,否则报错。
5.3 拼接与拆分:concatenate、stack、split
将多个数组合并成一个,常用np.concatenate,它沿现有轴拼接,要求除了拼接轴之外的其他维度必须一致。比如两个形状(3,4)的数组沿轴0拼接得到(6,4),沿轴1拼接得到(3,8)。
如果要把一维数组合成二维数组,比如把两个数组作为两行,更适合用np.vstack(垂直堆叠)或np.row_stack;作为两列用np.hstack(水平堆叠)。还有np.dstack按深度堆叠,一般少用。
np.stack则创建一个新轴来拼接。比如两个形状(3,4)的数组,np.stack([a,b], axis=0)得到(2,3,4),axis=1得到(3,2,4)。这在批量处理数据、构造三维数据集时非常有用。注意stack和concatenate的区别:concatenate不增加维度,stack增加一个维度。
拆分操作则是反向的:np.split(ary, indices_or_sections, axis)。它会按指定位置或份数切分数组,返回一个子数组列表。还有np.vsplit和np.hsplit,分别是按行和按列拆分,常用于把数据分成训练集测试集。
5.4 扁平化:ravel和flatten
要把多维数组变一维,ravel和flatten两个函数都能做到。flatten总是返回拷贝,ravel尽可能返回视图,只有在无法表示成连续视图时才拷贝。所以如果只是为了取一份一维数据且不担心修改原数组,用flatten更安全。如果需要高性能且明确只需要视图,用ravel。不过大多数场景区别不大。
还有一个用C顺序和F顺序的区别。默认是C顺序(行优先),如果要按列优先展平,需要指定order='F'。在配合Fortran风格的数据文件或旧代码时可能会用到。
6. 计算与统计:从基础运算到向量化思维
6.1 逐元素运算与数组减法
NumPy最让人舒服的一点是,算术运算符直接作用在数组的每个元素上。arr + 1、arr * 2、arr1 + arr2等,都是逐元素计算。这里隐含了一条规则:当两个数组形状一致时,按对应位置计算;当形状不一致时,尝试广播。广播机制我单独讲,这里先说逐元素。
比较运算符也一样:arr > 0返回布尔数组,arr[arr > 0]就能选出正数。逻辑运算需要借用np.logical_and、np.logical_or,或者用短操作符&、|,但注意&和|在数组上是逐位布尔运算,用法是(arr > 0) & (arr < 10),必须加括号,否则会报错或得到奇怪结果。
还有一个容易犯的错:用if arr > 0:判断整个数组的条件,这是不允许的,因为数组是多值的。应该使用np.any(arr > 0)或np.all(arr > 0)来汇总判断。
6.2 聚合函数:sum、mean、std的axis逻辑
聚合函数包括np.sum、np.mean、np.std、np.var、np.min、np.max、np.argmin、np.argmax、np.median等。它们都可以指定axis参数,决定沿哪一个方向聚合。
前面我提过"按轴压扁"记忆法,这里再细化一下。arr形状(3,4),np.sum(arr, axis=0)把每列的元素相加,相当于第0维消失,结果形状(4,);np.sum(arr, axis=1)把每行的元素相加,结果形状(3,)。如果axis不填,则把所有元素加到一个标量。
keepdims参数经常被忽略,但实际操作很有用。设置keepdims=True会让被聚合的维度保留为1,比如arr.sum(axis=1, keepdims=True)的结果形状是(3,1),这样做减法或除法时可以利用广播避免维度不对的问题。我在处理数据标准化时经常这么写:
data = np.random.rand(100, 5) mean = data.mean(axis=0, keepdims=True) std = data.std(axis=0, keepdims=True) normalized = (data - mean) / std如果不加keepdims=True,mean的形状是(5,),跟(100,5)减会广播成(100,5),其实也能算对,但有些场景就容易出错。养成加keepdims的习惯能减少很多麻烦。
6.3 广播机制:你必须透彻理解的底层规则
广播是NumPy最强大也是最容易懵的特性。一句话解释:当两个数组形状不一致时,NumPy会把较小的数组"拉伸"到与较大数组相同的形状,然后逐元素计算。拉伸不是复制数据,而是在逻辑上扩展,底层没有额外内存开销。
具体规则有三条:
- 如果两个数组的维度数不同,形状较短的数组在前面补1,直到维度数相同。
- 从最后一个维度开始比较,如果两个维度的大小相等,或者其中一个为1,则可以继续比较;否则无法广播。
- 最终结果的每个维度大小为两个数组中较大的那个(如果某个维度一个为1一个为n,则结果为n)。
举例:形状(3,1)和(1,4)可以广播成(3,4)。形状(3,)和(4,)不能直接广播(除非手动加维度)。形状(3,)和(2,3)则可以广播,因为(3,)会变成(1,3)再扩展成(2,3)。
理解广播的关键是"从后往前对齐"。我最开始学的时候总是搞反,后来用表格记录:
| 数组A形状 | 数组B形状 | 能否广播 | 结果形状 |
|---|---|---|---|
| (3, 1) | (1, 4) | 能 | (3, 4) |
| (3,) | (2, 3) | 能 | (2, 3) |
| (4,) | (3,) | 不能 | - |
| (5, 1, 3) | (4, 3) | 能 | (5, 4, 3) |
广播让代码更简洁,但也很容易掩盖错误。比如本意是将两个不同长度的向量相加,结果广播后得到一个大矩阵,你不会立刻发现。排查时可以用np.broadcast_arrays把广播后的形状显式打印出来看看。
6.4 矩阵乘法:dot、@、matmul
*是逐元素乘法,矩阵乘法则要用np.dot或者@运算符,两个作用相同。在高维数组上,np.matmul和@支持批量矩阵乘法,而np.dot在某些高维情况下的逻辑不同。简单记忆:一维和二维情况两者没区别,三维以上建议用@,按广播批量做矩阵乘法。
矩阵乘法要求左矩阵的列数等于右矩阵的行数,形状不满足时直接抛错。这个错误很好定位。实际中容易混淆的是维度的次序,比如计算(3,4) @ (4,2)得到(3,2),很多人算出(4,2)就是因为没搞清行与列。
6.5 实际应用:数据标准化与归一化
用数组操作做标准化是再常见不过的任务了。有一组特征矩阵X,形状(n_samples, n_features),要对每个特征做零均值单位方差标准化:
mu = X.mean(axis=0) sigma = X.std(axis=0) X_scaled = (X - mu) / sigma由于广播机制,X - mu时mu的(n_features,)会广播到每一行。但有一个微妙的地方:如果n_features恰好等于n_samples之一,广播方向可能不是你期望的那条。比如X形状(5,5),mu形状(5,),按广播规则会沿着最后一个维度减,也就是按列减,这符合特征标准化。如果mu的长度等于行数且你想按行减,就需要mu[:, np.newaxis]变成(5,1)。这种维度陷阱只有理解了广播机制才能避开。
归一化到[0,1]也很简单:
X_min = X.min(axis=0, keepdims=True) X_max = X.max(axis=0, keepdims=True) X_norm = (X - X_min) / (X_max - X_min + 1e-8)加一个1e-8防止除零,这是工程中的常见小技巧。
7. 高级技巧与性能优化:别只会写慢循环
7.1 向量化:把循环榨干
很多新手拿到问题第一反应是写for i in range(n)循环,但NumPy的哲学是"能向量化就不要循环"。向量化也就是用数组运算替代逐元素Python循环,利用底层C实现和连续内存获得巨大加速。
举个例子,如果要对数组的每个元素做sin(x)并求和,用循环:
s = 0 for x in arr: s += np.sin(x)用NumPy写法就是:
s = np.sin(arr).sum()两者结果一样,但后者快几十倍。实际项目里,向量化不仅提升速度,代码也更简洁、更易读。要把循环改写成向量化,核心是识别出"操作被应用到每个元素上"的模式,然后用对应的通用函数(ufunc)替代。
7.2 通用函数ufunc:add、multiply、where、clip等
NumPy的通用函数是向量化操作的基础。np.add(a, b)等价于a + b;np.multiply(a, b)等价于a * b。类似的还有np.divide、np.power、np.mod等。比较函数np.greater、np.less等在布尔运算底层也用得到。
更实战的函数是np.where和np.clip。np.where(condition, x, y)根据条件从x或y中选择元素。这比用循环加判断高效得多。例如将数组中的正数保留,负数替换为0:
out = np.where(arr > 0, arr, 0)np.clip(arr, min, max)把小于min的值替换为min,大于max的替换为max。这在处理异常值、限制数值范围时非常常用。这两个函数可以说是数据清洗的左右手。
np.unique返回数组的唯一值,默认排序。可以加上return_counts=True获得每个唯一值的出现次数,用于统计类别分布。在数据分析中比用字典遍历快很多。
7.3 巧用布尔掩码做条件计算
布尔掩码除了索引过滤,还能实现按条件赋值。例如:
arr[arr < 0] = 0如果要把小于0的设为0,大于100的设为100,可以用组合条件:
arr[(arr < 0)] = 0 arr[(arr > 100)] = 100或者用np.clip一步完成。再比如把数组中的偶数乘以2、奇数加1,可以构造两个布尔掩码分别操作。要注意的是,一次只能用一个掩码做连续赋值,不然会出现链式索引视图问题。
7.4 内存布局:C顺序与F顺序
ndarray在内存中按某种顺序存储元素。默认是C顺序(行优先),即一整行排完后排下一行。另一种是F顺序(列优先),像Fortran语言一样先排完一整列。内存布局影响访问模式的局部性,从而影响性能。
当你按行遍历一个二维数组时,用C顺序布局最友好;按列遍历时,用F顺序更快。虽然NumPy的向量化操作不需要显式写循环,但底层还是会有内存访问模式的问题。在大型矩阵运算中,布局不合适可能导致缓存命中率降低。
创建数组时可以通过order参数指定:
np.ones((3, 4), order='F')转换布局可以用np.asfortranarray和np.ascontiguousarray。如果你跟C库交换数据,通常要确保是C顺序。检查一个数组的内存布局可以用arr.flags查看C_CONTIGUOUS和F_CONTIGUOUS属性。
7.5 性能测试与优化实战
我处理过一个模拟任务,需要反复对一个100万元素数组做指数运算和累积求和。最初用Python循环,跑一次要几十秒;改成np.exp和np.cumsum之后,耗时降到十几毫秒。这个差距让我彻底转变了写法习惯。
np.cumsum是沿指定轴做前缀和,返回与原数组形状相同的数组。它可以用O(n)时间完成所有前n项和的计算,比不停调用sum快得多。类似的还有np.cumprod。
在做性能优化时,我习惯先用time.perf_counter或%timeit测量基线,再逐步替换瓶颈。优化的顺序是:先向量化,再考虑dtype和内存布局,最后才是并行化。不要一开始就想着用多进程,NumPy底层很多操作已经释放了GIL或使用BLAS,简单的数组运算本身就有不错的并行度。
8. 文件读写与数据交换
8.1 保存与加载npy、npz
np.save('data.npy', arr)将数组以二进制格式保存为.npy文件,np.load('data.npy')可以读回。.npy文件保存了数组的shape、dtype、数据本体,读回时无需再指定元信息,非常方便。如果要保存多个数组,用np.savez('data.npz', a=arr1, b=arr2),读回来的是一个NpzFile对象,可以像字典一样按名字取数组。
二进制格式比文本文件更节省空间,读写速度也更快。但需要记住:.npy格式只有NumPy生态自己用,别的软件不认。如果要跨软件交换,就要用下面提到的文本或通用格式。
8.2 文本文件的读写:loadtxt与genfromtxt
np.loadtxt可以从文本文件读入数值数据,默认以空白字符分隔,支持跳过表头行、指定列。它要求所有数据都是数值,如果有缺失值或者混合类型,会报错。这时要用np.genfromtxt,它支持处理缺失值、自动判断dtype、甚至可以指定转换函数。
比如常见CSV文件:
data = np.genfromtxt('data.csv', delimiter=',', skip_header=1, dtype=float, filling_values=0)filling_values可以把缺失值填成指定数字。genfromtxt比loadtxt慢不少,所以如果数据是干净的纯数字,优先用loadtxt;数据不规整时才用genfromtxt。
写文本文件用np.savetxt,可以指定分隔符、格式字符串。例如:
np.savetxt('out.csv', arr, delimiter=',', fmt='%.6f')这非常适用于把计算结果输出给其他软件处理。
8.3 与其他库的数据互转
NumPy数组和Pandas的DataFrame、Series之间转换很频繁。pd.DataFrame(arr)可以基于二维数组创建DataFrame,df.to_numpy()可以取回数组。注意df.values也能取,但官方更推荐to_numpy,因为它更明确。
图像处理中,OpenCV读取的图像直接是ndarray,形状(height, width, 3),dtype是uint8。PIL读取后的Image对象要转成数组用np.array(image)。这在图像预处理、保存中间结果时几乎必用。
8.4 一个数据管线的例子
假设你有一个iris.csv数据文件,包含5列数值,前4列是特征,最后1列是类别标签。读取并划分:
import numpy as np raw = np.genfromtxt('iris.csv', delimiter=',', skip_header=1) X = raw[:, :4] y = raw[:, 4].astype(int) # 洗牌并划分训练集与测试集 rng = np.random.default_rng(42) order = rng.permutation(len(X)) X, y = X[order], y[order] split = int(0.8 * len(X)) X_train, X_test = X[:split], X[split:] y_train, y_test = y[:split], y[split:]这个流程几乎代表了机器学习预处理的标准起点:读文件、切片特征、转换标签、打乱顺序、划分数据集。掌握这些NumPy操作之后,你会发现大部分数据处理场景都是这些组件的组合。
9. 常见坑与排查技巧实录
9.1 视图导致的数据污染
开篇就提到切片是视图,这里再展开讲。有一次我对一个大数组做切片后,直接对切片赋值想修改部分数据,结果把原数组也改了。当时排查了很久才意识到arr[2:5] = 0这种操作本身就会改动原数组,因为切片返回的是视图。这其实不是bug,而是设计如此,但如果你误以为得到了副本,就会踩坑。
避免的方法是:如果想要独立副本,始终使用arr[2:5].copy()。另外,如果函数内部对传入的参数做切片赋值,外部传参的数组也会被污染。写库给别人用时,最好在函数开头用np.array(arr, copy=True)拷贝一份,防止外部变量被意外修改。
9.2 形状不对引发的广播误解
上面提过,广播时形状不匹配通常会报错,但有一种情况不会报错却会悄悄做错:行数和列数恰好相等时。比如数组X形状(5, 5),向量v形状(5,),要做X - v,你的意图可能是按行减,结果实际是按列减。原因是广播默认沿最后一个轴对齐,向量v会被视为一行,而不是一列。
要按列操作,必须先把向量变成列向量:v.reshape(-1, 1)或v[:, np.newaxis]。我在做数据预处理时常提醒自己:涉及一维向量和二维矩阵混合运算,一定要先想清楚希望向量沿哪个轴广播,再显式加维度。
9.3 浮点数比较的坑
计算np.linspace(0, 1, 5)之后,你可能会觉得arr[2] == 0.5成立,结果它返回False。因为浮点数0.5在某些二进制表示下是精确的,但1/3这种就不是。在布尔掩码里用精确相等判断浮点数很危险,应该用接近比较:
mask = np.isclose(arr, 0.5)np.isclose默认相对误差和绝对误差都很小。还有np.allclose用于比较两个数组是否元素接近,适合检查算法结果。
9.4 np.random在旧接口中的全局状态问题
如果你一直用旧接口np.random.seed,然后在多个模块之间不断设置种子,很容易产生"看似设置了种子但结果还是随机"的困惑。原因是整个模块共享一个全局随机状态,任何一个库调用np.random都可能改变状态。新版default_rng则是每个Generator对象独立状态,互不干扰,这对并行实验更友好。
记住:复现实验的正确方式不是反复调用np.random.seed,而是显式创建Generator并传入或保存种子。
9.5 性能下降的隐藏来源
有时候同样的操作,不同写法性能差很多。最常见的隐藏性能问题是:
- 在循环中不断
np.concatenate,导致反复分配内存; - 使用了dtype为
object的数组,完全失去向量化优势; - 对大数据集使用
genfromtxt而不是二进制加载; - 没有利用
axis聚合,而是用Python列表推导做嵌套循环。
解决的办法很简单:尽量先把所有数据组装成一个大数组,一次性处理;如果必须逐步积累,用np.empty预分配再填充;数据量足够大时优先保存成.npy,后续加载比文本快一个数量级。
9.6 快速问题排查清单
我把日常调试NumPy代码的检查顺序整理成了如下清单,遇到问题可以从上到下过一遍:
| 现象 | 可能原因 | 检查方法 |
|---|---|---|
| 结果形状不对 | 广播方向理解错误 | 打印arr.shape和arr2.shape,手动推导广播 |
| 原数据被意外修改 | 切片视图未复制 | 检查是否有链式索引、是否有切片赋值 |
| 掩码选择结果不符合预期 | 浮点数比较精度 | 改用np.isclose |
| 运行速度极慢 | 未向量化、dtype为object | 检查arr.dtype、arr.nbytes,重写为ufunc |
| 内存占用爆炸 | dtype过大或不断拼接 | 检查arr.itemsize,优化dtype或预分配 |
| 随机结果无法复现 | 用旧接口全局种子 | 改default_rng,固定种子 |
这个清单是我在实际项目中反复验证过的,覆盖了九成以上的新手问题。
10. 学习路线与后续扩展
从这份笔记出发,如果你想继续深入,可以按这个顺序扩展:
掌握
np.linalg模块中的矩阵求逆、特征值、奇异值分解,这是线性代数应用的基础。我最初接触SVD是从压缩图像开始的,把一个512x512的图像分解后,只保留前50个奇异值就能重建出清晰可辨认的图像,直观感受到矩阵分解的威力。学习
numpy.polynomial做多项式拟合,或者直接用np.polyfit。这个在曲线拟合、回归分析中很实用。尝试与Pandas、Matplotlib联用。NumPy负责底层数据计算,Pandas提供更高层的数据结构和缺失值处理,Matplotlib负责可视化。三者结合才能完成一个真正的数据分析项目。
如果你想进入深度学习领域,最好先吃透NumPy的索引、广播、矩阵乘法,因为这些概念在深度学习框架的tensor操作中完全通用。
我在实际使用中的一个体会是:NumPy的学习不必面面俱到,抓住数组、数组创建、索引、形状、广播、向量化这几个核心,已经能覆盖90%的日常需求。剩下的20%功能,等真正遇到问题时查文档学会也不迟。泛泛地把所有函数看一遍不如把这些核心概念彻底弄懂来得有效。
最后再分享一个小技巧:写代码时遇到不确定的操作,不妨直接在交互式环境里建一个小数组,用np.shares_memory、arr.flags、arr.shape这些属性去验证自己的猜测。这比死记文档快得多,也会让你对数组行为产生直觉。毕竟NumPy文档很完整,但真正融会贯通还是得靠手上的实践。希望这份笔记能帮你少走我当年走过的弯路。