NumPy

用清晰的形状、数据类型和所有权模型处理多维数组,并正确使用广播、向量化、索引、随机数与线性代数。

难度 入门 时长 标准深度约 17分钟
版本 Python 3.14
what

NumPy 用 ndarray 表示同质多维数组,并在整个数组上执行数值运算。

when

数据能组成规则的数值网格,而且你需要切片、聚合、线性代数或与 Python 科学计算库交换数据时使用它。

how

先明确每个轴的含义和 dtype,再用数组运算组合计算;遇到广播、切片或类型转换时检查结果的形状和内存所有权。

是什么,为什么存在

NumPy 是 Python 的数值数组库。它的核心对象是 多维数组(ndarray) :一组同类型元素,加上一份描述维度、数据类型和内存布局的元数据。二维数组可以表示「样本 × 特征」,三维数组可以表示「图像高度 × 宽度 × 通道」,但 NumPy 本身不会替你记录这些领域含义。

Python 列表适合保存异质对象和不断变化的集合,却没有数组级算术的统一语义。[1, 2] * 2 会重复列表,而 NumPy 数组乘以 2 会逐元素计算。NumPy 为规则数值数据提供共同的数组协议,Pandas、SciPy、Matplotlib 和许多机器学习工具因此能交换数据,而不必为每次运算编写 Python 循环。

当数据是规则、稠密、同质的数值网格时,NumPy 很合适。带列名和混合类型的表格通常更适合 Pandas 或 Polars;形状高度不规则的对象集合仍可能适合 Python 容器。这个边界很实用:不要为了「向量化」而把本来不规则的数据硬塞进 dtype=object 数组。

这里的示例已经在 Python 3.14.3 和 NumPy 2.5.2 环境中运行。显示结果经过明确的舍入或列表转换,避免依赖数组的省略规则或终端宽度。

工作原理

一个数组的三份契约

每个数组都有 形状(shape) 数据类型(dtype) 和数据缓冲区。shape 是各轴长度组成的元组;ndim 是轴数;size 是元素总数。dtype 决定每个元素如何解释,例如 int64float32bool

数组变量还隐含一份领域契约:每个轴代表什么。形状同为 (32, 10) 的两个数组,可能分别表示 32 个样本的 10 个特征,也可能表示 32 个时间点的 10 个传感器。NumPy 只验证长度能否配合,不会发现轴的业务含义颠倒。

步幅(stride) 说明沿每个轴移动一个位置时跨过多少字节。切片和转置可以只改 shapestrides,继续引用同一缓冲区。于是两个外观不同的数组可能共享数据,这也是修改切片有时会改到原数组的原因。

创建数组时确定类型

np.array() 从现有 Python 数据创建数组,np.zeros()np.ones()np.full() 创建已初始化的规则数组,np.arange()np.linspace() 创建数值序列。业务边界处应显式给出 dtype,尤其是整数宽度、浮点精度和输入可能为空时。这样可以避免类型由某一批样本偶然决定。

np.empty() 只分配空间,不初始化其中的值。它适合随后会覆盖每个元素的内部实现,不适合当作「空值数组」。如果任何位置可能在赋值前被读取,就应使用带有确定初值的创建函数。

reshape() 要求元素总数保持不变。用 -1 可以让 NumPy 推导一个轴的长度,但一条形状合法的语句仍可能表达错误的数据布局。重塑前后都要写清每个轴的意义,而不是只检查元素数能否整除。

常见形状操作的差异可以概括如下:

目的表达式结果要点
改变维度长度a.reshape(rows, cols)元素数不变,可能共享内存
交换全部轴a.T二维时交换行列,通常是视图
指定轴顺序a.transpose(order)order 是输出轴对应的输入轴
移动一个轴np.moveaxis(a, source, destination)适合把通道轴移到明确位置
插入长度为一的轴np.expand_dims(a, axis)常用于准备广播
删除长度为一的轴np.squeeze(a, axis=axis)指定 axis 可避免误删其他轴
沿新轴组合np.stack(arrays, axis=axis)所有输入形状必须相同
沿已有轴拼接np.concatenate(arrays, axis=axis)非拼接轴的长度必须相同

移动与组合轴

转置不是「把任何数组的行列互换」。二维数组的 .T 确实交换两个轴,但三维数组的 .T 会反转全部轴顺序。处理批次、通道和空间维度时,transpose()moveaxis() 能把意图写得更明确。

