Skip to content

第 19 章 · 数组的索引与切片(以及视图 vs 拷贝) ​

这一章要解决什么:从数组里精确取出想要的部分。 打掉 NumPy 最难查的一类 bug:切片返回的是"视图"而不是"拷贝" —— 改了切片,原数组也变了。

本章你需要完成的代码:demos/ch19-array-index.py

19.1 一维索引与切片(和列表一样) ​

python
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 开始、支持负索引、切片左闭右开。

python
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 花式索引:用下标列表一次取多个 ​

python
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 会讲为什么这重要)。

实用场景:抽取特定的几支电芯。

python
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 布尔索引:按条件挑(最常用) ​

python
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。

多条件:& | 加括号 ​

python
print(soh[(soh >= 0.93) & (soh < 0.97)])      # [0.964 0.956 0.944 0.936]

三个必踩的坑:

python
# ❌ 用 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 需要判断"左边整体是真还是假":

python
x = np.array([True, False])
if x and True:       # x 整体是真还是假?

这个问题没有答案 —— 数组里有一个 True 一个 False,整体算什么? 所以 NumPy 直接报错,而不是猜一个。

& 和 and 的区别:

and&
作用于整个对象(要一个布尔值)逐元素(返回布尔数组)
array and array❌ 报错✅ 逐元素与
True and False✅ 返回 FalseTrue & False 也返回 False

记忆法:and 问"整体",& 问"每个位置"。 数组要的是后者,所以必须用 &。

同理:or → |、not → ~。

python
soh[~(soh < 0.95)]       # 取反:不小于 0.95 的

19.4 二维索引 ​

python
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 的元素,拍平成一维
点开:一个不报错但结果全错的写法
python
print(X[:, 0])       # [1 5 9]    ✅ 第 0 列
print(X[:0])         # []         ❌ 空数组!

X[:0] 是什么意思:切片"从开始到下标 0(不含)" —— 当然是空的。

危险之处:它不报错。你会得到一个 shape=(0, 4) 的空数组, 然后后面的计算要么报错,要么静默产生空结果。

预防:养成 print(结果.shape) 的习惯。空数组一看 shape 就知道了。

19.5 ★ 视图 vs 拷贝:本章最重要的坑 ​

点开:这一个知识点值得单独一节,因为它是 NumPy 最难查的 bug 来源

问题演示 ​

python
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() ​

python
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 ​

python
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、转置、增维) "需要挑挑拣拣或重新排版"的操作 → 拷贝(花式/布尔索引、类型转换、展平)

为什么后者必须拷贝?因为花式索引和布尔索引挑出来的元素在原内存里不连续, 没法用"加个框"的方式表示,只能复制一份出来。

为什么要有视图(而不是全部都拷贝) ​

省内存、省时间。

python
huge = np.zeros(100_000_000)         # 8 亿字节 = 800 MB
part = huge[:1000]                    # 视图:0 额外内存
part_copy = huge[:1000].copy()        # 拷贝:8000 字节(这里还小)

如果每次切片都复制,那么对一个 10 GB 的数据集连续做几次切片, 内存直接爆掉。视图让"切一片"变成零成本操作。

这就是 NumPy 的设计哲学:默认不复制(快),需要复制时你明确说 .copy()(显式优于隐式)。

实践纪律 ​

① 需要独立副本时,主动写 .copy()。

python
# ❌ 危险:后面如果改了 subset,df 也会变
subset = data[data["soh"] < 0.85]

# ✅ 明确复制
subset = data[data["soh"] < 0.85].copy()

这条在 Pandas 里表现为 SettingWithCopyWarning(第 25 章会详细讲)—— 同一个问题的另一个表现形式。

② 函数里拿到数组参数时,别假设它是"属于你的"。

python
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.  ]  ← 被"顺手"改了

修复方式二选一:

python
arr = arr.ravel().copy()       # ① 明确复制
arr = arr / arr.max()          # ② 用产生新数组的写法(不原地改)

③ 调试时用 is 和 .base 快速判断。

python
print(result.base is original)     # True → 是视图,小心

19.6 修改数组内容的几种方式 ​

python
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                   # 整体赋值

"条件赋值"是数据清洗里最常用的:

python
soh[soh < 0.80] = 0.80     # 把所有低于阈值的截断到阈值(clip 的手写版)

注意原地修改 vs 产生新数组:

python
a = np.array([1.0, 2.0])
a = a * 2          # 产生新数组,a 指向新对象
a *= 2             # ✅ 原地修改(等价于 a = a * 2 但复用内存)

对单元素来说没区别,但对 10 GB 的数组,a *= 2 能省下一份内存。

19.7 完整示例与输出 ​

bash
cd code
python demos/ch19-array-index.py

真实输出(节选):

text
=== ② 一维切片(左闭右开) ===
  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 值,完成三件事:

  1. 取"第 3~8 个"(打印结果和长度);
  2. 取"第 1、5、10 个"(花式索引);
  3. 用布尔索引挑出 < 0.94 的值,打印个数和占比。
参考答案
python
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]

