首页/Python 与数据实践/NumPy 数组与向量化
Python 与数据实践 · 第 26 章

NumPy 数组与向量化

上一章,notebook 实验台已经搭好,也试了试手。但很快你就撞上新麻烦:手里只有 Python 列表,算个均值要 sum(x) / len(x),两列数据对应相乘得写 for 循环——数据一多,代码臃肿,速度还慢

约 40 分钟16,022 字46 个代码示例

上一章,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 数据结构