Skip to content

第 09 章 · 字典与集合:用字典描述一支电芯 ​

这一章要解决什么:把一支电芯的多个属性(编号、批次、容量、温度)装进一个变量,并按名字取出来。 三个必须打掉的坑:cell.id 报 AttributeError、in 查的是键不是值、取不存在的键会 KeyError。

本章你需要完成的代码:demos/ch09-dict.py

9.1 从列表到字典:按名字取值 ​

第 06 章的列表靠位置取值,而位置本身没有含义:

python
cell_list = ["C001", "A", 2.5, 2.30, 25]
print(cell_list[2])        # 2.5 —— 这是什么?得回去数一遍

cell = {"id": "C001", "batch": "A", "cap_rated": 2.5, "cap_now": 2.30, "temp": 25}
print(cell["cap_rated"])   # 2.5 —— 一眼就知道取的是什么
列表 list字典 dict
取值方式位置(下标)caps[0]名字(键) cell["id"]
适合放同一类东西的一串(6 个容量)一个对象的多个属性(5 个字段)
有顺序吗有3.7+ 保留插入顺序,但别依赖它

怎么选:6 次循环的容量 → 列表;一支电芯的编号/容量/温度 → 字典。

9.2 创建字典:键、值、键值对 ​

在 cell = {"id": "C001", ...} 里,"id" 是键(必须可哈希:str/int/float/元组行,list 不行), "C001" 是值(什么类型都行),"id": "C001" 整体叫键值对(中间是冒号,不是等号)。 取值用方括号 + 键:cell["id"] → 'C001';单双引号都行(cell['id'] 等价)。 花括号是字典、[] 是列表、() 是元组,别写混。

9.3 cell.id 是不行的:AttributeError ​

python
cell.id
# AttributeError: 'dict' object has no attribute 'id'

读报错:'dict' object has no attribute 'id' = "dict 对象没有名为 id 的属性"。

点开:点号和方括号到底差在哪?

点号只用于访问"属性 / 方法"(caps.append、"c001".upper()、cell.keys()), 而字典的键是"数据",不是"属性" —— "id" 只是存在字典里的一个字符串, Python 不会因为你写了 {"id": ...} 就给对象长出一个 id 属性。 所以 cell["id"] ✅(方括号里放数据)、cell.id ❌(点号后面只能放代码里写死的名字)。 看到 AttributeError: 'dict' object has no attribute ...,基本可以断定:我把字典当对象用了。

9.4 增、改、删:一个语法包办三件事 ​

python
cell["soh"] = round(cell["cap_now"] / cell["cap_rated"], 4)   # 键不存在 → 新增
cell["temp"] = 26              # 键已存在 → 改值
removed = cell.pop("temp")     # 删掉并返回被删的值(26)
想做的事写法注意
新增cell["soh"] = 0.92键不存在就是新增
修改cell["temp"] = 26键存在就是覆盖
删除并取值v = cell.pop("temp")键不存在会 KeyError
安全删除cell.pop("temp", None)不存在也不报错

同一个 cell["x"] = ...,是"新增"还是"修改"完全取决于键在不在 —— 这是它和列表最大的区别 (列表 caps[99] = 1 只会报 IndexError)。另外记住第 04 章的规律:pop 有返回值,del 没有。

9.5 KeyError 与 .get():读外部数据必须用这个 ​

python
cell["soh_now"]
# KeyError: 'soh_now'

报错会原样回显键名,一眼看出缺了哪个键。但很多时候"键不在"是正常的 (配置项可以省略、CSV 可能没这一列、接口字段可能缺失),这时用 .get():

python
cell.get("soh_now")              # None      —— 不存在返回 None,不报错
cell.get("soh_now", "未知")       # '未知'    —— 带默认值(推荐)
写法键不存在时什么时候用
cell["k"]抛 KeyError,程序停你确信这个键必须有(该崩就崩)
cell.get("k")返回 None不确定有没有
cell.get("k", 默认值)返回默认值最常用:读配置、读外部数据