三个易错点:

  1. "第 3~8 个"要写 [2:8] —— 起点是 2(因为 0 基),终点是 8(因为右端取不到)。 用 len() 反向验证是最可靠的:结果长度应该等于 8 - 2 = 6。
  2. "第 1、5、10 个"的下标是 0、4、9 —— 都减 1。用 soh[[0,4,9]] 一次取出。
  3. .mean() 直接给出占比,不用手算除法。

多条件的写法(顺便练):

python
band = soh[(soh >= 0.93) & (soh < 0.97)]
print(band)                 # [0.964 0.956 0.944 0.936]

注意 & 两边都必须加括号。

完整练习:exercises/ex19-01-index-slice

练习 2:找出这三段代码里的"视图陷阱"

每段都有一个问题,说出问题是什么、会造成什么后果、怎么修。

python
# 段一
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

python
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 和 -= 都在原地改

python
x = x.reshape(-1)     # 视图(虽然这里 x 本来就是一维,reshape 没有实际影响)
x -= x.mean()         # 原地修改 → 改的是 vals 的内存
x /= x.std()          # 又一次原地修改

standardize(vals) 调用后,vals 本身被标准化了。 调用方通常不会预期"我的数组被函数改了",这就是所谓的副作用。

修复:

python
def standardize(x):
    x = x.reshape(-1).copy()      # ① 明确复制
    x = (x - x.mean()) / x.std()  # ② 用产生新数组的写法
    return x

段三:这个其实不是 bug —— 布尔索引返回拷贝

python
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。

这正是"哪些返回视图、哪些返回拷贝"这条规律的价值:

python
print(arr[arr < 5].base)       # None  → 拷贝
print(arr[1:3].base is arr)    # True  → 视图

如果不确定,记住这个最保险的做法:

只要你要修改这个结果,就加 .copy()。零成本的心智负担。

代价:多占一份内存。对超大数组,可以先用 .base 判断再决定(但相信我,99% 的情况下直接 .copy() 更划算 —— 因为排查这类 bug 的时间远比内存贵)。

练习 3:布尔数组的两个"魔法"
python
import numpy as np
soh = np.array([1.0, 0.95, 0.92, 0.88, 0.84, 0.80])

要求:用一行代码分别回答:

  1. 有多少个 SOH 低于 0.90?
  2. 低于 0.90 的占比是多少?
  3. 把低于 0.85 的值全部替换成 0.85(原地修改)。
参考答案
python
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 里同样适用:

python
df["soh"].lt(0.90).sum()      # 个数
df["soh"].lt(0.90).mean()     # 占比

③ 的"原地替换"是数据清洗里最常用的动作:

python
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 章讲异常处理时会用):

python
np.clip(soh, 0.85, 1.00)      # 一步完成上下界截断,返回新数组

面向 AI 开发:视图陷阱在 PyTorch 里一模一样 ​

这是本书最值得"迁移"的一章 —— 因为深度学习框架完整继承了 NumPy 的视图语义。

python
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() 的区别。

python
x = torch.zeros(4, 4)
y = x.view(16)          # 视图:要求内存连续,否则报错
z = x.reshape(16)       # 可能返回视图,也可能返回拷贝(自动处理)

.view() 严格要求内存连续(非连续时直接报错),.reshape() 会按需复制。 要独立副本就 .clone()(对应 NumPy 的 .copy())。

② 数据增强时的"原地操作"。

python
# ❌ 危险:原地修改会污染原始数据集
img = dataset[0]              # 拿到的是引用
img /= 255.0                  # 原地归一化 → 数据集里的那张图也被改了!

# ✅ 安全
img = dataset[0].clone()
img = img / 255.0             # 产生新张量

这个 bug 特别阴险:第一次跑没问题(数据集里那张图被"顺手"归一化了), 第二次跑就发现数据已经不对了 —— 因为 normalize 被执行了两次。

③ 优化器更新参数。

python
# PyTorch 的参数更新是"原地"的(为了省内存)
with torch.no_grad():
    param -= lr * param.grad      # 原地修改 param
    param.grad.zero_()            # 原地清零

这是有意为之:模型有几十亿参数,每步都复制一份会爆显存。 但这也意味着 param 是共享的 —— 你在别处保存的引用会看到更新后的值。

三个立刻能用的迁移习惯:

NumPyPyTorch说明
.copy().clone() / .detach()要独立副本
arr.basex._base检查是不是视图(内部属性,仅调试用)
arr[条件] = 值x.masked_fill_(mask, value)带下划线的是原地版本
无torch.no_grad()明确声明"我不要梯度",也提示"这里会改数据"

一条通用纪律:

任何带下划线结尾的方法(_)都是原地操作。add_ / mul_ / zero_ / clamp_ / masked_fill_ —— 看到下划线就想到"它会改原对象"。

这条纪律在 NumPy 里也部分适用(sort vs sort_ 在旧版里,+= vs +), 但 PyTorch 把它变成了系统性的命名约定。记住它,能省掉很多"改了一个地方结果另一个地方也变了"的困惑。


下一章:第 20 章 · 向量化运算:告别 for 循环