stack()concatenate() 解决不同问题。stack() 新建一个轴,例如把十个形状为 (64, 64) 的图像组合成 (10, 64, 64)concatenate() 沿已有轴接长数组。生成代码很容易在这里多出或丢掉一个批次轴。

不带 axissqueeze() 会删除所有长度为 1 的轴。这在测试数据中看似方便,却可能让批量大小为 1 时的返回值突然少一个维度。公共 API 应指定要删除的轴,或者干脆保留稳定的批次维度。

np.newaxis 就是 None 的别名,用在索引中可以插入长度为 1 的轴。values[:, np.newaxis](n,) 变成 (n, 1)values[np.newaxis, :] 则变成 (1, n)。两者元素相同,但广播方向完全不同。

组合数组前先验证除目标轴以外的长度。错误消息只能指出某些维度不匹配,不知道一个输入是否使用「通道优先」,另一个却使用「通道最后」。接口中的轴命名比修复一条 ValueError 更重要。

向量化、通用函数与聚合

向量化(vectorization) 是把计算表达为整个数组上的操作。例如,prices * quantities 表示逐元素乘法,np.sqrt(values) 把同一个通用函数应用到每个元素。向量化首先改变的是表达方式:循环由 NumPy 处理,形状与类型规则集中在数组操作中。

sum()mean()max() 等聚合通过 axis 指定要消去的轴。对于形状为「样本 × 特征」的二维数组,axis=0 会消去样本轴,留下每个特征的结果;axis=1 会消去特征轴,留下每个样本的结果。keepdims=True 保留长度为 1 的轴,后续广播时通常更清楚。

普通算术运算是逐元素的。两个二维数组之间的 * 不是矩阵乘法;线性代数乘法使用 @np.matmul()np.linalg.solve() 用于求解方阵线性方程组,通常比显式计算逆矩阵再相乘更直接。

广播从尾轴开始

广播(broadcasting) 让不同形状的数组参与逐元素运算。NumPy 从最右侧轴开始比较:两个长度相等,或者其中一个长度为 1,这一对轴就兼容;缺失的左侧轴按长度 1 处理。否则运算会抛出形状不兼容错误。

例如,形状为 (4, 3) 的样本矩阵可以减去形状为 (3,) 的特征均值,因为尾轴长度都是 3。形状为 (4,) 的样本均值不能直接从它减去,因为尾轴 34 不兼容。把样本均值保留为 (4, 1) 后,长度为 1 的尾轴才会沿特征方向广播。

广播描述的是逻辑上的扩展,不等于先创建一份重复数据。不过,算术结果通常仍会分配自己的数组。先预测结果形状,再写表达式;「没有报错」只能证明形状兼容,不能证明轴的含义正确。

索引决定共享还是复制

基本切片,例如 array[1:4]matrix[:, ::2],通常返回共享数据的 数组视图(array view) 。修改视图中的元素会在原数组中可见。view.base 有助于探索,但一串视图的 base 不一定直接就是手里的原数组,因此判断两个数组是否重叠应使用 np.shares_memory()

整数数组索引和布尔索引属于高级索引,结果是副本。array[[0, 2]]array[array > 0] 后续可以独立修改,但复制会分配新缓冲区。混合基本索引与高级索引时,结果形状也可能与逐轴切片的直觉不同,应该用小输入先确认。

赋值时规则不同:array[mask] = 0 会写回原数组,因为索引出现在赋值目标上。相反,先保存 selected = array[mask],再改 selected,只会修改那份副本。代码审查时需要看完整的数据流,不能只凭一个索引表达式判断最终写到了哪里。

掩码、条件与顺序

比较数组会得到布尔数组。多个条件必须使用逐元素运算符 &|~,而且每个比较通常要放在括号中;Python 的 andor 需要把整个数组解释成一个真假值,因此会抛出歧义错误。

布尔索引只保留满足条件的元素,常常会压平原有的结构。np.where(condition, left, right) 则在两个同形或可广播的值之间逐元素选择,并保留广播后的形状。选择哪一个取决于下游是否还需要原来的轴。

缺失值不只有 NaN 一种表示。浮点数组能直接保存 NaN,整数数组不能;把整数输入与 NaN 放在一起创建数组,通常会提升到浮点类型。若缺失与零、空字符串或某个哨兵值混用,聚合前必须先统一语义。

np.argsort() 返回能重排数组的索引,而不是已经排序的值。需要同步重排标签与特征时,先计算一份顺序索引,再把它应用到所有关联数组。只排序一列会破坏样本内部的对应关系。