为什么推荐带默认值:None 本身不是错,但它会一路传染,最终在第 20 行引发一个看不懂的 TypeError。第 03 章讲过 None 的语义是"这里没有值" —— 能用默认值挡住就挡住。

9.6 遍历字典的三种姿势 ​

python
for k in cell:                      # ① 默认遍历「键」
for v in cell.values():             # ② 只要值
for k, v in cell.items():           # ③ 键值一起(最常用)

真实输出(节选):

text
  ① 遍历键(默认):id / batch / cap_rated / cap_now / soh / temp
  ③ 键值一起:for k, v in cell.items()
     id         = C001
     cap_rated  = 2.5
     soh        = 0.92
写法拿到什么典型用途
for k in d键检查 / 收集字段名
for v in d.values()值求最大最小、算平均
for k, v in d.items()键 + 值打印、拼字符串、转换结构

最常踩的一行:写了 for k in cell: 却在循环体里用 v → NameError。

9.7 in 查的是键,不是值 ​

本章最容易"静默出错"的一条:

python
"soh" in cell          # True    —— soh 是一个「键」✅
2.30 in cell           # False   —— 2.30 是一个「值」!❌ 不报错,只是永远 False
2.30 in cell.values()  # True    —— 要这样查值 ✅

为什么这么设计:字典底层是哈希表,只为"键"建了索引 —— 用键查是 O(1), 查值必须遍历全部值,是 O(n),所以 in 默认查键。 为什么危险:2.30 in cell 返回 False 而不报错;它出现在 if 里,那个分支会悄悄永远不执行。 纪律:看到 x in 某个字典,先问"我要查键还是查值",查值就写 .values()。

9.8 用字典"查表",替代一长串 if/elif ​

第 08 章的写法要 9 行,加一个批次还得找地方插 elif:

python
if batch == "A":      line = "一线"
elif batch == "B":    line = "二线"
else:                 line = "未知产线"    # ← 每来一个新批次都要改这一段

改成字典查表:

python
LABELS = {"A": "一线", "B": "二线", "C": "三线", "D": "试制线"}

for b in ("A", "D", "X"):
    print(f"批次 {b} → {LABELS.get(b, '未知产线')}")
# 批次 A → 一线 / 批次 D → 试制线 / 批次 X → 未知产线
if/elif 链字典查表
10 个批次约 20 行1 行
加一个批次找位置插入加一个键值对
一眼看懂吗要读完全段数据本身就是文档
默认分支else.get(k, 默认值)

三个要点:① 能查表就别写分支(分支是逻辑,表是数据,数据更容易检查); ② .get(b, "未知产线") 就是那个 else,别漏;③ 真实数据大小写/空格不统一, 进表前先 b.strip().upper()。 反例:如果每个分支做的事不一样(一个写文件、一个报警),还是得用 if/elif —— 字典只能表达"一个输入 → 一个输出"。

9.9 嵌套字典:真实数据长什么样(第 12 章伏笔) ​

python
fleet = {
    "C001": {"batch": "A", "soh": 0.95, "r_int": 45.2},
    "C002": {"batch": "B", "soh": 0.88, "r_int": 52.8},
}

print(fleet["C001"]["soh"])       # 0.95 —— 一层一层剥
for cid, info in fleet.items():   # 外层:编号 -> 属性字典
    print(f"{cid} 批次{info['batch']} SOH={info['soh']}")
# C001 批次A SOH=0.95 / C002 批次B SOH=0.88

为什么说这是第 12 章的伏笔:CSV 读进来时,每一行就是一个字典 —— 整个文件是 list(13,967 行),一行是 dict(9 个字段,表头就是键),一个字段是 str/int/float。 第 12 章 csv.DictReader 读出来的 rows[0] 长这样:

python
{'cell_id': 'C001', 'batch': 'A', 'cycle': '1', 'soh': '0.9999', ...}

本章的四种技能,第 12 章全部会用到:row["soh"](按列名取值)、 row.get("batch", "未知")(某列可能缺失)、for k, v in row.items()(遍历字段)、 {r["cell_id"] for r in rows}(统计有多少支不同电芯)。

