第 17 章 · 第一个 NumPy 数组
这一章要解决什么:把电容量的列表变成 NumPy 数组,看清它的四个基本属性。 打掉一个致命认知:一个数组只能有一种数据类型 —— 混进字符串会让整列再也算不了数。
本章你需要完成的代码:demos/ch17-first-array.py
17.1 为什么需要 NumPy
先看一个真实的问题。假设你要把一万个容量值换算成 SOH:
caps = [2.50, 2.46, 2.42, ...] # 一万个值
# Python 列表的写法:必须写循环
sohs = []
for c in caps:
sohs.append(c / 2.5)能跑,但有两个问题:
- 慢:每个元素都要走一遍 Python 解释器的类型检查、函数调用,一万次就是一万遍;
- 啰嗦:明明是一件事(整列除一个数),却要写三行。
NumPy 的答案是:
import numpy as np
caps = np.array([2.50, 2.46, 2.42, ...])
sohs = caps / 2.5 # 一行,整列一次算完不只是"少写两行" —— caps / 2.5 底层是一个编译好的 C 循环, 一万个元素和一百万个元素的耗时差不了多少。这就是第 20 章要展开讲的向量化。
为什么 AI 开发必须先学 NumPy:PyTorch 的 Tensor、TensorFlow 的 EagerTensor、 JAX 的 Array,API 都是照着 NumPy 设计的。学会 NumPy 的"整体运算"思维, 再学这些框架会轻松得多。
17.2 四个属性:先看这几个再看别的
import numpy as np
caps_list = [2.50, 2.46, 2.42, 2.38, 2.33]
caps = np.array(caps_list)
print(type(caps_list).__name__) # list
print(type(caps).__name__) # ndarray拿到任何数组,先打印这四个属性:
| 属性 | 含义 | 本例的值 |
|---|---|---|
.shape | 形状(元组) | (5,) |
.dtype | 元素类型 | float64 |
.ndim | 维度数 | 1 |
.size | 元素总个数 | 5 |
print(caps.shape) # (5,) ← 一维 5 个元素
print(caps.dtype) # float64
print(caps.ndim) # 1
print(caps.size) # 5
print(len(caps)) # 5 ← 一维时等于 size;二维时是"行数"点开:(5,) 里的逗号为什么不能省?
因为 (5) 在 Python 里就是数字 5,不是元组。
print(type((5,))) # <class 'tuple'> ← 有逗号才是元组
print(type((5))) # <class 'int'> ← 括号只是括号shape 永远返回元组,所以一维数组的形状写出来带一个尾逗号 (5,), 意思是"1 个维度,长度是 5"。
对比一下就清楚了:
| 形状 | 含义 | 读法 |
|---|---|---|
() | 0 维标量 | 一个数 |
(5,) | 1 维,长度 5 | 一行 5 个格子 |
(3, 4) | 2 维,3 行 4 列 | 一张表格 |
(2, 3, 4) | 3 维 | 一叠 2 张 3×4 的表 |
这个"尾逗号"在写代码时会变成真实的 bug:第 18 章会讲 np.zeros((2, 3)) 少了外层括号会报错。
17.3 数组比列表强在哪
caps_list = [2.50, 2.46, 2.42, 2.38, 2.33]
caps = np.array(caps_list)
print(caps_list * 2) # [2.5, 2.46, ..., 2.5, 2.46, ...] 列表 × 2 = 重复一遍
print(caps * 2) # [5. 4.92 4.84 4.76 4.66] 数组 × 2 = 逐元素乘同一行代码,两种完全不同的语义 —— 这是从列表切换到数组时最容易看走眼的地方。
再感受一下"一行算完两万个数":
big = np.arange(1, 20_001)
print(big.sum()) # 200,010,000np.arange(1, 20001) 造出 1~20000 的数组,.sum() 一次求和。 如果用 Python 的 sum(range(1, 20001)) 也能得到同样结果,但 NumPy 版本快得多。
.sum() 里的点号值得注意:这是方法(属于数组自己的函数), 而不是 sum(big) 那样的内置函数。两者都能用,但方法版本更快、语义更清楚。
17.4 ★ 一个数组只能有一种类型
点开:这是本章最重要的内容,也是零基础最常踩的坑
NumPy 数组在内存里是"等宽、连续"排布的 —— 这正是它快的原因。 代价是:所有元素必须是同一种类型。
当你用混合类型创建数组时,NumPy 会强制统一,规则是:
bool → int → float → 字符串
(窄) (宽)向"装得下所有人"的那个类型靠拢。
例子一:int + float + bool → float64
mixed = np.array([1, 2.5, True])
print(mixed.dtype) # float64
print(mixed) # [1. 2.5 1. ]True是bool,能无损变成1;1是int,能无损变成1.0;2.5必须是float;- 于是全体升格为
float64。
这是"安全的" —— 没有信息丢失(虽然 True 变成了 1.0,但从数值角度看是对的)。
例子二:int + 字符串 → 字符串(危险!)
s = np.array([1, "2"])
print(s.dtype) # <U21 (U = Unicode,21 是最大长度)
print(s) # ['1' '2'] ← 数字也被变成了字符串!这一次信息全丢了 —— 1 变成了 "1"。而且:
s + 1
# numpy._core._exceptions._UFuncNoLoopError:
# ufunc 'add' did not contain a loop with signature matching types
# (dtype('<U21'), dtype('int64')) -> None这个报错很长很吓人,但翻译成人话就是:"字符串数组没法做加法"。
为什么这件事对本书至关重要
因为从文件读进来的数据里,字符串和数字是混在一起的:
# 从 CSV 读出来的"数字",其实全是字符串
row = {"cell_id": "C001", "capacity_ah": "2.5092", "soh": "0.9999"}
# 如果直接喂给 NumPy:
arr = np.array(list(row.values()))
print(arr.dtype) # <U6 —— 整行变成字符串数组!
print(arr * 2) # ['C001C001' '2.50922.5092' ...] —— 完全不报错,但结果毫无意义这就是为什么读 CSV 必须转类型(第 12 章), 也是为什么 Pandas 的 read_csv 会自动推断类型如此重要(第 23 章)—— 它替你把这件事做掉了。
17.5 维度的直觉
scalar = np.array(2.5) # 0 维
vector = np.array([2.5]) # 1 维
matrix = np.array([[1, 2, 3], [4, 5, 6]]) # 2 维
print(scalar.shape, scalar.ndim) # () 0
print(vector.shape, vector.ndim) # (1,) 1
print(matrix.shape, matrix.ndim) # (2, 3) 2心智模型:
1 维 → 一行格子 [a, b, c, d]
2 维 → 一张表格 行 × 列
3 维 → 一叠表格 (比如一批图像:张数 × 高 × 宽)对应到电池数据:
- 一支电芯的容量序列 → 1 维数组,
shape = (862,) - 多支电芯排成表(行=样本,列=特征)→ 2 维数组,
shape = (13967, 5)
注意 0 维和 1 维的区别:
print(np.array(2.5).shape) # () 0 维标量
print(np.array([2.5]).shape) # (1,) 1 维,一个元素看起来差不多,但在后面做广播(第 21 章)和 Pandas 索引(第 25 章)时, () 和 (1,) 会导致完全不同的行为。所以"先 print shape"是一条纪律。
17.6 dtype:指定与转换
# 创建时指定
print(np.array([1, 2, 3], dtype=np.float64)) # [1. 2. 3.]
# 事后转换(返回新数组!)
float32_arr = caps.astype(np.float32)
print(float32_arr.dtype) # float32
# 转整数是截断,不是四舍五入
print(np.array([2.5, 2.9]).astype(np.int64)) # [2 2].astype() 返回新数组,原数组不变 —— 和 round() 一样,忘了接收返回值就白算。
内存:为什么 AI 里到处是 float32
print(caps.nbytes) # 40 (5 个 float64,每个 8 字节)
print(caps.astype(np.float32).nbytes) # 20 (每个只要 4 字节)| 类型 | 每个元素 | 精度(有效数字) | 典型用途 |
|---|---|---|---|
float64 | 8 字节 | ~15~16 位 | 科学计算默认、累加器 |
float32 | 4 字节 | ~7 位 | 深度学习默认(GPU 算得快、省显存) |
float16 / bfloat16 | 2 字节 | ~3~4 位 | 混合精度训练 |
int64 | 8 字节 | 精确 | 计数、索引 |
int8 / uint8 | 1 字节 | 0~255 | 图像像素 |
这就是"为什么模型参数是 32 位浮点"的答案之一:一张 10 亿参数的模型, float64 要 8 GB 显存,float32 只要 4 GB,float16 只要 2 GB。 在显存就是瓶颈的场景下,这个差别是"能不能跑起来"的区别。
点开:float32 的代价是什么?
精度。float32 只有约 7 位有效数字。
在单次运算里无所谓(我们的数据本来就有 ±0.4% 的噪声)。 但在累加时会出问题:
import numpy as np
big = np.full(10_000_000, 1e-8, dtype=np.float32)
print(big.sum()) # 可能得到 0.1 附近,但也可能有明显误差
print(big.sum(dtype=np.float64)) # 用 float64 累加,结果准确得多这就是"混合精度训练"里的关键技巧:
- 前向/反向用
float16(快、省显存) - 累加(尤其是损失和梯度累加)用
float32
PyTorch 的 torch.cuda.amp.GradScaler 就是自动做这件事的工具。
实践建议:
- 数据处理阶段(本书)→ 用默认的
float64,省心; - 模型训练阶段 →
float32,需要稳定性时把关键累加提升到float64。
17.7 什么时候用列表,什么时候用数组
list | ndarray | |
|---|---|---|
| 元素类型 | 可以混(字符串+数字+对象) | 必须同一种 |
| 大小 | 可以随意增删 | 固定(改大小要新建) |
| 整列运算 | 要写循环 | 一行搞定 |
| 速度 | 慢 | 快(底层 C) |
| 内存 | 大(每个元素是对象) | 小(紧凑排布) |
| 适合 | 配置项、命令行参数、混合结构 | 数值、信号、图像、特征矩阵 |
判断方法:如果你要写 for 循环遍历它做算术 → 用数组; 如果你要往里不断 append 不同东西 → 用列表。
从第 23 章起,Pandas 的底层就是 NumPy 数组:df["soh"] 是一个 Series, 但 df["soh"].to_numpy() 就是一个 ndarray。而 Pandas 的整列运算能一行完成, 正是因为底层走了 NumPy 的向量化。
17.8 完整示例与输出
cd code
python demos/ch17-first-array.py真实输出(节选):
=== ② 四个属性(先看这几个再看别的) ===
caps.shape = (5,) ← 形状。一维 5 个元素 → (5,)
caps.dtype = float64 ← 元素类型
caps.ndim = 1 ← 维度数
caps.size = 5 ← 元素总个数
len(caps) = 5 ← 一维时等于 size;二维时是「行数」
=== ③ 数组比列表强在哪 ===
列表 * 2 是「重复」: [2.5, 2.46, 2.42, 2.38, 2.33, 2.5, 2.46, 2.42, 2.38, 2.33]
数组 * 2 是「逐元素乘」: [5. 4.92 4.84 4.76 4.66]
=== ④ 混类型会怎样(重点) ===
np.array([1, 2.5, True])
dtype = float64
内容 = [1. 2.5 1. ]
→ int 和 bool 都能无损装进 float64,于是全体升格为 float64。
规则:bool → int → float → 字符串,向「装得下所有人」的那个类型靠拢。
np.array([1, '2'])
dtype = <U21(U 表示 Unicode 字符串,21 是最大长度)
内容 = ['1' '2'] ← 数字也被变成了字符串!
→ 一旦数组里混进字符串,整列就再也算不了数。
=== ⑥ 指定与转换 dtype ===
内存 : float64 每个数 8 字节,float32 只要 4 字节。
本数组 float64 40 字节 → float32 20 字节本章练习
练习 1:混合类型的数组会变成什么
不要运行,先猜,再验证:
import numpy as np
a = np.array([1, 2.5, True])
b = np.array([1, "2"])
print(a.dtype, b.dtype)
for x in a:
print(x, type(x).__name__)要求:解释 a 和 b 分别变成了什么类型,以及为什么。
参考答案
float64 <U21
1.0 float64
2.5 float64
1.0 float64a 为什么是 float64?
升格规则是 bool → int → float → 字符串,向"装得下所有人"的那个类型靠拢:
True(bool)→ 能无损变成1→ 再变成1.01(int)→ 能无损变成1.02.5(float)→ 必须是 float
三者都能装进 float64,所以全体升格。这是安全的(数值上没有信息丢失)。
b 为什么是 <U21?
<表示小端字节序,U表示 Unicode 字符串,21是最大长度。1本来能装进 int,但"2"是字符串,而字符串是最宽的类型 ——1只能降级成"1"。- 于是整列都变成了字符串。
<U21 里的 21 是怎么来的? NumPy 会取所有字符串里最长的那一个的长度。 本例两个字符串长度都是 1,21 是 NumPy 内部的对齐/最小长度策略。具体数字不必记,知道它是"字符串数组"就够了。
危险之处:
b * 2 # array(['11', '22']) ← 字符串重复,不报错但毫无意义
b + 1 # UFuncTypeError —— 报错,但信息很长很吓人结论:混进字符串后,* 会静默给出错误结果,+ 会报错。 两种都比"得到正确数字"糟得多。
完整练习:exercises/ex17-01-first-array
练习 2:数组还是列表,该用哪个?
判断下面四个场景该用 list 还是 ndarray,并说明理由:
- 存 100 万个容量测量值,要算它们的均值和标准差;
- 存程序支持的几个命令行开关
["-v", "--debug", "-o"]; - 存一支电芯 862 圈的内阻序列,要和另一支做逐元素相减;
- 存一个配置,里面有字符串的模型名、数字的学习率、布尔的开关键。
参考答案
| 场景 | 选择 | 理由 |
|---|---|---|
| 1. 100 万个值算均值标准差 | ndarray | 同类型数值 + 整列运算 + 数据量大。arr.mean() 一行且快几十倍 |
| 2. 命令行开关 | list | 数量极少、要频繁增删、不需要算术 |
| 3. 逐元素相减 | ndarray | r1 - r2 一行搞定;用 list 得写循环 |
| 4. 混合类型的配置 | list 或 dict | 类型不一致(str + float + bool),ndarray 会强制升格成字符串,毁掉数字 |
判断口诀:
要写 for 循环做算术吗? → 是 → ndarray
元素类型一样吗? → 否 → list / dict
元素个数固定吗? → 否(要频繁增删)→ list
数据量大吗(> 几万)? → 是 → ndarray(内存和速度都差一个数量级)场景 4 是很多人踩过的坑:
cfg = np.array(["resnet50", 0.001, True])
print(cfg.dtype) # <U8 ← 学习率变成字符串 "0.001" 了!
print(float(cfg[1])) # 0.001 ← 还能救回来,但每次都转类型很烦配置永远用 dict(第 09 章),不要用数组。
一个补充:NumPy 也有 object dtype 的数组(能装混合类型), 但它失去了数组的全部性能优势(每个元素变成了指针), 那就没有任何理由不用 list 了。
练习 3:内存账算一遍
一支电芯 862 圈的容量序列,分别用 float64 和 float32 存,各占多少内存? 如果有 100 万支电芯(每支 862 圈),两者差多少 GB?
参考答案
import numpy as np
n = 862
a = np.zeros(n, dtype=np.float64)
b = np.zeros(n, dtype=np.float32)
print(a.nbytes, b.nbytes) # 6896 3448单支电芯:
float64:862 × 8 = 6,896 字节 ≈ 6.7 KBfloat32:862 × 4 = 3,448 字节 ≈ 3.4 KB
100 万支电芯:
float64:1000000 × 862 × 8 = 6,896,000,000 字节 ≈ 6.42 GBfloat32:1000000 × 862 × 4 = 3,448,000,000 字节 ≈ 3.21 GB
相差约 3.2 GB。
这意味着什么:
- 一张 24 GB 显存的 GPU,
float32能装下、float64装不下 —— 直接决定能不能跑; - 即使内存够,
float32的读写带宽需求也只有一半,训练速度快 1.5~2 倍。
所以深度学习的默认精度是 float32,而不是科学计算惯用的 float64。
但要记住代价(见 17.6 的补充块):精度只有约 7 位有效数字。 在本课程的数据处理阶段,用 float64 就好 —— 数据量小,精度更省心。
面向 AI 开发:张量就是"加了两个特性的 NumPy 数组"
学完这一章,你其实已经掌握了深度学习框架里 80% 的数据操作心智。
张量(Tensor)与 ndarray 的三个主要区别:
NumPy ndarray | 框架 Tensor | |
|---|---|---|
| 能在哪算 | CPU 内存 | CPU 或 GPU |
| 自动求导 | 不支持 | 支持(.backward() 自动算梯度) |
| 形状/索引/广播 API | 基准 | 几乎完全一致 |
API 有多像:
# NumPy
import numpy as np
x = np.arange(12).reshape(3, 4)
y = (x - x.mean(axis=0)) / x.std(axis=0)
print(y.shape) # (3, 4)
# PyTorch —— 除了 import 和 .to("cuda"),几乎一字不差
import torch
x = torch.arange(12).reshape(3, 4)
y = (x - x.mean(dim=0)) / x.std(dim=0) # 注意:torch 叫 dim,numpy 叫 axis
print(y.shape) # torch.Size([3, 4])所以你在第 19~22 章学的四件事,会直接迁移过去:
| 本章/后续 | 在 AI 里的对应 |
|---|---|
.shape 检查形状(17) | 报错 90% 来自形状不匹配,print(x.shape) 是第一步 |
| 视图 vs 拷贝(19) | x.view() / x.reshape() 在 PyTorch 里同样是视图语义,改视图会影响原张量 |
| 向量化(20) | 训练循环里绝不能写 for 遍历样本,必须批量矩阵运算 |
| 广播(21) | (B, 1) - (B, T) 这种对齐是标准操作(每行减自己的均值) |
三个立刻能用的迁移习惯:
① 每个数据处理步骤后 print(x.shape)。 这是排查张量问题的第一步,没有例外。
② 用 float32 思考内存。 模型参数量 × 4 字节 = 显存需求。 一个 70 亿参数的模型,光参数就要 28 GB(7e9 × 4)—— 这就是为什么需要量化(int8 只要 7 GB)。
③ 别用 == 比浮点,用 torch.allclose / np.allclose。 第 04 章讲过的浮点误差, 在 GPU 上更严重(并行累加的加法顺序不确定)。