掩码和顺序索引的长度也属于数组契约。一个来自过滤前数据的旧掩码,不能安全地复用到过滤后的数组。数据管道应让掩码与它对应的数据一起流动,或者在每个阶段重新计算。

错误与数值警告

形状不兼容通常会抛出 ValueError。先读取异常中列出的输入形状,再从尾轴向左比较,比盲目插入 reshape() 更安全。能让代码运行的形状修补不一定保留数据含义。

不安全的原地类型转换会失败。例如,浮点除法结果不能按 same_kind 规则写回整数数组。修复方向通常是创建所需浮点类型的结果,而不是强制使用会截断信息的转换。

除零、无效运算和溢出的浮点操作默认可能发出 RuntimeWarning,同时产生 infNaN。如果调用方忽略警告,这些特殊值还能继续流入后续计算,所以只看函数是否抛出异常并不够。

np.errstate() 可以在一个明确的代码块内改变浮点错误处理方式。测试关键数值路径时,可以把相关警告临时提升为异常;预期会产生特殊值的算法则可以在局部处理,并立即验证结果。

np.seterr() 改变的是当前线程的 NumPy 错误设置,不适合由普通库函数随意修改。局部上下文比隐藏的环境变化更容易推理,也不会要求调用者在函数返回后恢复设置。

np.isfinite() 能同时筛出 NaN 与正负无穷。输入验证和关键中间结果都可以使用它,但是否拒绝、替换或保留这些值仍应由领域契约决定。

警告策略不能替代边界测试。要分别覆盖除零、极大值、极小值和全缺失切片,因为它们触发的类型提升、警告和返回形状可能不同。

示例

读懂形状、类型和轴

这个二维数组的行表示日期,列表示传感器。axis=1 消去传感器轴,得到每日均值;axis=0 消去日期轴,得到每个传感器的峰值。

array_axes.py
import numpy as np

temperatures = np.array(
    [[18.5, 20.0, 19.5], [21.0, 23.5, 22.0]],
    dtype=np.float64,
)

print(f"shape={temperatures.shape}")
print(f"ndim={temperatures.ndim}, dtype={temperatures.dtype}")
print("daily mean:", np.round(temperatures.mean(axis=1), 2).tolist())
print("sensor peak:", temperatures.max(axis=0).tolist())
shape=(2, 3)
ndim=2, dtype=float64
daily mean: [19.33, 22.17]
sensor peak: [21.0, 23.5, 22.0]

输出形状 (2, 3) 本身不能说明哪个轴是日期。变量名、邻近注释和接口文档要保存这份语义。聚合后也应检查结果形状是否与消费方的预期一致。

用广播标准化特征

下面把每列当作一个特征。keepdims=True 让均值与标准差保持 (1, 3),所以减法和除法都明确沿样本轴广播。第三列是常数,代码把它的零标准差替换为 1.0,避免除零。

standardize_features.py
import numpy as np

samples = np.array(
    [[1.0, 10.0, 7.0], [2.0, 20.0, 7.0], [3.0, 30.0, 7.0]]
)

means = samples.mean(axis=0, keepdims=True)
scales = samples.std(axis=0, keepdims=True)
safe_scales = np.where(scales == 0, 1.0, scales)
standardized = (samples - means) / safe_scales

np.set_printoptions(precision=2, suppress=True)
print("mean shape:", means.shape)
print(standardized)
print("constant feature:", standardized[:, 2].tolist())
mean shape: (1, 3)
[[-1.22 -1.22  0.  ]
 [ 0.    0.    0.  ]
 [ 1.22  1.22  0.  ]]
constant feature: [0.0, 0.0, 0.0]

把零标准差替换为 1.0 是这里明确选择的策略,不是普遍规则。另一个系统可能要删除常数特征或拒绝输入。数值代码必须把这种领域决定写进接口和测试。

区分视图与副本

基本切片 scores[:, 1:] 与原数组共享数据,布尔索引则创建副本。两次赋值看起来相似,却只有第一次回写到 scores

view_copy.py
import numpy as np

scores = np.array(
    [[70, 80, 90], [60, 75, 85], [88, 92, 95]],
    dtype=np.int64,
)

last_two = scores[:, 1:]
selected = scores[scores[:, 0] >= 70]

print("slice shares memory:", np.shares_memory(scores, last_two))
print("mask shares memory:", np.shares_memory(scores, selected))

last_two[0, 0] = 999
selected[0, 0] = -1
print("source first row:", scores[0].tolist())
print("selected first row:", selected[0].tolist())
slice shares memory: True
mask shares memory: False
source first row: [70, 999, 90]
selected first row: [-1, 80, 90]