9.10 字典推导式:一行建表 ​

python
soh_map = {cid: info["soh"] for cid, info in fleet.items()}
# {'C001': 0.95, 'C002': 0.88}

healthy = {cid: s for cid, s in soh_map.items() if s >= 0.90}
# {'C001': 0.95}

骨架:{ 键表达式 : 值表达式 for 元素 in 可迭代对象 if 条件 }。 最常见的用途是把"列表"变成"按编号索引的表",之后 O(1) 查某一支电芯: soh_of = {cid: info["soh"] for cid, info in fleet.items()},然后 soh_of.get("C002") 得到 0.88。

推导式的完整规则(还有集合推导式、生成器表达式)在第 11 章统一讲。

9.11 集合 set:自动去重 + 集合运算 ​

set 是"只有键、没有值"的字典 —— 这也是它为什么快。

python
ids = ["C001", "C002", "C001", "C003", "C002"]
uniq = set(ids)
print(uniq, len(uniq))        # {'C002', 'C001', 'C003'} 3

对比手写循环 for i in ids: if i not in ids_loop: ... —— 列表的 in 是 O(n), 整个循环 O(n²);set 只要 O(n)。

listset
允许重复是否,自动去重
有顺序有无(打印顺序不保证)
s[0] 下标能不能 —— 无顺序,没有"第 0 个"
x in sO(n)O(1)
用途存一串有顺序的数据去重、成员判断、集合运算

集合运算(把两支电芯的循环编号当成两个集合):

python
a, b = {1, 2, 3, 4}, {3, 4, 5}

a & b      # {3, 4}           交集:两边都有
a | b      # {1, 2, 3, 4, 5}  并集:合起来
a - b      # {1, 2}           差集:a 有、b 没有
a ^ b      # {1, 2, 5}        对称差:只在一侧出现
运算真实用途
len(set(ids))有多少支不同的电芯(第 12 章主线问题)
set(a) & set(b)两批测试都测了的电芯(可横向对比)
set(a) - set(b)这次漏测了哪些

三个注意点:① 打印顺序不固定(set 靠哈希排列,字符串哈希每次运行还会加盐), 写报告时用 sorted(s) 固定顺序;② 能放进 set 的条件和字典的键一样,必须可哈希 (元组 ✅、列表 ❌ TypeError: unhashable type: 'list');③ 空集合只能写 set(),{} 是空字典。

9.12 完整示例与真实输出 ​

bash
cd code
python demos/ch09-dict.py

真实输出(节选):

text
=== ① 创建与访问 ===
cell = {'id': 'C001', 'batch': 'A', 'cap_rated': 2.5, 'cap_now': 2.3, 'temp': 25}

=== ② cell.id 是不行的 ===
  AttributeError: 'dict' object has no attribute 'id'

=== ④ KeyError 与 .get() ===
  KeyError: 'soh_now'    .get 安全取: None    .get 带默认值: 未知  ← 推荐

=== ⑥ in 查的是键,不是值 ===
  'soh' in cell : True      2.30 in cell : False  ← False,虽然它是个值
  2.30 in cell.values() : True  ← 要这样查值

=== ⑩ 集合 set ===
  原始列表: ['C001', 'C002', 'C001', 'C003', 'C002']
  set 去重后: {'C002', 'C001', 'C003'}  不同电芯数: 3
  set 查找是 O(1),比 list 的 in 快得多 —— 第 12 章统计不同电芯就用它。

注意 ⑩ 的 set 去重后:你机器上的顺序可能不同,这是正常的(原因见 9.11)。

常见坑 ​

报错 / 现象原因怎么改
AttributeError: 'dict' object has no attribute 'id'用点号取字典的键cell["id"] 或 cell.get("id")
KeyError: 'soh_now'键不存在,直接 [] 取值cell.get("soh_now", "未知")
2.30 in cell 得到 Falsein 查的是键2.30 in cell.values()
TypeError: unhashable type: 'list'用列表当键换成元组或字符串
NameError: name 'v' is not definedfor k in d: 里访问了 vfor k, v in d.items():
集合打印顺序每次都变set 无序,字符串哈希带随机盐打印前 sorted(s)
{} 建出来是空字典不是空集合{} 默认含义是 dict空集合写 set()

