1. 项目概述:为什么从NumPy开始?
如果你刚开始接触Python数据分析、机器学习或者科学计算,大概率会听到一个名字:NumPy。很多人会告诉你,这是“基础”,是“必学”。但你可能也困惑过,Python本身不是有列表(list)吗?为什么还要额外学一个库?我刚开始用的时候也有这个疑问,直到我尝试用纯Python列表去处理一个10万行5列的数据集,做一个简单的矩阵乘法,那个等待时间让我彻底明白了NumPy存在的意义。
简单来说,NumPy是Python科学计算生态的基石。它提供了一个核心对象——多维数组(ndarray),以及围绕这个对象构建的一整套高效运算函数。它的“快”,不仅仅是代码写得简洁,而是底层用C语言实现的,在内存管理和数值计算上进行了极致优化。当你处理的数据量从几百条变成几万、几十万条时,NumPy和纯Python列表的性能差距是指数级的。可以说,没有NumPy,后续的Pandas、Scikit-learn、TensorFlow等库都无从谈起。这个系列,我们就来彻底拆解NumPy,从最核心的数组对象开始,到各种让你事半功倍的函数。这不是一份简单的API文档罗列,而是结合我多年踩坑经验,告诉你每个函数在什么场景下用、怎么用最高效、以及背后容易忽略的细节。
2. 核心基石:深入理解ndarray对象
在开始调用各种花哨的函数之前,我们必须把地基打牢。NumPy的ndarray(N-dimensional array,N维数组)是理解一切的前提。它看起来像列表,但内在逻辑完全不同。
2.1 ndarray与Python列表的本质区别
很多人把ndarray当成一个“加强版列表”,这是第一个认知误区。我们来做个对比实验:
import numpy as np import sys # 创建一个Python列表和一个NumPy数组,内容相同 py_list = [1, 2, 3, 4, 5] np_array = np.array([1, 2, 3, 4, 5]) print(f"Python列表内存占用: {sys.getsizeof(py_list)} bytes") print(f"NumPy数组内存占用: {sys.getsizeof(np_array)} bytes")你会发现,NumPy数组的内存占用远小于列表。这是因为:
- 同质数据类型:ndarray要求数组内所有元素必须是相同的数据类型(如全是int32,或全是float64)。而Python列表是异构的,可以同时存放整数、字符串、甚至另一个列表。ndarray的这种设计使得它在内存中是一块连续的存储空间,CPU可以高效地进行批量读取和计算(向量化操作)。
- 静态类型:创建数组时,数据类型就确定了。这避免了Python动态类型检查在循环中带来的巨大开销。
- 向量化操作:这是NumPy的灵魂。对数组的运算(如
array * 2)是作用于整个数组的每个元素,这个操作在底层是用C循环实现的,速度极快。而Python列表的[i * 2 for i in list]需要解释器一层层地解析Python字节码,慢得多。
注意:正因为数据类型固定,如果你用一个包含整数和浮点数的列表去创建数组,NumPy会进行“类型提升”,将所有元素转换为更通用的类型(如float),以保证同质性。这有时会导致意想不到的结果,需要留意。
2.2 数组的属性:你的数据“体检报告”
创建一个数组后,立刻查看它的属性,就像医生看体检报告一样,能快速掌握其全貌。这几个属性必须烂熟于心:
arr = np.array([[1, 2, 3], [4, 5, 6]]) # 一个2行3列的二维数组 print("数组维度 (ndim):", arr.ndim) # 输出: 2 print("数组形状 (shape):", arr.shape) # 输出: (2, 3) print("元素总数 (size):", arr.size) # 输出: 6 print("数据类型 (dtype):", arr.dtype) # 输出: int64 (取决于系统) print("每个元素字节数 (itemsize):", arr.itemsize) # 输出: 8 (int64占8字节) print("总字节数 (nbytes):", arr.nbytes) # 输出: 48 (6个元素 * 8字节)ndim:告诉你这是几维数组。机器学习里的特征矩阵通常是2维(样本×特征),图像数据是3维(高度×宽度×通道)。shape:这是最重要的属性之一,一个元组,表示每个维度上的大小。(2, 3)表示“2行3列”。在矩阵运算中,shape必须匹配,否则会报错。dtype:决定了你能存储的数据范围和精度。常用的有np.int32,np.int64,np.float32,np.float64。在深度学习或内存紧张时,选择float32而非默认的float64可以节省一半内存。nbytes:帮你快速估算大数据集的内存占用,对于避免内存溢出(OOM)至关重要。
2.3 高效创建数组的多种姿势
除了用np.array()从列表转换,NumPy提供了多种高效的创建方式,能避免不必要的内存拷贝和初始化循环。
1. 创建占位数组:
np.zeros((3, 4)) # 创建3行4列的全0数组 np.ones((2, 2, 2)) # 创建2x2x2的全1三维数组 np.empty((2, 3)) # 创建未初始化的数组,内容为内存残留值(最快,但需谨慎) np.full((3, 3), 7) # 创建3x3且全部填充为7的数组np.empty最快,因为它只分配内存而不进行初始化。但你必须紧接着用数据填充它,否则里面的随机值会导致程序行为不确定。我通常只在性能瓶颈处且确保会立刻覆盖所有值时使用它。
2. 创建序列数组:
np.arange(0, 10, 2) # 类似range,输出:[0 2 4 6 8] np.linspace(0, 1, 5) # 在0到1之间等间隔生成5个数,输出:[0. 0.25 0.5 0.75 1. ]np.linspace在需要固定数量的数据点(如绘图横坐标)时非常方便,因为它关心的是“点数”,而arange关心的是“步长”。
3. 创建特殊矩阵:
np.eye(3) # 3x3的单位矩阵 np.diag([1, 2, 3]) # 以给定值为对角线的对角矩阵4. 利用现有数组创建:
a = np.array([1, 2, 3]) b = np.zeros_like(a) # 创建一个和a形状、数据类型相同的全0数组 c = np.ones_like(a) # 创建全1数组*_like系列函数在编写通用函数时特别好用,你可以根据输入数组的形状来创建输出数组,而无需硬编码形状。
3. 数组的索引与切片:精准数据操控术
掌握了创建,下一步就是如何高效地取出和修改数据。NumPy的索引功能强大且灵活,但也有一些“坑”。
3.1 基础索引与切片:和列表相似但更强
对于一维数组,索引和切片与列表几乎一致:
arr = np.arange(10) # [0 1 2 3 4 5 6 7 8 9] print(arr[2]) # 2 print(arr[2:5]) # [2 3 4] print(arr[:5]) # [0 1 2 3 4] print(arr[5:]) # [5 6 7 8 9] print(arr[::2]) # [0 2 4 6 8] (步长为2)对于多维数组,使用逗号分隔的索引:
arr_2d = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) print(arr_2d[0, 1]) # 取第0行第1列 -> 2 print(arr_2d[1]) # 取第1行(整行) -> [4 5 6] print(arr_2d[:, 1]) # 取所有行的第1列 -> [2 5 8] print(arr_2d[0:2, 1:3]) # 取第0、1行,第1、2列 -> [[2 3], [5 6]]重要心得:NumPy的切片返回的是原始数组的视图(view),而不是副本(copy)。这意味着修改切片,原数组也会被修改!
sub_arr = arr_2d[:2, :2] sub_arr[0, 0] = 99 print(arr_2d) # 原数组的第一个元素也变成了99如果你需要一份独立的副本,必须显式调用
.copy()方法:sub_arr_copy = arr_2d[:2, :2].copy()。这个特性是为了性能,但也是初学者最容易踩的坑之一。
3.2 花式索引:用数组来索引
这是NumPy非常强大的功能,允许你用一个整数数组或布尔数组来索引目标数组。
整数数组索引:
arr = np.arange(10, 20) indices = [1, 3, 5] print(arr[indices]) # 输出arr中索引为1,3,5的元素 -> [11 13 15] # 更复杂的多维索引 arr_2d = np.array([[1,2], [3,4], [5,6]]) row_idx = np.array([0, 1, 2]) col_idx = np.array([0, 1, 0]) # 取(0,0), (1,1), (2,0) print(arr_2d[row_idx, col_idx]) # [1 4 5]这在需要从数组中抽取非连续、特定位置的元素时非常有用,比如根据一个索引列表抽取样本。
布尔数组索引(条件索引):这是数据清洗和筛选的利器。
arr = np.array([1, -2, 3, -4, 5]) # 找出所有大于0的元素 mask = arr > 0 print(mask) # [ True False True False True] print(arr[mask]) # [1 3 5] # 更复杂的条件组合 mask = (arr > 0) & (arr < 4) # 使用&(与)、|(或)、~(非)进行组合,注意括号 print(arr[mask]) # [1 3] # 直接赋值 arr[arr < 0] = 0 # 将所有负数设为0 print(arr) # [1 0 3 0 5]布尔索引同样返回视图,但通过它赋值是安全的,因为它是基于条件的选择性赋值。
3.3 维度操作:reshape、resize与ravel
数据常常需要变换维度以适应不同的算法接口。
reshape:改变数组形状,不改变数据本身,返回新视图(如果可能)。arr = np.arange(12) arr_3x4 = arr.reshape(3, 4) # 将一维数组变为3行4列注意:
reshape的新形状必须满足np.prod(new_shape) == arr.size,即元素总数不变。有一个特殊参数-1,表示“自动计算该维度大小”。arr.reshape(3, -1)或arr.reshape(-1, 4)非常方便。resize:与reshape类似,但会改变原数组。如果新形状更大,会用0填充;如果更小,会截断数据。arr.resize(5, 3) # 原数组被改变我通常避免直接
resize原数组,除非明确需要原地修改,因为它的行为不如reshape直观。ravel与flatten:都将多维数组展平为一维。ravel():返回视图(如果可能),更快。flatten():总是返回副本,更安全。
arr_2d = np.array([[1,2], [3,4]]) a = arr_2d.ravel() # 视图 b = arr_2d.flatten() # 副本 a[0] = 99 print(arr_2d) # [[99 2], [3 4]] 原数组被修改 b[0] = 100 print(arr_2d) # [[99 2], [3 4]] 原数组不变需要展平操作且不想影响原数据时,无脑用
flatten();追求极致性能且清楚自己在做什么时,用ravel()。
4. 通用函数:向量化计算的引擎
如果说ndarray是NumPy的躯体,那么通用函数(ufunc)就是它的灵魂。它们是对ndarray进行逐元素操作的函数,实现了向量化,彻底避免了低效的Python循环。
4.1 一元ufunc:对单个数组操作
arr = np.array([1.0, 4.0, 9.0, 16.0]) print(np.sqrt(arr)) # 开方 [1. 2. 3. 4.] print(np.exp(arr)) # 指数运算 print(np.log(arr)) # 自然对数 print(np.abs(np.array([-1, -2]))) # 绝对值 print(np.ceil(np.array([1.2, 2.7]))) # 向上取整 [2. 3.] print(np.floor(np.array([1.2, 2.7]))) # 向下取整 [1. 2.] print(np.round(np.array([1.234, 5.678]), decimals=2)) # 四舍五入到2位小数 [1.23 5.68]这些函数在数学计算和数据处理中无处不在。它们会自动将标量广播到整个数组。
4.2 二元ufunc:对两个数组操作
a = np.array([1, 2, 3]) b = np.array([4, 5, 6]) print(np.add(a, b)) # 加法 [5 7 9] print(np.subtract(a, b)) # 减法 [-3 -3 -3] print(np.multiply(a, b)) # 乘法 [4 10 18] print(np.divide(a, b)) # 除法 [0.25 0.4 0.5] print(np.power(a, b)) # 幂运算 [1 32 729] print(np.maximum(a, b)) # 逐元素最大值 [4 5 6] print(np.minimum(a, b)) # 逐元素最小值 [1 2 3]实际上,我们更常用运算符(+,-,*,/,**),它们底层调用的就是这些ufunc。np.maximum/minimum在需要对比两个序列对应位置元素时非常方便。
4.3 聚合函数:从数据中提取信息
聚合函数沿着数组的某个轴(axis)进行操作,将多个值减少为单个值。理解axis参数是关键。
arr = np.array([[1, 2, 3], [4, 5, 6]]) print(arr.sum()) # 所有元素求和 -> 21 print(arr.sum(axis=0)) # 沿轴0(行方向)求和,即压缩行,对每列求和 -> [5 7 9] print(arr.sum(axis=1)) # 沿轴1(列方向)求和,即压缩列,对每行求和 -> [6 15]可以把axis想象成要被压缩掉的维度。axis=0就是沿着行的方向(垂直向下)把每一行压扁,所以是列方向的操作。
其他常用聚合函数:
np.mean(arr),arr.mean(): 平均值np.std(arr): 标准差np.var(arr): 方差np.min(arr),np.max(arr): 最小值,最大值np.argmin(arr),np.argmax(arr): 最小/最大值的索引np.median(arr): 中位数np.percentile(arr, 50): 百分位数(50即中位数)np.prod(arr): 所有元素的乘积np.cumsum(arr): 累积和(返回数组,不是单个值)np.cumprod(arr): 累积积
实操心得:处理真实数据时,我习惯在加载数据后立刻用
arr.shape,arr.dtype,arr.mean(axis=0),arr.std(axis=0),np.percentile(arr, [25, 50, 75], axis=0)等函数快速查看数据的规模、类型和分布情况,这对发现异常值(如标准差极大)或数据错误(如数据类型不对)非常有帮助。
5. 广播机制:不同形状数组运算的魔法
广播是NumPy最强大也最让人困惑的特性之一。它允许不同形状的数组进行算术运算。规则可以简化为两条:
- 从尾部维度开始,比较两个数组的形状。
- 维度大小相等,或其中一个为1,或其中一个数组在该维度上不存在,则广播兼容。
看几个例子就明白了:
例1:标量与数组运算(最简单的广播)
arr = np.ones((3, 4)) result = arr + 5 # 标量5被广播成形状(3,4)的数组,所有元素为5例2:向量与矩阵运算
arr = np.ones((3, 4)) # shape (3, 4) row_vector = np.array([1, 2, 3, 4]) # shape (4,) # 比较形状:(3,4) 和 (4,) # 从尾部开始:4和4相等,通过。 # 接着,arr有维度3,而row_vector没有这个维度(可视为1),通过。 # 所以row_vector被广播为(1,4),然后复制为(3,4) result = arr + row_vector # shape (3,4)例3:维度扩展
arr = np.ones((3, 4, 5)) vector = np.ones((5,)) # 比较(3,4,5)和(5,):尾部5和5相等,通过。vector缺失的维度通过在前面补1来扩展,相当于(1,1,5),然后广播为(3,4,5) result = arr + vector例4:经典错误
A = np.ones((3, 4)) B = np.ones((4, 3)) try: C = A + B except ValueError as e: print(e) # 报错:operands could not be broadcast together with shapes (3,4) (4,3)比较(3,4)和(4,3):尾部4和3既不相等,也不是1,所以不兼容。
避坑指南:广播虽然方便,但也容易掩盖错误。一个常见错误是以为
(n,)形状的数组(一维数组)和(n, 1)或(1, n)的列/行向量是一样的。在矩阵运算中,它们的行为截然不同。为了代码清晰,我强烈建议使用reshape或np.newaxis显式指明维度:vec = np.array([1, 2, 3]) row_vec = vec.reshape(1, -1) # shape (1, 3) col_vec = vec.reshape(-1, 1) # shape (3, 1)使用
vec[:, np.newaxis]也能达到reshape(-1,1)的效果,写法更简洁。
6. 随机数生成:数据模拟与采样的核心
np.random模块是生成模拟数据、进行随机采样和初始化参数的必备工具。虽然新版本推荐使用Generator对象,但旧API仍广泛使用,我们都需要掌握。
6.1 旧API(仍常见于旧代码)
# 设置随机种子,保证结果可复现 np.random.seed(42) # 生成均匀分布 print(np.random.rand(3, 4)) # [0,1)均匀分布,直接指定形状 print(np.random.uniform(0, 10, size=(2,3))) # [low, high)均匀分布 # 生成正态分布 print(np.random.randn(2, 2)) # 标准正态分布(均值0,方差1) print(np.random.normal(loc=5, scale=2, size=10)) # 指定均值loc和标准差scale # 生成随机整数 print(np.random.randint(0, 10, size=5)) # [low, high)的随机整数 # 随机选择 arr = np.array([10, 20, 30, 40]) print(np.random.choice(arr, size=3)) # 从arr中随机选3个(可重复) print(np.random.choice(arr, size=3, replace=False)) # 不可重复抽样 print(np.random.choice(arr, size=3, p=[0.1, 0.2, 0.3, 0.4])) # 指定概率 # 打乱顺序 shuffled_arr = np.random.permutation(arr) # 返回新数组 np.random.shuffle(arr) # 原地打乱原数组6.2 新API(推荐使用)
NumPy 1.17+引入了更灵活、更科学的随机数生成器。
# 创建生成器 rng = np.random.default_rng(seed=42) # 使用生成器的方法 print(rng.random((3, 4))) # 替代 rand print(rng.standard_normal((2,2))) # 替代 randn print(rng.integers(0, 10, size=5, endpoint=False)) # 替代 randint (注意endpoint参数) print(rng.choice(arr, size=3, replace=False, shuffle=True))新API将不同分布的函数作为生成器对象的方法,组织更清晰,且算法通常更新、更优。在新项目中建议使用default_rng。
6.3 常见应用场景与技巧
数据分割:在机器学习中随机划分训练集和测试集。
data = np.arange(100) rng = np.random.default_rng(42) indices = rng.permutation(len(data)) train_size = int(0.8 * len(data)) train_idx, test_idx = indices[:train_size], indices[train_size:] train_data, test_data = data[train_idx], data[test_idx]参数初始化:深度学习中的权重初始化。
# He初始化(适用于ReLU激活函数) weights = rng.standard_normal(size=(100, 200)) * np.sqrt(2. / 100)数据增强:添加随机噪声。
clean_data = np.array([1.0, 2.0, 3.0]) noisy_data = clean_data + rng.normal(0, 0.1, size=clean_data.shape)
重要提醒:务必设置随机种子(seed),尤其是在需要复现结果的科学实验或调试中。这能确保每次运行程序生成的随机序列相同。可以将
seed值设为固定常数(如42)。但在生产环境或需要真正随机性的场景,则不应设置种子。
7. 线性代数操作:机器学习与科学计算的支柱
np.linalg模块提供了标准的线性代数运算,是进行矩阵分解、求解方程组、计算特征值等操作的核心。
7.1 基础矩阵运算
A = np.array([[1, 2], [3, 4]]) B = np.array([[5, 6], [7, 8]]) # 矩阵乘法(注意不是逐元素乘) print(np.dot(A, B)) # 传统函数 print(A @ B) # Python 3.5+ 引入的运算符,更推荐 # 结果:[[19 22], [43 50]] # 转置 print(A.T) # [[1 3], [2 4]] # 逆矩阵 print(np.linalg.inv(A)) # [[-2. 1. ], [ 1.5 -0.5]] # 注意:只有方阵且非奇异(行列式不为0)才有逆矩阵 # 行列式 print(np.linalg.det(A)) # -2.07.2 解线性方程组
这是工程和科学中极其常见的需求。对于方程组 $Ax = b$:
A = np.array([[3, 1], [1, 2]]) b = np.array([9, 8]) x = np.linalg.solve(A, b) # 求解 x print(x) # [2. 3.] 验证:3*2 + 1*3 = 9, 1*2 + 2*3 = 8solve函数在A可逆时使用。如果A不可逆或不是方阵,则需要使用最小二乘法np.linalg.lstsq。
7.3 特征值与特征向量
在主成分分析(PCA)、振动分析等领域至关重要。
A = np.array([[4, -2], [1, 1]]) eigenvalues, eigenvectors = np.linalg.eig(A) print("特征值:", eigenvalues) # [3. 2.] print("特征向量:\n", eigenvectors) # 每一列是一个特征向量,对应一个特征值 # 验证:A @ eigenvectors[:, i] ≈ eigenvalues[i] * eigenvectors[:, i]7.4 范数与条件数
范数:衡量向量或矩阵的“大小”。
v = np.array([1, -2, 3]) print(np.linalg.norm(v)) # 默认L2范数(欧几里得距离) sqrt(1+4+9)≈3.74 print(np.linalg.norm(v, ord=1)) # L1范数(绝对值之和) 1+2+3=6 print(np.linalg.norm(v, ord=np.inf)) # 无穷范数(最大绝对值) 3 M = np.array([[1, 2], [3, 4]]) print(np.linalg.norm(M, 'fro')) # 弗罗贝尼乌斯范数(类似矩阵的L2)条件数:衡量矩阵在求解线性方程组时的稳定性。条件数越大,矩阵越接近奇异,解对输入误差越敏感。
print(np.linalg.cond(A))如果条件数非常大(如$10^{12}$),求解结果可能不可信。
7.5 矩阵分解
奇异值分解(SVD):应用极其广泛(PCA、推荐系统、图像压缩)。
U, S, Vt = np.linalg.svd(A, full_matrices=False) # A ≈ U @ np.diag(S) @ VtS是奇异值向量,通常按从大到小排列。通过保留前k个最大的奇异值,可以实现降维和压缩。QR分解:用于求解最小二乘问题。
Q, R = np.linalg.qr(A)
性能与精度提示:对于非常大的矩阵,
np.linalg中的函数可能会比较慢。在生产环境或处理超大规模数据时,可以考虑使用SciPy的线性代数模块(scipy.linalg),它底层调用更高效的BLAS/LAPACK库,或者使用专门为GPU计算的库如CuPy。对于病态矩阵(条件数大),直接求逆或solve可能数值不稳定,此时应考虑使用SVD并截断小奇异值来求伪逆。
8. 实战避坑与性能优化经验谈
最后,分享几个我多年用NumPy踩出来的坑和优化技巧,这些在官方文档里不一定找得到。
坑1: 整数除法与浮点数陷阱
a = np.array([1, 2, 3]) print(a / 2) # 在Python 3和NumPy中,输出是[0.5 1. 1.5] (浮点数) print(a // 2) # 整除,输出[0 1 1] b = np.array([1., 2., 3.]) # 浮点数组 print(b / 2) # 没问题 # 但要注意,如果除数是整数,且被除数也是整数,结果会被向下取整(在旧版本或某些设置下) # 最安全的做法是确保至少有一个操作数是浮点数,或者使用 np.true_divide坑2: 布尔数组与位运算逻辑运算(&,|,~)和关键字(and,or,not)在NumPy数组上行为不同。
arr = np.array([True, False, True]) # 正确:使用 &, |, ~ mask = (arr > 0) & (arr < 5) # 正确 # 错误:使用 and, or, not # mask = (arr > 0) and (arr < 5) # 会报错:The truth value of an array... is ambiguous.and/or/not用于单个布尔值,而&/|/~用于逐元素的布尔数组运算。
性能技巧1: 避免在循环中逐元素操作这是NumPy使用的大忌。永远优先考虑向量化操作。
# 慢:Python循环 result = np.zeros_like(arr) for i in range(len(arr)): result[i] = arr[i] * 2 + 1 # 快:向量化操作 result = arr * 2 + 1 # 快几个数量级性能技巧2: 使用就地操作减少内存分配
arr *= 2 # 就地乘法,不创建新数组 arr += 1对于大数组,这能显著减少内存压力和垃圾回收开销。
性能技巧3: 合理选择数据类型float64是默认的,但如果你确信数据范围不大或精度要求不高,使用float32甚至float16(半精度)可以节省大量内存,并在某些硬件(如GPU)上加速计算。
arr_f32 = np.array([1.0, 2.0], dtype=np.float32)性能技巧4: 使用np.einsum进行复杂张量运算对于复杂的多维数组乘法求和,np.einsum(爱因斯坦求和约定)语法简洁且通常非常高效。
A = np.random.rand(3, 4) B = np.random.rand(4, 5) # 矩阵乘法 C = np.einsum('ij,jk->ik', A, B) # 等价于 A @ B # 双线性形式 x = np.random.rand(3) y = np.random.rand(5) result = np.einsum('i,ij,j->', x, A, y) # 等价于 x.T @ A @ y它通过字符串公式指定维度的收缩方式,避免了中间变量的创建,在深度学习自定义层时尤其有用。
掌握这些核心函数和概念,你就能用NumPy高效地解决绝大多数数值计算和数据处理的基础问题。下一期,我们将深入NumPy更高级的主题:结构化数组、文件IO、性能剖析工具以及如何与Pandas等库高效协作。记住,熟练使用NumPy的关键不在于背下所有函数,而在于理解其设计哲学:向量化、广播、视图,并能在实际场景中灵活运用。