第 19 章 · 数组的索引与切片(以及视图 vs 拷贝)
这一章要解决什么:从数组里精确取出想要的部分。 打掉 NumPy 最难查的一类 bug:切片返回的是"视图"而不是"拷贝" —— 改了切片,原数组也变了。
本章你需要完成的代码:demos/ch19-array-index.py
19.1 一维索引与切片(和列表一样)
soh = np.array([1.0, 0.996, 0.988, 0.980, 0.972,
0.964, 0.956, 0.944, 0.936, 0.928])
print(soh[0]) # 1.0 第一个
print(soh[-1]) # 0.928 最后一个
print(soh[-3]) # 0.944 倒数第三个规则和列表完全一致:从 0 开始、支持负索引、切片左闭右开。
print(soh[2:8]) # [0.988 0.98 0.972 0.964 0.956 0.944] 第 3~8 个
print(soh[:3]) # 前 3 个
print(soh[-3:]) # 最后 3 个
print(soh[::2]) # 每隔一个取
print(soh[::-1]) # 倒序soh[2:8] 的长度是 6(8 - 2 = 6)—— 右端取不到。 要取到下标 7(第 8 个),终点必须写 8。这是本课程第四次强调左闭右开, 因为它确实是最常出错的地方。
19.2 花式索引:用下标列表一次取多个
print(soh[[0, 4, 9]]) # [1. 0.972 0.928] 第 1、5、10 个
print(soh[[-1, -2]]) # [0.928 0.936] 支持负数
print(soh[[0, 0, 0]]) # [1. 1. 1.] 可以重复取这是列表做不到的 —— list[[0, 4, 9]] 会报 TypeError。
术语:用"下标数组"当索引,叫花式索引(fancy indexing)。
重要区别:花式索引返回的是拷贝,不是视图(下面 19.5 会讲为什么这重要)。
实用场景:抽取特定的几支电芯。
cells = np.array(["C001", "C002", "C003", "C004"])
caps = np.array([2.50, 2.46, 2.42, 2.38])
idx = [0, 2] # 只看第 1 和第 3 支
print(cells[idx], caps[idx])
# ['C001' 'C003'] [2.5 2.42]19.3 布尔索引:按条件挑(最常用)
mask = soh < 0.95
print(mask)
# [False False False False False False False True True True]
print(soh[mask]) # [0.944 0.936 0.928]
print(soh[soh < 0.95]) # 完全等价,直接写更常见
print(mask.sum()) # 3 ← True 当 1 加,所以 sum 就是"个数"
print(mask.mean()) # 0.3 ← 布尔数组的均值就是"占比".sum() 和 .mean() 这两个技巧务必记住 —— 它们是后面所有"数一数有多少个满足条件" 的标准写法,也直接用到了 Pandas。
多条件:& | 加括号
print(soh[(soh >= 0.93) & (soh < 0.97)]) # [0.964 0.956 0.944 0.936]三个必踩的坑:
# ❌ 用 and → 直接报错
soh[soh >= 0.93 and soh < 0.97]
# ValueError: The truth value of an array with more than one element is ambiguous
# ❌ 不加括号 → 优先级错误
soh[soh >= 0.93 & soh < 0.97] # & 优先级高于比较,被解析错
# ✅
soh[(soh >= 0.93) & (soh < 0.97)]点开:为什么数组不能用 and?
因为 Python 的 and 需要判断"左边整体是真还是假":
x = np.array([True, False])
if x and True: # x 整体是真还是假?这个问题没有答案 —— 数组里有一个 True 一个 False,整体算什么? 所以 NumPy 直接报错,而不是猜一个。
& 和 and 的区别:
and | & | |
|---|---|---|
| 作用于 | 整个对象(要一个布尔值) | 逐元素(返回布尔数组) |
array and array | ❌ 报错 | ✅ 逐元素与 |
True and False | ✅ 返回 False | True & False 也返回 False |
记忆法:and 问"整体",& 问"每个位置"。 数组要的是后者,所以必须用 &。
同理:or → |、not → ~。
soh[~(soh < 0.95)] # 取反:不小于 0.95 的19.4 二维索引
X = np.arange(1, 13).reshape(3, 4)
# [[ 1 2 3 4]
# [ 5 6 7 8]
# [ 9 10 11 12]]
print(X[0]) # [1 2 3 4] 整行
print(X[:, 0]) # [1 5 9] 整列 ← 逗号前是"所有行"
print(X[1, 2]) # 7 单个元素
print(X[0:2, 1:3]) # 子块:2 行 × 2 列
print(X[:, [0, 3]]) # 指定列:第 0 和第 3 列
print(X[X > 6]) # [ 7 8 9 10 11 12] 按条件挑(返回一维)二维索引的读法:X[行, 列] —— 先横后纵。
| 写法 | 取什么 |
|---|---|
X[0] | 第 0 行(一整行) |
X[:, 0] | 第 0 列(所有行的第 0 个) |
X[1, 2] | 第 1 行第 2 列的那一个元素 |
X[0:2, 1:3] | 行 0~1、列 1~2 的子块 |
X[X > 6] | 所有大于 6 的元素,拍平成一维 |
点开:一个不报错但结果全错的写法
print(X[:, 0]) # [1 5 9] ✅ 第 0 列
print(X[:0]) # [] ❌ 空数组!X[:0] 是什么意思:切片"从开始到下标 0(不含)" —— 当然是空的。
危险之处:它不报错。你会得到一个 shape=(0, 4) 的空数组, 然后后面的计算要么报错,要么静默产生空结果。
预防:养成 print(结果.shape) 的习惯。空数组一看 shape 就知道了。
19.5 ★ 视图 vs 拷贝:本章最重要的坑
点开:这一个知识点值得单独一节,因为它是 NumPy 最难查的 bug 来源
问题演示
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
sub = arr[1:4] # 切片
print(sub) # [2. 3. 4.]
sub[0] = 99.0 # 改切片
print(sub) # [99. 3. 4.]
print(arr) # [ 1. 99. 3. 4. 5.] ← 原数组也变了!!arr 被改了,但不是你直接改的。 如果你在一个大项目里遇到这种情况, 可能要排查很久才能想到"问题出在三行前的那个切片"。
为什么会这样
切片不复制数据,只是"加了一个框"。
内存里的数字: [1.0, 2.0, 3.0, 4.0, 5.0]
↑
arr 的看法:整个数组
sub = arr[1:4]: 只看第 1~3 个位置,但指向同一块内存
sub[0] = 99
└─→ 改的是内存里的第 1 个位置 ─→ arr 也能看到怎么解决:.copy()
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
copied = arr[1:4].copy() # 真·复制
copied[0] = 99.0
print(arr) # [1. 2. 3. 4. 5.] ← 没变 ✅怎么判断:看 .base
a = np.arange(5)
print(a[1:3].base is a) # True ← 视图:base 指向原数组
print(a[1:3].copy().base) # None ← 拷贝:没有 base.base 就是这条线索:视图的 .base 指向"它基于谁",拷贝的是 None。
哪些操作返回视图,哪些返回拷贝
| 返回视图(改它会改原数组) | 返回拷贝(安全) |
|---|---|
切片 a[1:5] | 花式索引 a[[0, 2]] |
reshape / ravel | 布尔索引 a[a > 5] |
.T 转置 | flatten() |
np.newaxis 增维 | astype() |
a.view() | a.copy() / np.copy(a) |
记忆法:
"只改变解读方式"的操作 → 视图(切片、reshape、转置、增维) "需要挑挑拣拣或重新排版"的操作 → 拷贝(花式/布尔索引、类型转换、展平)
为什么后者必须拷贝?因为花式索引和布尔索引挑出来的元素在原内存里不连续, 没法用"加个框"的方式表示,只能复制一份出来。
为什么要有视图(而不是全部都拷贝)
省内存、省时间。
huge = np.zeros(100_000_000) # 8 亿字节 = 800 MB
part = huge[:1000] # 视图:0 额外内存
part_copy = huge[:1000].copy() # 拷贝:8000 字节(这里还小)如果每次切片都复制,那么对一个 10 GB 的数据集连续做几次切片, 内存直接爆掉。视图让"切一片"变成零成本操作。
这就是 NumPy 的设计哲学:默认不复制(快),需要复制时你明确说 .copy()(显式优于隐式)。
实践纪律
① 需要独立副本时,主动写 .copy()。
# ❌ 危险:后面如果改了 subset,df 也会变
subset = data[data["soh"] < 0.85]
# ✅ 明确复制
subset = data[data["soh"] < 0.85].copy()这条在 Pandas 里表现为 SettingWithCopyWarning(第 25 章会详细讲)—— 同一个问题的另一个表现形式。
② 函数里拿到数组参数时,别假设它是"属于你的"。
def normalize(arr):
arr = arr.ravel() # 视图!
arr /= arr.max() # 原地修改 → 调用方的数组被改了
return arr
data = np.array([1.0, 2.0, 4.0])
normalize(data)
print(data) # [0.25 0.5 1. ] ← 被"顺手"改了修复方式二选一:
arr = arr.ravel().copy() # ① 明确复制
arr = arr / arr.max() # ② 用产生新数组的写法(不原地改)③ 调试时用 is 和 .base 快速判断。
print(result.base is original) # True → 是视图,小心19.6 修改数组内容的几种方式
a = np.array([1.0, 2.0, 3.0, 4.0])
a[0] = 10 # 单点赋值
a[a < 3] = 0 # 条件赋值:把小于 3 的归零
a[[1, 3]] = [100, 200] # 花式赋值
a[:] = 0 # 整体赋值"条件赋值"是数据清洗里最常用的:
soh[soh < 0.80] = 0.80 # 把所有低于阈值的截断到阈值(clip 的手写版)注意原地修改 vs 产生新数组:
a = np.array([1.0, 2.0])
a = a * 2 # 产生新数组,a 指向新对象
a *= 2 # ✅ 原地修改(等价于 a = a * 2 但复用内存)对单元素来说没区别,但对 10 GB 的数组,a *= 2 能省下一份内存。
19.7 完整示例与输出
cd code
python demos/ch19-array-index.py真实输出(节选):
=== ② 一维切片(左闭右开) ===
soh[2:8](第 3~8 个)= [0.988 0.98 0.972 0.964 0.956 0.944]
长度 = 6(8 - 2 = 6)✅
=== ③ 花式索引:用下标列表一次取多个(列表做不到) ===
第 1、5、10 个 → soh[[0, 4, 9]] = [1. 0.972 0.928]
=== ④ 布尔索引:按条件挑(最常用) ===
满足条件的个数 = 3(True 当 1 加)
满足条件的占比 = 30.0% ← 布尔数组的均值就是比例
=== ⑥ 视图 vs 拷贝 ★ 本章最重要的坑 ===
切片得到的 sub = arr[1:4],然后 sub[0] = 99
sub = [99. 3. 4.]
arr = [ 1. 99. 3. 4. 5.] ← 原数组被改了!
怎么判断是视图还是拷贝?看 .base:
a[1:3].base is a → True (视图)
a[1:3].copy().base → None (拷贝)本章练习
练习 1:索引、切片与布尔索引
给定 10 个 SOH 值,完成三件事:
- 取"第 3~8 个"(打印结果和长度);
- 取"第 1、5、10 个"(花式索引);
- 用布尔索引挑出
< 0.94的值,打印个数和占比。
参考答案
import numpy as np
soh = np.array([1.0, 0.996, 0.988, 0.98, 0.972, 0.964, 0.956, 0.944, 0.936, 0.928])
# ① 第 3~8 个:起点下标 2,终点写 8(右端取不到)
mid = soh[2:8]
print(mid) # [0.988 0.98 0.972 0.964 0.956 0.944]
print(len(mid)) # 6 = 8 - 2 ← 用长度验证
# ② 第 1、5、10 个:下标是 0、4、9
picked = soh[[0, 4, 9]]
print(picked) # [1. 0.972 0.928]
# ③ 布尔索引
mask = soh < 0.94
print(mask.sum()) # 2
print(f"{mask.mean():.1%}") # 20.0%
print(soh[mask]) # [0.936 0.928]三个易错点:
- "第 3~8 个"要写
[2:8]—— 起点是 2(因为 0 基),终点是 8(因为右端取不到)。 用len()反向验证是最可靠的:结果长度应该等于8 - 2 = 6。 - "第 1、5、10 个"的下标是 0、4、9 —— 都减 1。用
soh[[0,4,9]]一次取出。 .mean()直接给出占比,不用手算除法。
多条件的写法(顺便练):
band = soh[(soh >= 0.93) & (soh < 0.97)]
print(band) # [0.964 0.956 0.944 0.936]注意 & 两边都必须加括号。
完整练习:exercises/ex19-01-index-slice
练习 2:找出这三段代码里的"视图陷阱"
每段都有一个问题,说出问题是什么、会造成什么后果、怎么修。
# 段一
data = np.array([1.0, 2.0, 3.0, 4.0])
chunk = data[1:3]
chunk[0] = 100
print(data)
# 段二
def standardize(x):
x = x.reshape(-1)
x -= x.mean()
x /= x.std()
return x
vals = np.array([1.0, 2.0, 3.0])
standardize(vals)
print(vals)
# 段三
arr = np.arange(10)
first_five = arr[arr < 5]
first_five[0] = 999
print(arr)参考答案
段一:切片是视图,改 chunk 会改 data
data = np.array([1.0, 2.0, 3.0, 4.0])
chunk = data[1:3] # 视图
chunk[0] = 100 # 改的是 data[1]
print(data) # [ 1. 100. 3. 4.] ← data 被改了修复:chunk = data[1:3].copy()
段二:reshape 和 -= 都在原地改
x = x.reshape(-1) # 视图(虽然这里 x 本来就是一维,reshape 没有实际影响)
x -= x.mean() # 原地修改 → 改的是 vals 的内存
x /= x.std() # 又一次原地修改standardize(vals) 调用后,vals 本身被标准化了。 调用方通常不会预期"我的数组被函数改了",这就是所谓的副作用。
修复:
def standardize(x):
x = x.reshape(-1).copy() # ① 明确复制
x = (x - x.mean()) / x.std() # ② 用产生新数组的写法
return x段三:这个其实不是 bug —— 布尔索引返回拷贝
arr = np.arange(10)
first_five = arr[arr < 5] # 布尔索引 → 拷贝
first_five[0] = 999 # 改的是拷贝
print(arr) # [0 1 2 3 4 5 6 7 8 9] ← arr 没变 ✅注意:arr < 5 得到的 first_five 是 [0, 1, 2, 3, 4], 改 first_five[0] = 999 不会影响 arr。
这正是"哪些返回视图、哪些返回拷贝"这条规律的价值:
print(arr[arr < 5].base) # None → 拷贝
print(arr[1:3].base is arr) # True → 视图如果不确定,记住这个最保险的做法:
只要你要修改这个结果,就加
.copy()。零成本的心智负担。
代价:多占一份内存。对超大数组,可以先用 .base 判断再决定(但相信我,99% 的情况下直接 .copy() 更划算 —— 因为排查这类 bug 的时间远比内存贵)。
练习 3:布尔数组的两个"魔法"
import numpy as np
soh = np.array([1.0, 0.95, 0.92, 0.88, 0.84, 0.80])要求:用一行代码分别回答:
- 有多少个 SOH 低于 0.90?
- 低于 0.90 的占比是多少?
- 把低于 0.85 的值全部替换成 0.85(原地修改)。
参考答案
import numpy as np
soh = np.array([1.0, 0.95, 0.92, 0.88, 0.84, 0.80])
# ① 个数:布尔数组的 sum
print((soh < 0.90).sum()) # 3 (0.88, 0.84, 0.80)
# ② 占比:布尔数组的 mean
print(f"{(soh < 0.90).mean():.1%}") # 50.0%
# ③ 原地替换
soh[soh < 0.85] = 0.85
print(soh) # [1. 0.95 0.92 0.88 0.85 0.85]为什么 .sum() 和 .mean() 能这样用:
soh < 0.90 返回的是 [False False False True True True]。 在 NumPy 里,布尔参与算术时 True 当 1、False 当 0:
.sum()→0+0+0+1+1+1 = 3→ 个数.mean()→3/6 = 0.5→ 比例
这两个技巧的价值:它们把"数一数满足条件的有多少"变成了一次方法调用, 不用写 for + 计数器。在 Pandas 里同样适用:
df["soh"].lt(0.90).sum() # 个数
df["soh"].lt(0.90).mean() # 占比③ 的"原地替换"是数据清洗里最常用的动作:
soh[soh < 0.85] = 0.85 # 手写版 clip(下界截断)
soh[soh > 1.00] = 1.00 # 上界截断注意 soh[soh < 0.85] = 0.85 和 soh[soh < 0.85].fill(0.85) 的区别: 前者是原地赋值(真正修改了 soh),后者需要 np.ndarray.fill() 的特定用法才生效。 记住这个模式:数组[条件] = 新值 才是可靠的原地修改。
等价的 NumPy 函数(第 33 章讲异常处理时会用):
np.clip(soh, 0.85, 1.00) # 一步完成上下界截断,返回新数组面向 AI 开发:视图陷阱在 PyTorch 里一模一样
这是本书最值得"迁移"的一章 —— 因为深度学习框架完整继承了 NumPy 的视图语义。
import torch
x = torch.arange(6).reshape(2, 3)
y = x[0] # 切片 → 视图
y[0] = 99
print(x)
# tensor([[99, 1, 2],
# [ 3, 4, 5]]) ← x 被改了!在 PyTorch 里最容易踩的三个场景:
① .view() 和 .reshape() 的区别。
x = torch.zeros(4, 4)
y = x.view(16) # 视图:要求内存连续,否则报错
z = x.reshape(16) # 可能返回视图,也可能返回拷贝(自动处理).view() 严格要求内存连续(非连续时直接报错),.reshape() 会按需复制。 要独立副本就 .clone()(对应 NumPy 的 .copy())。
② 数据增强时的"原地操作"。
# ❌ 危险:原地修改会污染原始数据集
img = dataset[0] # 拿到的是引用
img /= 255.0 # 原地归一化 → 数据集里的那张图也被改了!
# ✅ 安全
img = dataset[0].clone()
img = img / 255.0 # 产生新张量这个 bug 特别阴险:第一次跑没问题(数据集里那张图被"顺手"归一化了), 第二次跑就发现数据已经不对了 —— 因为 normalize 被执行了两次。
③ 优化器更新参数。
# PyTorch 的参数更新是"原地"的(为了省内存)
with torch.no_grad():
param -= lr * param.grad # 原地修改 param
param.grad.zero_() # 原地清零这是有意为之:模型有几十亿参数,每步都复制一份会爆显存。 但这也意味着 param 是共享的 —— 你在别处保存的引用会看到更新后的值。
三个立刻能用的迁移习惯:
| NumPy | PyTorch | 说明 |
|---|---|---|
.copy() | .clone() / .detach() | 要独立副本 |
arr.base | x._base | 检查是不是视图(内部属性,仅调试用) |
arr[条件] = 值 | x.masked_fill_(mask, value) | 带下划线的是原地版本 |
| 无 | torch.no_grad() | 明确声明"我不要梯度",也提示"这里会改数据" |
一条通用纪律:
任何带下划线结尾的方法(
_)都是原地操作。add_/mul_/zero_/clamp_/masked_fill_—— 看到下划线就想到"它会改原对象"。
这条纪律在 NumPy 里也部分适用(sort vs sort_ 在旧版里,+= vs +), 但 PyTorch 把它变成了系统性的命名约定。记住它,能省掉很多"改了一个地方结果另一个地方也变了"的困惑。