一条总纪律:只要数据是"从外面来的"(配置、JSON、CSV、接口),就用 .get(k, 默认值)。 自己刚定义、确信存在的键,用 d[k] 更直接 —— 该崩的时候崩,比把 None 传下去好。

本章练习 ​

练习 1:给电芯字典补上 status 字段

给定 cell = {"id": "C001", "cap_rated": 2.5, "cap_now": 2.30, "temp": 25},依次完成: ① 算 SOH(保留 4 位)并写回字典;② 新增 status:SOH ≥ 0.80 → "健康",否则 "待退役"; ③ 打印 C001 SOH=0.92 状态=健康(SOH 保留 2 位);④ 用 .get() 读 "batch",取不到返回 "未知"; ⑤ 打印字典里所有的键。

期望输出:

text
C001 SOH=0.92 状态=健康
批次:未知(用 .get 安全读取,不报错)
字典里的键:['id', 'cap_rated', 'cap_now', 'temp', 'soh', 'status']
参考答案
python
cell = {"id": "C001", "cap_rated": 2.5, "cap_now": 2.30, "temp": 25}

soh = cell["cap_now"] / cell["cap_rated"]      # ① 算 SOH
cell["soh"] = round(soh, 4)                    #    写回字典(键不存在 → 新增)

cell["status"] = "健康" if soh >= 0.80 else "待退役"    # ② 新增 status
print(f"{cell['id']} SOH={cell['soh']:.2f} 状态={cell['status']}")   # ③ 存 4 位、显示 2 位

print(f"批次:{cell.get('batch', '未知')}(用 .get 安全读取,不报错)")   # ④
try:
    cell["batch"]                              #    直接取会报 KeyError,这里演示一下
except KeyError as exc:
    print(f"  (直接取会报 KeyError:{exc})")

print(f"字典里的键:{list(cell.keys())}")        # ⑤

三个要点:① cell["soh"] = ... 是"新增" —— 字典不需要提前声明字段,键不存在赋值就是加进去; ② 存 4 位、显示 2 位:字典里存 round(soh, 4),打印时用 :.2f —— 这是第 04 章"中间保持精度,只在输出时格式化"的纪律,别为了好看去改存进去的值; ③ .get('batch', '未知') 和 try/except KeyError 都对,但场景不同:"缺了无所谓"用 .get, "缺了要记录、要报警"用 try/except(第 13 章细讲)。

完整练习在 exercises/ex04-09-dict —— 含骨架 exercise.py 和参考答案 solution.py。

练习 2:用字典查表替代一长串 if/elif

把下面这段改成"字典 + .get()",要求新增批次时只改数据、不改逻辑:

python
for b in ("A", "B", "C", "X"):
    if b == "A":      line = "一线"
    elif b == "B":    line = "二线"
    elif b == "C":    line = "三线"
    else:             line = "未知产线"
    print(f"批次 {b} → {line}")

期望输出:批次 A → 一线、批次 B → 二线、批次 C → 三线、批次 X → 未知产线。

参考答案
python
# 把「逻辑」变成「数据」:这张表本身就是文档
LINE_OF = {"A": "一线", "B": "二线", "C": "三线", "D": "试制线"}

for b in ("A", "B", "C", "X"):
    print(f"批次 {b} → {LINE_OF.get(b, '未知产线')}")

三个细节:① .get(b, "未知产线") 就是原来的 else —— 漏了它,未知批次会得到 None, 打印出 批次 X → None,比报错还难发现;② 表名全大写是惯例(LINE_OF、LABELS 都表示"这是常量"); ③ 数据不干净时先清洗:b.strip().upper(),真实数据里 "a"、" A"、"A " 都会出现。 什么时候不该用:每个分支做的事不一样(一个写文件、一个报警)时,老实写 if/elif。