需要隔离修改时,调用 .copy() 明确取得独立缓冲区。需要原地更新时,也要在函数名或文档中写明副作用。依赖调用者猜测所有权,会让一次看似局部的清洗污染上游数据。

隔离随机状态并求最小二乘解

default_rng() 返回独立的随机数生成器,不会重置模块级随机状态。这里用它生成训练与测试索引,再由 np.linalg.lstsq() 拟合含截距的一元线性模型。

random_lstsq.py
import numpy as np

rng = np.random.default_rng(42)
feature = np.array([0, 1, 2, 3, 4, 5], dtype=np.float64)
target = 1.0 + 2.0 * feature

order = rng.permutation(feature.size)
train_index = order[:4]
test_index = order[4:]

design = np.column_stack(
    [np.ones(train_index.size), feature[train_index]]
)
intercept, slope = np.linalg.lstsq(
    design, target[train_index], rcond=None
)[0]
predicted = intercept + slope * feature[test_index]

print("train index:", train_index.tolist())
print(f"model: y = {intercept:.1f} + {slope:.1f}x")
print("test index:", test_index.tolist())
print("prediction:", np.round(predicted, 6).tolist())
train index: [3, 2, 5, 4]
model: y = 1.0 + 2.0x
test index: [1, 0]
prediction: [3.0, 1.0]

固定种子让这次运行可重复,但长期实验还应保存实际索引、NumPy 版本和所选位生成器。不要把「同一个种子」当作跨所有版本与算法的完整数据溯源。真实建模还要在分割前明确分层、分组和时间顺序等约束。

陷阱

修复方法: 在边界处断言形状,并为每个轴写出含义。使用 keepdims=Truenp.newaxis 或明确的 reshape() 表达要扩展的轴,再用一个很小的手算样本验证结果。

修复方法: 需要隔离时显式 .copy(),需要确认重叠时调用 np.shares_memory()。对原地修改函数写测试,既检查返回值,也检查输入数组是否按契约改变。

修复方法: 在入口处选择足够宽的 dtype,用 np.result_type() 检查组合运算的结果类型。除法、归一化和累计求和前尤其要确认类型,并对最大值、最小值与负数编写边界测试。

修复方法: 只有能证明每个元素都会在读取前赋值时才用 empty()。否则使用 zeros()full() 或一个能表达缺失策略的明确初值。

修复方法: 接收一个 Generator 参数,或在函数内部用明确种子创建局部 Generator。实验需要跨进程重放时,还要保存抽样结果和位生成器信息。

修复方法: 近似比较使用 np.isclose()np.allclose(),并根据领域设置容差。先决定 NaN 应当拒绝、忽略还是传播,再选择 mean()nanmean() 或显式验证;不要为了得到数字而默认忽略缺失值。

深入 内存布局和数据所有权

内存布局和数据所有权

ndarray 把数据缓冲区与解释缓冲区的元数据分开。元数据包括 shapedtypestrides 和从缓冲区起点算起的偏移。一个视图可以更改这些字段而不复制元素,所以转置、反向切片和间隔切片仍能看到同一批底层数据。

这种设计也意味着「数组连续」不是恒真条件。C 连续数组的最后一个轴通常相邻,Fortran 连续数组的第一个轴通常相邻;转置后的数组可能只满足其中一种,也可能都不满足。调用外部库前应查看其契约,只有接口确实要求时才用 np.ascontiguousarray() 物化一份连续副本。

ravel() 会尽量返回视图,但必要时也会复制;flatten() 总是返回副本。reshape() 在布局允许时可以返回视图,否则可能复制,某些形状与顺序组合还会失败。所有权重要时,不要用「这个函数一般不会复制」代替检查和测试。

广播视图可能让多个逻辑位置指向同一个元素。np.broadcast_to() 返回只读视图,避免一次赋值含糊地影响多个位置。算术表达式的输出通常是新数组,但 out= 参数和原地运算会改变所有权与类型转换要求,使用时应在接口中写明。

内存消耗可以从数组本身核对:size * itemsize 等于元素缓冲区的 nbytes。这不包含 Python 数组对象的少量元数据,也不代表一个视图拥有同等大小的新缓冲区。排查峰值内存时,要区分视图的逻辑大小、实际拥有的缓冲区,以及表达式创建的临时结果。

类型提升和数值边界

dtype 是数组运算的一部分,不只是存储选项。两个输入参与运算时,NumPy 根据其数据类型与 Python 标量的类别确定共同结果类型;np.result_type() 可以在不执行完整计算的情况下检查该决定。赋值回现有数组还要满足转换规则,因此浮点结果不能安全地原地写回整数数组。

