NumPy 数组与向量化
上一章,notebook 实验台已经搭好,也试了试手。但很快你就撞上新麻烦:手里只有 Python 列表,算个均值要 sum(x) / len(x),两列数据对应相乘得写 for 循环——数据一多,代码臃肿,速度还慢
上一章,notebook 实验台已经搭好,也试了试手。但很快你就撞上新麻烦:手里只有 Python 列表,算个均值要 sum(x) / len(x),两列数据对应相乘得写 for 循环——数据一多,代码臃肿,速度还慢。
这一章,数据实践的第一件正式工具登场:NumPy。它是整个 Python 科学计算生态的地基——下一章的 Pandas 直接建在它上面,AI 领域的张量库(如 PyTorch)虽然自成体系,但"形状、轴、广播"这套思维方式也是从这里的数组概念一路传承下来的。NumPy 的计算核心由 C/C++ 实现,处理大规模数值数据时,性能和原生 Python 完全不是一个量级。
本章分两段,对应一件事情的两面:
- 前半段解剖 ndarray 这个数据结构本身——形状、类型、内存排布、轴。看懂这四样,任何数组摆在眼前你都能"读"出来;
- 后半段把日常操作过一遍——造数、取数、变形、运算、统计、拼接、排序,最后以广播收尾。
这些操作都是通用底座,等进入 Pandas(27-28 章)和可视化(29-31 章)后,它们会以各种化身反复出现。建议开着 notebook,跟着敲。
1. 上手:装库与导入
还记得上一章的 Shell 逃逸吗?在 notebook 的 cell 里直接写:
!uv add numpy
或者回到终端执行 uv add numpy 也一样。装完导入:
import numpy as np
np 这个别名是全世界约定俗成的写法——NumPy 官方文档、几乎所有开源项目都这么导入,不要标新立异。
2. ndarray:给"一大批数"安的家
2.1 和列表比,它牺牲了什么、换来了什么
ndarray(N 维数组)是 NumPy 的核心数据结构。要理解它,最好的切入点是拿 Python 列表做对照:
| 列表 | ndarray | |
|---|---|---|
| 元素类型 | 随意混装([1, "a", 3.0] 合法) |
全体同一种类型(dtype) |
| 存储方式 | 一排指针,指向散落在内存各处的对象 | 一整块连续内存 |
| 批量运算 | 写 for 循环逐个处理 | 一行表达式,整组一起算 |
这三条是一体的:正因为每个元素类型固定、长度固定,才能紧凑地码进一块连续内存;正因为内存连续,C 层才能一口气对整块数据做运算——这就是向量化(vectorization)。代价也明摆着:失去了列表"想装什么装什么"的自由。数据计算恰恰不需要这种自由,这笔交易非常划算。
2.2 创建一个数组,顺便做个体检
最直接的创建方式:把(嵌套的)列表交给 np.array。拿两家门店三种饮品的日销量当例子:
sales = np.array([[86, 92, 79],
[74, 105, 68]])
print(sales)
# [[ 86 92 79]
# [ 74 105 68]]
print(type(sales))
# <class 'numpy.ndarray'>
print(f"形状 {sales.shape},维度数 {sales.ndim},元素总数 {sales.size},元素类型 {sales.dtype}")
# 形状 (2, 3),维度数 2,元素总数 6,元素类型 int64
最后那行打印的四个属性,就是一份"体检报告",逐个认识:
shape:形状,一个元组,描述每根轴有多长ndim:维度数(轴的数量)size:元素总数dtype:所有元素共用的那个类型
2.3 形状:数组的骨架
shape 是读懂数组的起点。它是元组,第 i 个数字就是第 i 根轴的长度:
hr_1d = np.array([72, 75, 71, 78, 74, 76]) # 一维:6 次心率采样
scores_2d = np.array([[92, 88, 95],
[78, 85, 82],
[90, 91, 87],
[66, 72, 70]]) # 二维:4 名学生 × 3 门课
jump_3d = np.array([[[215], [198], [240]],
[[186], [225], [209]]]) # 三维:2 个小组 × 3 名成员 × 1 项成绩
print(hr_1d.shape, scores_2d.shape, jump_3d.shape)
# (6,) (4, 3) (2, 3, 1)
print(jump_3d.ndim, jump_3d.size)
# 3 6
三个细节:
- 一维数组的 shape 是
(6,)——元组里只有一个数也要带逗号,不带就变成普通整数了; - 二维
(4, 3)按"行、列"读:4 行 3 列,可以想象一张表; - 三维
(2, 3, 1)按"层、行、列"读:两张 3×1 的小表摞在一起。size恒等于 shape 各项相乘:2×3×1 = 6。
2.4 想一想
你在做评论分析任务:每条评论被编码成一个 512 维的语义向量,手头共有 300 条评论,全部装进一个 ndarray。这个数组是几维的?shape 是什么?size 是多少?
对一下答案:
vecs = np.zeros((300, 512), dtype=np.float32)
print(vecs.ndim, vecs.shape, vecs.size)
# 2 (300, 512) 153600
"批量 × 每条维度"——(条数, 维度数) 这个形状,后面凡是和向量数据(embedding)打交道的场合都会反复见到。
3. dtype:一种数组,一种类型
ndarray 是同质的,dtype 就是描述"全体元素是哪种类型"的属性。常用的有这么几类:
- 浮点:
float64(默认浮点)、float32(省一半内存,向量数据常用) - 整数:
int64(默认整数)、int32、int8 - 布尔:
bool object:装 Python 对象——尽量别用,等于放弃连续内存和向量化,退回列表时代
为什么类型要这么细分?因为 ndarray 的类型系统本质上把 C 的类型系统直接搬进了 Python:
| NumPy dtype | 对应 C 类型 | 占用 |
|---|---|---|
int64 |
int64_t |
8 字节 |
int32 |
int32_t |
4 字节 |
float64 |
double |
8 字节 |
float32 |
float |
4 字节 |
对照之下,Python 的 int 是个"不定长"的完整对象,光是对象头部就要吃掉 28 字节往上,值本身还散在别处。ndarray 用 C 的定长类型把数据码成一片,才换来了高速的整块运算。itemsize 属性可以看到每个元素占几字节:
temps = np.array([36.5, 37.1, 36.8], dtype=np.float32)
uids = np.array([10001, 10002, 10003], dtype=np.int64)
flags = np.array([True, False, True], dtype=np.bool_)
print(temps.dtype, temps.itemsize) # float32 4
print(uids.dtype, uids.itemsize) # int64 8
print(flags.dtype, flags.itemsize) # bool 1
一字之差,账单翻倍。算笔内存账:
print(f"一千万个 float64 读数: {10_000_000 * 8 / 1024 / 1024:.2f} MB")
print(f"一千万个 float32 读数: {10_000_000 * 4 / 1024 / 1024:.2f} MB")
# 一千万个 float64 读数: 76.29 MB
# 一千万个 float32 读数: 38.15 MB
所以实践中有个共识:向量数据用 float32(精度够用、内存减半)、编号 ID 用 int64、开关掩码用 bool。
那如果硬塞进不匹配的数据呢?显式指定 dtype 时,NumPy 不会含糊,直接报错:
try:
np.array([3, "七", 9], dtype=np.int64)
except ValueError as e:
print(e)
# invalid literal for int() with base 10: '七'
更要提防的是不指定 dtype 的情况:NumPy 会悄悄"就高"——整数碰上浮点全部变浮点,只要混进一个字符串就全体变字符串。类型被无声地改变,比报错危险得多。所以创建数组时,类型敏感的场合显式传 dtype,是更稳的习惯。
4. strides:数据在内存里怎么摆
strides(步幅)回答一个很底层的问题:沿某根轴往前挪一格,内存里要跳过多少字节? 它是 NumPy 在内存里的"行走指令"。
一块 shape (2, 3)、int64(每元素 8 字节)的数组,内存里实际就是一排 6 个格子:
列0 列1 列2
行0 [ 10 ][ 20 ][ 30 ]
行1 [ 40 ][ 50 ][ 60 ]
内存实况(每格 8 字节,首尾相接):
[10][20][30][40][50][60]
· 从 10 走到 20(列方向挪一格):跳过 1 格 = 8 字节 → axis 1 的步幅
· 从 10 走到 40(行方向挪一格):跳过 3 格 = 24 字节 → axis 0 的步幅
验证一下:
grid = np.array([[10, 20, 30],
[40, 50, 60]], dtype=np.int64)
print(grid.shape, grid.strides, grid.itemsize)
# (2, 3) (24, 8) 8
strides 是 (24, 8):行方向 24 字节,列方向 8 字节——和图上推的一致。有个通用规律:某根轴的步幅 = 它右边所有轴长度的乘积 × 每元素字节数(行方向 = 3 × 8 = 24)。
这个属性看似冷门,却解释了一个高频"灵异现象"。看转置:
flipped = grid.T
print(flipped.shape, flipped.strides)
# (3, 2) (8, 24) ← 步幅只是被原样对调了!
flipped[0, 0] = 99
print(grid)
# [[99 20 30]
# [40 50 60]] ← 原数组被改了
转置没有搬动一个字节的数据——只是把两根轴的步幅互换,"解读同一块内存的方式"变了。这类不复制数据的操作叫零拷贝。改转置会波及原数组,根源就在这里;下一节切片的"视图"行为,同样是这个原理。
5. axis:运算沿着哪根轴走
axis(轴)就是一个整数:第几根轴。它出现在几乎所有接受方向的函数里,用来指定"这个操作沿着哪根轴进行"。
axis=1 →(横着走)
┌─────────────────────┐
axis=0 │ 10 20 30 │
↓ │ 40 50 60 │
(竖着走)└─────────────────────┘
一个实用的记法:沿 axis=0 求和,是把每一列压成一个数;沿 axis=1,是把每一行压成一个数。 也可以倒过来记:运算沿哪根轴走,哪根轴就从结果里消失。
grid = np.array([[10, 20, 30],
[40, 50, 60]])
print(grid.sum(axis=0)) # [50 70 90] 每列求和(竖着压)
print(grid.sum(axis=1)) # [ 60 150] 每行求和(横着压)
axis 这个概念后面会反复回来:统计、排序、拼接,全都用它指路。现在先把二维的情形焊牢,高维不过是同一逻辑的延续。
6. 造数组:不止 np.array 一条路
np.array 适合从手头现成的数据建数组;更多时候,我们要的是"凭空生成一批数"。NumPy 提供了一整组构造工具。
6.1 预填值:zeros / ones / eye
print(np.zeros((2, 3)))
# [[0. 0. 0.]
# [0. 0. 0.]]
print(np.ones((2, 3), dtype=np.float32))
# [[1. 1. 1.]
# [1. 1. 1.]]
print(np.eye(3))
# [[1. 0. 0.]
# [0. 1. 0.]
# [0. 0. 1.]]
zeros 的常见用途是预分配结果容器——先开好一块内存,再往里填数。eye 生成单位矩阵(对角线为 1),线性代数里的常客。注意形状参数是一个元组 (2, 3),不是两个参数。
6.2 序列:arange / linspace
print(np.arange(5, 30, 5))
# [ 5 10 15 20 25]
print(np.linspace(0, 100, 5))
# [ 0. 25. 50. 75. 100.]
print(np.linspace(0, 100, 5, endpoint=False))
# [ 0. 20. 40. 60. 80.]
哥俩分工明确:arange 管步长(起点、终点不含、步长),linspace 管个数(起点、终点含、总共几个点)。linspace 默认把右端点算进序列,endpoint=False 可以把它掐掉。画坐标轴采样点、做数值实验时,linspace 出场率极高。
6.3 随机数:default_rng
现代 NumPy 生成随机数的标准姿势是先创建一个随机数生成器:
rng = np.random.default_rng(7)
print(rng.uniform(60, 100, (2, 3))) # 均匀分布:区间 [60, 100) 内
# [[85.00381866 95.88855204 91.02742761]
# [69.0082876 72.0066514 94.94213782]]
print(rng.normal(170, 6, (2, 3))) # 正态分布:均值 170、标准差 6
# [[170.36086162 178.04129147 167.04676089]
# [166.2771506 172.9390523 172.14132205]]
print(rng.integers(1, 7, (2, 3))) # 随机整数:[1, 7),掷骰子
# [[5 2 6]
# [3 3 4]]
传入生成器的 7 是种子:种子固定,这批"随机"数每次都一模一样。这让带随机性的分析也能复现——交给同事的 notebook,别人跑出来的数字和你一致。旧教程里全局的 np.random.seed() 写法已被这套 default_rng 取代,见到新代码不必惊讶。
6.4 练手场景:现造一批模拟数据
分析流程还没写完,又想先拿数据试试?自己造。比如模拟 10 款饮品的单价与销量:
rng = np.random.default_rng(8)
drink_price = rng.uniform(12, 32, 10).round(1) # 单价:12~32 元
drink_sold = rng.integers(50, 500, 10) # 销量:50~499 杯
print(drink_price)
# [18.5 31.7 18.4 27.8 29.4 19.8 20.8 19.5 14.1 21.6]
print(drink_sold)
# [481 158 432 165 114 133 229 137 453 416]
uniform + integers 两行,排序、筛选、统计的逻辑立刻就能开测——不用干等真实数据。
7. 取数:索引、切片与掩码
有了数组,下一步是"把我要的那部分拿出来"。
7.1 基本索引与切片:小心"视图"
语法和列表一脉相承:arr[start:stop:step],多维之间用逗号分隔——逗号前管第 0 轴,逗号后管第 1 轴,以此类推。start 默认 0,stop 默认到头(不含),step 默认 1。
grid = np.arange(20).reshape(4, 5)
print(grid)
# [[ 0 1 2 3 4]
# [ 5 6 7 8 9]
# [10 11 12 13 14]
# [15 16 17 18 19]]
print(grid[0]) # 第 0 行
# [0 1 2 3 4]
print(grid[:, 1:]) # 所有行,从第 1 列起
# [[ 1 2 3 4]
# [ 6 7 8 9]
# [11 12 13 14]
# [16 17 18 19]]
print(grid[1:, 2:]) # 第 1 行起、第 2 列起
# [[ 7 8 9]
# [12 13 14]
# [17 18 19]]
读法:把 grid[1:, 2:] 拆成"行取 1:、列取 2:",逗号两边各自独立。
重头戏来了:切片返回的不是副本,而是视图(view)。 想想第 4 节的 strides——切一片,只需要挪挪起点、改改步幅,根本不用搬数据,何乐而不为?但代价是:切片和原数组共享同一块内存。
block = grid[1:, 3:]
block[0, 0] = 99 # 只改了切片
print(grid)
# [[ 0 1 2 3 4]
# [ 5 6 7 99 9] ← 原数组跟着变了
# [10 11 12 13 14]
# [15 16 17 18 19]]
想要一份"随便改、不影响原数组"的数据,必须显式复制:
safe = grid[1:, 3:].copy() # 真正的独立副本
视图是 NumPy 高性能的来源之一,也是新手数据被"莫名改坏"的头号案发现场。记住:切片是视图,.copy() 才是副本。
7.2 花式索引
想一次抽出不连续的若干行、若干位置?用一个整数列表(或数组)当索引:
grid = np.arange(21, 33).reshape(4, 3)
print(grid)
# [[21 22 23]
# [24 25 26]
# [27 28 29]
# [30 31 32]]
print(grid[[1, 3]]) # 一次取第 1、3 两行
# [[24 25 26]
# [30 31 32]]
rows = [3, 0, 2]
cols = [1, 2, 0]
print(grid[rows, cols]) # 行、列配对取三个点
# [31 23 27]
最后一条要格外注意:两个列表按位置一一配对,取的是 (3,1)、(0,2)、(2,0) 三个点——不是"这些行 × 这些列"的组合。另外,花式索引返回的是拷贝,改它不影响原数组(和切片相反)。
7.3 布尔索引
如果说花式索引是"按编号点名",布尔索引就是"按条件筛选"。把条件表达式套在数组上,会得到一个同形状的布尔数组——掩码(mask);用掩码做索引,True 位置的元素被留下:
revenue = np.array([8200, 5600, 12400, 4100, 9800, 7300])
mask = revenue > 6000
print(mask)
# [ True False True False True True]
print(revenue[mask])
# [ 8200 12400 9800 7300]
print(revenue[revenue <= 5600])
# [5600 4100]
布尔索引返回的也是拷贝。筛选条件可以组合,但要注意两个 Python 语法细节:
print(revenue[(revenue > 5000) & (revenue < 10000)])
# [ 8200 5600 9800 7300]
print(revenue[(revenue < 5000) | (revenue > 10000)])
# [12400 4100]
其一,组合条件用 &(与)、|(或)这两个按位运算符——and / or 是 Python 关键字,没法参与数组的逐元素运算;其二,括号不能省,& 的优先级比 > 高,少了括号整个表达式会被拆错。这两条是布尔索引的头号报错来源,值得背下来。
8. 变形:reshape 与 resize
数据还是那些数据,只是换一种"行列姿势"看——reshape 干的就是这个:
seq = np.arange(1, 13)
print(seq)
# [ 1 2 3 4 5 6 7 8 9 10 11 12]
print(seq.reshape(3, 4))
# [[ 1 2 3 4]
# [ 5 6 7 8]
# [ 9 10 11 12]]
print(seq.reshape(2, -1)) # -1:这根轴的长度让 NumPy 自己算
# [[ 1 2 3 4 5 6]
# [ 7 8 9 10 11 12]]
print(seq.reshape(2, 2, 3))
# [[[ 1 2 3]
# [ 4 5 6]]
#
# [[ 7 8 9]
# [10 11 12]]]
两条规则:新形状的元素总数必须和原来一致(12 个数怎么摆都是 12 个,多一个少一个都报错);-1 表示"这根轴你看着办",只能用一次。另外 reshape 基于第 4 节的步幅机制,通常不复制数据——返回的往往是原数组的视图。
亲戚 np.resize 则允许改变元素总数:新形状要的数不够,它就把原数据循环重复填充(要的少就截断):
base = np.array([5, 10, 15, 20])
print(np.resize(base, (2, 3)))
# [[ 5 10 15]
# [20 5 10]]
print(np.resize(base, (2, 2)))
# [[ 5 10]
# [15 20]]
这个"自动复读"的行为容易埋 bug,用得不多,认识即可——但看到输出里数据诡异地重复,要想到它。
练手场景
传感器接口每分钟吐一个读数,一天下来你拿到 24 个数的一维数组,但报表要按"3 个班次 × 每班 8 小时"呈现:
hourly = np.arange(24) # 24 个整点读数(示意)
shift_table = hourly.reshape(3, 8)
print(shift_table)
# [[ 0 1 2 3 4 5 6 7]
# [ 8 9 10 11 12 13 14 15]
# [16 17 18 19 20 21 22 23]]
一维"流水账"到二维"表格",一个 reshape 的事。
9. 向量化运算:把 for 循环还回去
NumPy 的运算符几乎全部是向量化的:一个表达式,同时作用到每个元素,循环发生在 C 层,你一个 for 都不用写。
9.1 数组 × 标量
n = np.array([2, 4, 6, 8])
print(n + 10) # [12 14 16 18]
print(n * 3) # [ 6 12 18 24]
print(n ** 2) # [ 4 16 36 64]
print(n > 5) # [False False True True]
注意最后一条:比较运算也是向量化的,产出布尔数组——第 7.3 节的掩码正是这么来的。
9.2 数组 × 数组
两个形状相同的数组运算,就是逐个位置对应计算:
unit_price = np.array([12, 18, 25])
quantity = np.array([3, 2, 4])
print(unit_price + quantity) # [15 20 29]
print(unit_price - quantity) # [ 9 16 21]
print(unit_price * quantity) # [ 36 36 100] 三种商品各自的金额
print(unit_price / quantity) # [4. 9. 6.25]
9.3 练手场景:批量换算
手头一排标价,促销统一打 85 折;跑步 App 记录了一串公里数,要换算成英里给海外朋友看:
list_price = np.array([199, 259, 329, 499], dtype=float)
print(list_price * 0.85)
# [169.15 220.15 279.65 424.15]
km = np.array([5, 10, 21.5, 42])
print(km * 0.6214)
# [ 3.107 6.214 13.3601 26.0988]
不写循环、不建新列表、不改原数组——这就是向量化思维的日常形态。用上一章的 %timeit 测一测"循环逐个算 vs 向量化一把算",你会对它快多少有直观感受。
10. 聚合统计:把一摞数压成几个数
聚合(aggregate)运算是向量化的另一面:进去一个数组,出来一个(或一排)概括性的数字。
10.1 基础统计与 axis
quarterly = np.arange(1, 13).reshape(3, 4).astype(float) # 3 名销售 × 4 个季度
print(quarterly)
# [[ 1. 2. 3. 4.]
# [ 5. 6. 7. 8.]
# [ 9. 10. 11. 12.]]
print(quarterly.sum()) # 78.0 全部加总
print(quarterly.mean()) # 6.5 平均值
print(quarterly.sum(axis=0)) # [15. 18. 21. 24.] 每个季度三人合计(竖着压)
print(quarterly.sum(axis=1)) # [10. 26. 42.] 每人全年合计(横着压)
print(quarterly.min(), quarterly.max()) # 1.0 12.0
print(quarterly.argmin(), quarterly.argmax()) # 0 11 最值出现的位置(拉平后的下标)
axis 在这里兑现了第 5 节的诺言:不传 axis 就压成单个数;传了 axis,就沿那根轴压、那根轴消失。argmin / argmax 返回的不是值本身,而是值在哪儿——"最佳员工是第几个人"往往比"最高业绩是多少"更有用。
10.2 标准差:总体与样本
均值告诉你"平均水平",标准差告诉你大家离平均有多远——标准差小,数据扎堆,均值很能代表整体;标准差大,数据散开,均值开始失真(有人考 100 有人考 0,平均 50 分不代表大家都考 50)。
它有两个版本,区别只在分母:
$$\sigma = \sqrt{\frac{\sum_{i=1}^{N}(x_i - \mu)^2}{N}} \qquad s = \sqrt{\frac{\sum_{i=1}^{n}(x_i - \bar{x})^2}{n-1}}$$
左边是总体标准差(除以 N):手上的数据就是全部,比如全班每个人的分数;右边是样本标准差(除以 n-1):手上的数据只是抽样,要靠它估计整体的波动,除以 n-1 是修正小样本带来的偏差。NumPy 的 std() 默认算总体,ddof=1 切到样本:
print(quarterly.std()) # 3.452052529534663 总体标准差
print(quarterly.std(ddof=1)) # 3.605551275463989 样本标准差
数据分析里多数场景拿的是样本,ddof=1 这个参数要眼熟——用错版本,结论可能悄悄偏掉。
10.3 变异系数:不同量纲怎么比波动
身高(单位厘米)的标准差 6,和收入(单位千元)的标准差 8,能说收入"更波动"吗?不能——量纲都不一样。这时候用变异系数(CV):标准差除以均值,得到一个无单位的相对波动:
print(quarterly.std() / quarterly.mean())
# 0.5310850045437943
CV 完全去掉了量纲,"谁更散"就有了公平的尺子。几个经验区间:
| CV 区间 | 波动水平 | 直观感受 | 大致场景 |
|---|---|---|---|
| < 10% | 极低 | 几乎齐步走 | 精密加工的零件尺寸 |
| 10% ~ 20% | 低 | 有起伏,但均值可靠 | 成年人身高、日常气温 |
| 20% ~ 30% | 中 | 正常波动范围 | 考试成绩、绩效评分 |
| 30% ~ 50% | 高 | 明显分散,均值代表性下滑 | 不同城市房价、薪资 |
| > 50% | 极高 | 高度分散,均值基本没有意义 | 股票日收益率、创业公司估值 |
不必死记数字,记住结论就够用:CV 越大,越要警惕"只报一个平均值"的结论。
11. np.where:一行写完一整排 if-else
"对这列数的每一个:满足条件就取甲,否则取乙"——这类逐元素的条件分支,np.where 一个表达式搞定,可以理解为向量化的三元表达式:
delta = np.array([2, -3, 5, -1, 0, 4])
print(np.where(delta < 0, 0, delta))
# [2 0 5 0 0 4] 负数一律清零(只保留上涨)
三个参数依次是:条件、满足时取的值、不满足时取的值;后两个也都可以是数组。如果只传条件,np.where 改行另一份工作——返回满足条件的位置:
latency = np.array([45, 120, 88, 230, 60, 310, 95])
pos = np.where(latency > 100)
print(pos)
# (array([1, 3, 5]),) 满足条件的下标,装在一个元组里
print(latency[pos])
# [120 230 310] 按位置取回对应的值
练手场景
给一批静息心率数据打标签:75 以上记"偏高",其余记"正常":
hr = np.array([58, 72, 91, 65, 84, 77, 62])
print(np.where(hr >= 75, "偏高", "正常"))
# ['正常' '正常' '偏高' '正常' '偏高' '偏高' '正常']
一条命令完成整列打标,这在 Pandas 里也照样用得上。
12. 拼接与拆分
数据常常分头而来:不同来源的表要拼成一张,一张大表又要拆成几段。上下摞(行变多)用 vstack,左右拼(列变多)用 hstack;两者都有个通用版本 concatenate,用 axis 指定拼接方向:
top = np.array([[7, 14],
[21, 28]])
extra_row = np.array([[35, 42]])
print(np.vstack([top, extra_row]))
# [[ 7 14]
# [21 28]
# [35 42]]
print(np.concatenate([top, extra_row], axis=0)) # 等价于 vstack
# [[ 7 14]
# [21 28]
# [35 42]]
extra_col = np.array([[3],
[6]])
print(np.hstack([top, extra_col]))
# [[ 7 14 3]
# [21 28 6]]
print(np.concatenate([top, extra_col], axis=1)) # 等价于 hstack
# [[ 7 14 3]
# [21 28 6]]
拼接的唯一硬要求:沿着拼接方向以外的轴,长度必须对得上(上下摞就要求列数相同,左右拼就要求行数相同),否则报错。
拆分是拼接的逆操作。np.split 传入一组切分位置,在指定轴上把数组切开:
big = np.arange(20).reshape(5, 4)
head, middle, tail = np.split(big, [1, 3]) # 在行下标 1、3 处下刀,切三段
print(head)
# [[0 1 2 3]]
print(middle)
# [[ 4 5 6 7]
# [ 8 9 10 11]]
print(tail)
# [[12 13 14 15]
# [16 17 18 19]]
hsplit 沿列方向对半拆:
wide = np.arange(16).reshape(4, 4)
left, right = np.hsplit(wide, 2)
print(left)
# [[ 0 1]
# [ 4 5]
# [ 8 9]
# [12 13]]
print(right)
# [[ 2 3]
# [ 6 7]
# [10 11]
# [14 15]]
实际工作里,"上半年 + 下半年拼成全年""把数据集拆成训练 / 验证两块",用的就是这对操作。
13. 排序与 Top N
ms = np.array([23, 8, 41, 8, 15, 29, 10, 24]) # 8 次接口响应耗时(毫秒)
print(np.sort(ms))
# [ 8 8 10 15 23 24 29 41]
print(np.argsort(ms))
# [1 3 6 4 0 7 5 2]
print(np.unique(ms))
# [ 8 10 15 23 24 29 41]
三个函数各司其职:
np.sort返回排好序的新数组,原数组不动;np.argsort返回排好序后元素对应的原下标——[1, 3, ...]的意思是"最小的数在下标 1"。这才是真正的明星:拿到下标,就能去别的数组里按同样的顺序取记录;np.unique去重且顺便排好序。
二维数组排序,用 axis 指定方向(每行各自排 / 每列各自排);方法 arr.sort() 则是原地排序——没有返回值,数组自己变了,用之前想清楚:
board = np.array([[9, 4, 7],
[2, 5, 1]])
print(np.sort(board, axis=1)) # 每行各自升序
# [[4 7 9]
# [1 2 5]]
print(np.sort(board, axis=0)) # 每列各自升序
# [[2 4 1]
# [9 5 7]]
ms.sort() # 原地排序:ms 本身被改写
练手场景:Top N
"找出阅读量最高的 3 篇文章"——argsort 加一点下标戏法:
views = np.array([1200, 3500, 800, 4200, 2600, 900, 3100])
top3_idx = np.argsort(views)[::-1][:3] # 升序下标 → 反转成降序 → 取前 3
print(top3_idx)
# [3 1 6]
print(views[top3_idx])
# [4200 3500 3100]
有了下标 [3, 1, 6],文章标题、作者、发布时间这些存在其他数组里的信息,都能按名次同步取出来——这就是 argsort 比 sort 好用的地方。
14. 广播:形状不同,也能一起算
第 9.2 节说过,数组间运算要求形状相同——其实不完全对。NumPy 有一套聪明的补位机制:广播(broadcasting)。两个形状不同的数组运算时,小的那个会被虚拟地扩展成大的形状参与计算。注意"虚拟"二字——不复制任何数据,只是按第 4 节的步幅魔法重复读取,所以零开销。
规则一句话:从最后一根轴向前逐对比较,每对轴要么长度相等,要么有一方为 1(长度 1 的轴被拉长);否则报错。
标量:最简单的广播
其实你早就用过广播了——所有"数组 × 标量"都是:
price = np.array([30, 45, 60])
print(price * 0.9)
# [27. 40.5 54. ]
标量被视为零维,能广播到任何形状。
一行加到每一行
(3, 4) 的表格,加上长度 4 的一维数组——(4,) 先补成 (1, 4),再沿第 0 轴拉长成 (3, 4):
grid = np.arange(1, 13).reshape(3, 4)
row_off = np.array([100, 200, 300, 400]) # 每列各自的偏移
print(grid + row_off)
# [[101 202 303 404]
# [105 206 307 408]
# [109 210 311 412]]
四个偏移量被"复印"到每一行——(3, 4) + (4,) 得到 (3, 4)。
一列加到每一列
想给每行加不同的值,得准备一个 (3, 1) 的列向量,让它沿第 1 轴广播:
col_off = np.array([[1000],
[2000],
[3000]]) # 每行各自的偏移
print(grid + col_off)
# [[1001 1002 1003 1004]
# [2005 2006 2007 2008]
# [3009 3010 3011 3012]]
(3, 4) + (3, 1):第 0 轴 3 对 3 相等;第 1 轴 4 对 1,把 1 拉长成 4。
双向同时扩
两个"瘦"数组相遇,可以一起长胖——(3, 1) 配 (4,):
col = np.array([[2],
[4],
[6]]) # (3, 1)
row = np.array([1, 10, 100, 1000]) # (4,)
print(col + row)
# [[ 3 12 102 1002]
# [ 5 14 104 1004]
# [ 7 16 106 1006]]
(3, 1) 的第 1 轴拉长到 4,(4,) 先补成 (1, 4) 再把第 0 轴拉长到 3,双方在 (3, 4) 会合。一张加法表就这么"凭空"长了出来。
练手场景
3 家门店 × 3 个月的销量表,每个月各有一档全店促销的追加量:
store_sales = np.array([[430, 380, 510],
[520, 460, 480],
[310, 350, 405]])
monthly_boost = np.array([120, 80, 200]) # 每个月各自的追加量
print(store_sales + monthly_boost)
# [[550 460 710]
# [640 540 680]
# [430 430 605]]
不用写任何循环,也不用手动复制三份——这就是广播的价值:用"形状设计"代替"数据搬运"。反过来,当两个形状怎么都对不上时会得到 ValueError: operands could not be broadcast together with shapes (3,) (4,) 这样的报错,先手算一遍 shape 对不对,九成的广播问题当场就能定位。
15. 小结
本章信息量不小,收个尾。理解 ndarray 抓四把钥匙——shape(骨架)、dtype(类型契约)、strides(内存步幅,视图与零拷贝的根源)、axis(运算方向);操作层面一张速查表带走:
| 想做什么 | 顺手工具 |
|---|---|
| 创建 | array、zeros、ones、eye、arange、linspace、default_rng |
| 取数 | 基本切片(视图)、花式索引(拷贝)、布尔索引(& / |) |
| 变形 | reshape(含 -1)、np.resize |
| 逐元素运算 | + - * / **、比较运算,全部向量化 |
| 聚合 | sum、mean、std(ddof=1)、min / max / argmin / argmax,配 axis |
| 条件分支 | np.where(三参替换 / 单参找位置) |
| 拼接拆分 | vstack / hstack / concatenate、split / hsplit |
| 排序去重 | sort、argsort、unique(Top N 用 argsort) |
| 形状补位 | 广播:尾部对齐,相等或为 1 |
两条最容易翻车的规则再敲一遍黑板:切片是视图、花式/布尔索引是拷贝;广播从尾部轴对齐、长度 1 才能拉长。
不过你也感觉到了:ndarray 只认"位置",没有列名、没有索引标签,处理带表头的真实业务数据还是别扭。下一章的 Pandas 会在 ndarray 之上盖起一层"带标签的表格"——Series 和 DataFrame,而你现在掌握的形状、轴、广播,全部原样通用。
下一章:27 · Pandas 数据结构