练习 3:用 set 统计"多少支不同电芯"

给定 line_a = ["C001", "C002", "C001", "C003", "C002"]、line_b = ["C003", "C004", "C001", "C004"], 完成:① 两条线各测了多少次、各覆盖多少支不同电芯;② 求交集、并集、差集(只有 A 线测过的); ③ 用一句话回答"一共覆盖了多少支不同电芯"。

参考答案
python
line_a = ["C001", "C002", "C001", "C003", "C002"]
line_b = ["C003", "C004", "C001", "C004"]
a, b = set(line_a), set(line_b)

print(f"A 线:{len(line_a)} 条记录,覆盖 {len(a)} 支")          # 次数 vs 支数!
print(f"都测过: {sorted(a & b)}")            # ['C001', 'C003']
print(f"全部覆盖: {sorted(a | b)}")          # ['C001', 'C002', 'C003', 'C004']
print(f"只有 A 测过: {sorted(a - b)}")       # ['C002']
print(f"共覆盖 {len(a | b)} 支不同电芯")      # 4

三个要点:① len(line_a) 和 len(set(line_a)) 是两个不同的量 —— 一个是"测了多少次",一个是"覆盖了多少支",报告里混用是最常见的口径错误; ② sorted(...) 不能省,set 顺序每次运行可能不同,不排序会出现"我们数字不一样"的假问题; ③ 差集方向很重要:a - b 和 b - a 写反,结论就反了。 (set(...) 是 O(n),上面那个列表循环版是 O(n²) —— 这里 5 条看不出差别,第 12 章 13,967 行时差距明显。)

面向 AI 开发:一条样本就是一个 dict ​

AI 项目里几乎所有"配置"和"样本"都是字典:模型超参数 {"lr": 1e-3, "batch_size": 32}、 json.load(f) 读回来的配置、数据集里的样本 {"text": "...", "label": 1}、 HuggingFace 的 ds[0]({'input_ids': [...], 'label': 0})、词表 {"<pad>": 0, "<unk>": 1}、 评测结果 {"accuracy": 0.93, "f1": 0.91}。

① 配置一定要用 .get(k, 默认值) 读。

python
lr = config["learning_rate"]              # ❌ 少一个字段,训练脚本启动就崩
lr = config.get("learning_rate", 1e-3)    # ✅ 缺字段也能跑(HuggingFace Trainer 就这么做)

一个训练配置有几十个字段,绝大多数可选 —— 默认值让"最简可运行"成为可能, 这是所有训练框架的设计共识。

② 一条样本 = 一个 dict,一个数据集 = 一个 dict 列表。

python
dataset = [{"cell_id": "C001", "soh": 0.95}, {"cell_id": "C002", "soh": 0.88}]
X = [s["soh"] for s in dataset]              # 抽一列 → 列表
by_id = {s["cell_id"]: s for s in dataset}   # 按 ID 建索引 → O(1) 查

这三行就是"手工版的 DataFrame":第 23 章 Pandas 把它们变成 df["soh"]、 df.set_index("cell_id") —— 理解这几行,是理解 DataFrame 在干什么的前提。

③ 字典查表就是"规则引擎"的最小实现:EOL_BY_CHEM = {"LFP": 0.80, "NMC": 0.75}, 判定时写 EOL_BY_CHEM.get(sample.get("chemistry", "LFP"), 0.80)。 换材料体系时只改这张表,判定逻辑一行不动 —— "策略可配置"的第一步,就是把 if 变成表。

④ set 的三个高频用途:len(set(labels)) 数类别(决定输出层维度)、set(tokens) 建词表、 set(train_ids) & set(val_ids) 查训练集与验证集是否重叠。 最后一条尤其重要:重叠就是数据泄漏,指标会虚高。

一句话总结:这一章学的不是"一种数据类型",而是**"用名字组织数据"的思维方式**。 从配置文件到数据集到模型输出,AI 项目的每一层都在用这个名字系统 —— 而 JSON 就是它跨语言的通用写法。


下一章:第 10 章 · 函数:把「算 SOH」封装起来