第 09 章 · 字典与集合:用字典描述一支电芯
这一章要解决什么:把一支电芯的多个属性(编号、批次、容量、温度)装进一个变量,并按名字取出来。 三个必须打掉的坑:
cell.id报 AttributeError、in查的是键不是值、取不存在的键会 KeyError。
本章你需要完成的代码:demos/ch09-dict.py
9.1 从列表到字典:按名字取值
第 06 章的列表靠位置取值,而位置本身没有含义:
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
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 增、改、删:一个语法包办三件事
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():读外部数据必须用这个
cell["soh_now"]
# KeyError: 'soh_now'报错会原样回显键名,一眼看出缺了哪个键。但很多时候"键不在"是正常的 (配置项可以省略、CSV 可能没这一列、接口字段可能缺失),这时用 .get():
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 遍历字典的三种姿势
for k in cell: # ① 默认遍历「键」
for v in cell.values(): # ② 只要值
for k, v in cell.items(): # ③ 键值一起(最常用)真实输出(节选):
① 遍历键(默认):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 查的是键,不是值
本章最容易"静默出错"的一条:
"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:
if batch == "A": line = "一线"
elif batch == "B": line = "二线"
else: line = "未知产线" # ← 每来一个新批次都要改这一段改成字典查表:
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 章伏笔)
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] 长这样:
{'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 字典推导式:一行建表
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 是"只有键、没有值"的字典 —— 这也是它为什么快。
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)。
list | set | |
|---|---|---|
| 允许重复 | 是 | 否,自动去重 |
| 有顺序 | 有 | 无(打印顺序不保证) |
s[0] 下标 | 能 | 不能 —— 无顺序,没有"第 0 个" |
x in s | O(n) | O(1) |
| 用途 | 存一串有顺序的数据 | 去重、成员判断、集合运算 |
集合运算(把两支电芯的循环编号当成两个集合):
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 完整示例与真实输出
cd code
python demos/ch09-dict.py真实输出(节选):
=== ① 创建与访问 ===
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 得到 False | in 查的是键 | 2.30 in cell.values() |
TypeError: unhashable type: 'list' | 用列表当键 | 换成元组或字符串 |
NameError: name 'v' is not defined | for k in d: 里访问了 v | for 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",取不到返回 "未知"; ⑤ 打印字典里所有的键。
期望输出:
C001 SOH=0.92 状态=健康
批次:未知(用 .get 安全读取,不报错)
字典里的键:['id', 'cap_rated', 'cap_now', 'temp', 'soh', 'status']参考答案
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()",要求新增批次时只改数据、不改逻辑:
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 → 未知产线。
参考答案
# 把「逻辑」变成「数据」:这张表本身就是文档
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 线测过的); ③ 用一句话回答"一共覆盖了多少支不同电芯"。
参考答案
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, 默认值) 读。
lr = config["learning_rate"] # ❌ 少一个字段,训练脚本启动就崩
lr = config.get("learning_rate", 1e-3) # ✅ 缺字段也能跑(HuggingFace Trainer 就这么做)一个训练配置有几十个字段,绝大多数可选 —— 默认值让"最简可运行"成为可能, 这是所有训练框架的设计共识。
② 一条样本 = 一个 dict,一个数据集 = 一个 dict 列表。
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 就是它跨语言的通用写法。