固定宽度整数会在边界处溢出。累计值的范围往往比单个输入大,所以计数、像素和小整数特征即使能存进 int8,它们的和也可能需要更宽的类型。应根据最终运算范围选择类型,而不是只根据原始值选择。

浮点类型需要同时考虑范围、精度和特殊值。NaN 不等于自身,正负无穷可以通过 np.isfinite() 检出,接近零的分母会放大舍入误差。容差与缺失值策略来自问题本身;NumPy 提供检查工具,但不会替你决定什么结果可以接受。

数组 API 还会区分逐元素语义与线性代数语义。a * ba @ bnp.dot(a, b) 在高维输入上不一定相同。公共函数应约束接受的维度,并对非方形批次编写测试,这比依赖二维示例推断高维行为可靠。

随机数也有状态和算法边界。把 Generator 作为参数传入,可以让函数的随机状态归调用者所有,测试也能注入固定生成器。需要精确复现实验时,应保存输入、实际抽样结果、位生成器与库版本,而不只记录一个整数种子。

API 边界上的数组契约

输入验证

接收数组的公共函数应先决定它接受「任意 array-like 对象」,还是只接受 ndarraynp.asarray() 可以把列表等输入规范化为数组,并在输入已经兼容时避免不必要的复制。规范化之后再检查 ndimshapedtype 与有限值,错误信息才能对应统一表示。

不要只写 assert array.shape[1] == 3。普通断言可以在优化模式下移除,而且如果输入是一维数组,访问第二个轴会先抛出索引错误。应按顺序验证类型、维度、各轴长度和数值范围,并给调用者一条能修正输入的异常消息。

空数组需要单独的领域决定。某些逐元素变换可以自然返回空结果,而 max() 之类没有单位元的聚合无法从空输入得到答案。API 应说明空输入是被拒绝、返回空数组,还是使用显式的 initial 值。

可写性也是契约的一部分。把 array.flags.writeable 设为 False 可以阻止意外原地修改,但它不是安全边界,也不会冻结其他共享视图。真正需要隔离时仍要创建副本,并控制谁持有可写引用。

输出形状与所有权

函数文档应给出相对于输入的输出形状,例如「输入 (samples, features),输出 (features,)」。只写「返回均值」不足以区分全局标量、逐样本均值和逐特征均值。类型注解目前也不能替代运行时的具体轴契约。

返回视图可以避免复制,但会把输入的生命周期和可变性暴露给调用者。返回副本则提供隔离,却分配新缓冲区。两种选择都合理,关键是稳定、可见,并由测试覆盖修改输入和修改输出后的行为。

如果函数接受 out= 参数,就要验证目标形状、可写性和转换安全性。重叠的输入与输出还可能改变计算含义。除非底层操作明确支持这种重叠,不要假设原地写入与先计算完整结果再赋值等价。

标量返回值也需要留意。某些 NumPy 聚合返回 NumPy 标量,例如 np.float64,它能参与大多数 Python 运算,但序列化库可能要求原生 floatint。在 JSON 或数据库边界做明确转换,比在数值核心到处调用 .item() 更清楚。

保存与交换

.npy 格式保存一个数组的形状、dtype 和数据,.npz 可以把多个具名数组放进同一归档。它们适合 NumPy 工作流中的精确往返,但不是供所有语言通用的表格协议。跨系统交换前要根据消费者选择格式,而不是只考虑写出是否方便。

对象数组可能依赖 Python pickle。加载不可信 pickle 数据会执行构造对象所需的代码,因此不应把 allow_pickle=True 当作修复未知文件错误的通用开关。优先保存数值、布尔或定长字符串类型,并保持加载不可信文件时禁用 pickle。

文本格式会丢失一部分类型与形状信息。CSV 不能自然表示任意维数组,也无法完整保留浮点格式、字节序或结构化 dtype。写出文本时应同时定义列、缺失值、精度和重建形状的规则。

与 Pandas、机器学习框架或本地扩展交换数组时,要检查是否复制、是否共享可写内存,以及设备和字节序要求。名称相似的转换方法可能有不同所有权语义。最可靠的接口测试会修改交换前后的一侧,并验证另一侧是否应当变化。

延伸阅读

检查点

5个问题 · 2 道输出预测题 · 1 道找错题

前置内容 Getting started 即将上线
下一篇 pandas Polars Matplotlib 绘图 Linear algebra 即将上线
复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?