# Python 零基础实操教程

> Leon · Python — Zero to Practice

部分内容为info1110课程

版本 1.4 · 2026-10-21 · Windows / Mac

# 00 从这里开始

先把第一段程序运行起来，再逐步理解它。

## 学习目标

- 区分浏览器、编辑器、终端和 Python 解释器
- 在 Windows 与 Mac 上运行同一个文件
- 掌握教材、练习、答案和进度的使用方法

### 这份教材怎样帮你学会实操
这是给没有编程经验的学习者准备的第一阶段教材。你无需懂数学公式、命令行或英文术语。目标是让你独立完成“接收数据 → 判断与处理 → 保存结果”的小程序，并看懂后续机器学习代码中的常见 Python 写法。它不承诺学完即可训练大模型；模型原理、线性代数、NumPy、PyTorch 是后续阶段。

**先下载并完整解压学习包，再双击 index.html。** 不要在压缩包的预览窗口里运行脚本。网页、代码和示例数据均可离线使用；第一次安装 Python 需要联网，或提前从官方网站准备适合目标电脑的安装文件。网页本身不需要安装任何程序，也不依赖在线字体、CDN 或账号。运行 Python 代码则需要该电脑已安装 Python。

正文中的代码都是 Python；标注为“终端命令”的内容则写在终端里。网页的“复制”只复制代码，不会自动执行。为保证完整离线、避免额外大型运行环境，本教材**不在网页中执行任意 Python**。三个演示器只展示预设语义轨迹，真实练习请在自己的解释器里运行。

### 文件夹地图
- `index.html`：教材入口，双击打开。
- `examples/ch02/ex02_01.py`：第 02 章第 1 个示例，可以直接运行。
- `practice/ch02/ex02_01.py`：第 02 章第 1 道习题，留给你编写代码。
- `solutions/ch02/ex02_01.py`：该题参考答案；先尝试，再查看。
- `projects/`：四个综合项目，每个有分步任务、示例数据和完整实现。
- `tools/check_practice.py`：练习自检器；`tools/verify_examples.py`：全部示例核对器。
- `notes/`：错题本和复习记录模板；`textbook.md`：纯文字备份。

**练习请编辑解压包原位置的 practice 文件。** 网页文件链接会下载副本；若你修改的是“下载”目录中的副本，自检器仍会读取学习包里的原文件。最稳妥的方法是在编辑器里用 File → Open 打开题目注明的 practice 路径，修改并保存。

示例和习题分别编号，所以同一个 `02_01` 在 examples 和 practice 中不是同一段程序。编号的前两位是章节号，后两位是本章顺序。

### 四个工具分别做什么
**编辑器**负责修改文本，例如 Python 自带的 IDLE；**解释器**负责执行 Python 代码；**终端**负责接收启动程序的命令；**浏览器**负责显示教材。可以把编辑器想成写菜谱的纸，解释器想成按菜谱做菜的人，终端想成发出“开始做菜”的入口。类比只帮助入门；代码的准确含义以解释器规则为准。

`hello.py` 是文本文件，`.py` 告诉你这是 Python 源代码。`print("你好")` 的括号是调用，双引号包住字符串，`print` 把内容显示到输出区域。注意使用英文半角括号和引号，不能用中文的 `（ ） “ ”` 替换。

### Windows 安装与首次运行
1. 打开 [Python 官方下载页](https://www.python.org/downloads/)，按照 [Windows 官方安装说明](https://docs.python.org/3/using/windows.html) 安装 Python。官方目前提供 Python install manager；已有正常工作的 Python 3 不必为了本教材重装。安装流程随版本变化，以官方页面为准。
2. 安装完成后，关闭旧终端，再从开始菜单打开 PowerShell 或 Windows Terminal，输入 `py --version`。若 `py` 不可用但 `python --version` 成功，后续把 `py` 换成 `python`。
3. 确认输出是 Python 3.10 或更高。本教材使用的语法按 Python 3.10+ 编写，交付前在 3.12 环境实际验证；不是对所有操作系统组合都做过现场测试。
4. 将整个文件夹放在一个容易找到的位置，例如桌面。打开该文件夹，在文件资源管理器地址栏输入 `powershell` 并回车，通常即可在此目录启动 PowerShell。也可以手动输入 `cd "完整文件夹路径"`。
5. 输入下面的命令，看到文字即成功。命令前面没有要你复制的 `$` 或 `>>>`。

```terminal
py examples/ch01/ex01_01.py
```

### Mac 安装与首次运行
1. 从 [Python 官方下载页](https://www.python.org/downloads/) 获取 macOS 安装包，参考 [macOS 官方说明](https://docs.python.org/3/using/mac.html) 安装。学习用解释器不要依赖系统工具内部使用的 Python。
2. 用 Spotlight 搜索并打开“终端”，输入 `python3 --version`，确认 Python 3.10+。
3. 在终端输入 `cd `（注意末尾有空格），把解压后的学习包文件夹拖入终端，再按回车。拖入操作会帮助你填好带空格的路径。
4. 输入下面的命令：

```terminal
python3 examples/ch01/ex01_01.py
```

之后所有标为 `python ...` 的通用命令，在尚未使用虚拟环境时，Windows 可改成 `py ...`，Mac 可改成 `python3 ...`。第 11 章使用虚拟环境时会给出精确路径。

### 完全不会编辑文件时怎么做
如果安装中包含 IDLE，在开始菜单或应用程序中打开它。选择 File → Open，打开任意 `.py` 文件；修改后按 Ctrl+S（Mac 用 Command+S）保存，再选择 Run → Run Module（常见快捷键 F5）。结果会出现在 IDLE Shell 中。练习也可以用任意纯文本代码编辑器完成。

不要用 Word 编辑 `.py`，也不要保存成 `hello.py.txt`。Windows 可以在文件资源管理器中开启“显示文件扩展名”；Mac 文本编辑器需要先转换为纯文本。使用 IDLE 通常更省事。

遇到 `>>>` 说明进入了 Python 交互模式，可以输入 Python 表达式。此时不能直接输入 `py hello.py`。输入 `exit()` 回到系统终端，再运行终端命令。`...` 是交互模式的续行提示，不是要写进程序里的字符。

### 一次学习的具体流程
每次安排 45—75 分钟，可按自己的时间拆开：5 分钟默写上一节；15 分钟读新知识；20 分钟手敲并改动示例；15 分钟独立做题；5 分钟记录错误。**看懂答案不等于能够独立写出。** 每个例子先写下预计输出，再运行，然后至少改两个输入。

卡住时按顺序做：复述题目 → 写出输入与输出 → 手算一个小例子 → 用中文列步骤 → 把步骤翻译成代码 → 用 print 看中间值。独立尝试 10—15 分钟后可以展开提示；再卡住才看解析。看过答案的题，第二天关掉答案重写。

### 换电脑继续学习
复制整个解压后的文件夹，或者复制压缩包后重新解压。已写好的 practice、projects 和 notes 也要一起带走。网页进度保存在当前浏览器本地，它不会自动同步，也可能因移动文件或浏览器清理而丢失：离开前点击“导出进度”，在另一台电脑点击“导入进度”。导出的 JSON 包含章节勾选和笔记，不包含你的 `.py` 源代码。浏览器禁止本地存储时仍可阅读，应手动导出进度。

### 第一周不要做的额外工作
不需要显卡，不需要注册模型 API，不需要安装 PyTorch，也不需要一次配置多个编辑器。第 00—16 章和四个项目只使用标准库。先把这条最短路径走通：保存一个文件 → 运行 → 修改 → 再运行。

### AI / Agent 穿插内容怎样学
主页的“部分内容为info1110课程”用于标记你的课程关联。基础语法可以配合课堂复习；v1.1 新增的 AI / Agent 场景是自编延伸练习，不据此推定课程的考核范围。

保持原来的顺序：先学本章语法，完成普通示例，再做本章最后的“AI / Agent”示例与练习。遇到暂时不懂的 AI 名词，先关注代码中的字符串、列表、判断和函数；不需要先学神经网络。

本轮的小实验只使用 Python 标准库，完全离线。提示词是给模型的文字要求；大模型（LLM）根据上下文生成内容；工具（tool）是程序可以调用的函数；Agent 则围绕目标组织决策、工具执行和结果反馈。这里用固定数据和规则模拟部分流程，**没有调用真实模型、没有网络推理、没有产生 API 费用**。

| 章节 | 穿插的场景 | 这次真正练的 Python |
| --- | --- | --- |
| 03 / 04 | 组织提示词、选择处理路径 | 字符串、f-string、条件顺序 |
| 06 / 08 | 限制执行步数、保存对话 | while、break、列表套字典 |
| 09 / 12 | 工具参数与结果、保存会话 | 函数返回值、JSON 文件 |
| 13 / 14 | 检查结构化输出、独立会话 | 异常、输入校验、实例属性 |
| 15 / 16 | 模拟流式片段、检索工具循环 | yield、状态更新、可追踪的执行过程 |

每个场景示例都有 `.py` 文件，新增练习仍可用同一自检器检查。旧题号和进度文件格式保留；在本版导入旧进度后，新题会显示为未完成。

## 记忆要点
- 终端运行命令，解释器运行 Python。
- 保存文件后再运行；先预测，再验证。
- 跨电脑要带上源码；网页进度单独导出。

## 通关检查
- 我能找到学习包的位置，并从终端进入该目录。
- 我能运行第 01 章示例，并把输出改成自己的名字。
- 我知道报错时先读最后一行，而不是马上重装。


# 01 程序与基本语法

把想做的事情拆成计算机能执行的小步骤。

## 学习目标

- 理解执行顺序、表达式和语句
- 会用 print、注释和缩进
- 能识别最常见的输入错误

### 程序就是一系列明确指令
“帮我统计分数”对人足够，对计算机不够。你要写清楚：分数有哪些、怎样相加、人数怎么算、最后显示什么。通常先确定**输入、处理、输出**。比如输入两科成绩 80 和 90，处理是相加后除以 2，输出是 85.0。

Python 普通脚本从上到下执行；遇到函数定义会先记住函数，遇到调用才执行函数体（第 09 章）；遇到条件和循环会改变执行路径。先把现在的例子当作一行接一行执行。

### 表达式、语句和函数调用
`2 + 3` 是能得到一个值的表达式。在文件里单独写 `2 + 3` 会计算，但通常不会显示结果。`print(2 + 3)` 才会显示。`print` 是函数名，括号里的东西是传给它的参数。多个参数用英文逗号分开，默认用空格连接。

`print("2 + 3")` 输出文字 `2 + 3`；`print(2 + 3)` 输出数字 `5`。引号告诉 Python“里面是文本”，没有引号时则按代码解释。单引号与双引号都可以，开头和结尾必须匹配。

### 空行、注释、缩进
`#` 后直到这一行结束是注释，解释器不把它当作指令执行。注释最好解释“为什么”，例如“先换成分钟，方便比较”，而不是只重复代码。字符串中的 `#` 则是普通文字。

空行帮助人阅读。缩进在 Python 中则参与语法：同一代码块要用一致缩进，建议每层 4 个空格。顶层语句一般从最左边开始。不要混合 Tab 与空格。后面看到 `if`、`for`、`def` 后的冒号，下一层内容通常需要缩进。

### 先读懂一条报错
故意在练习文件里写 `print("你好"`，运行会看到 SyntaxError，意思是结构不符合语法。补上右括号后再运行。报错指向的位置有时是解释器发现问题的位置，真正漏掉的符号可能在上一行。

NameError 常表示名字没定义或拼错，如写成 `pirnt`。`Print` 与 `print` 不同，Python 区分大小写。每次只改一个问题，然后重新运行；不要一次凭感觉改很多行。

### 用中文先写算法
以“给两人平均分一笔钱”为例：得到总金额 → 得到人数 → 总金额除以人数 → 显示结果。暂时用固定数字，等第 02 章再接收键盘输入。写程序不等于背语法；先有步骤，再选择语法。

## 示例 01_01 第一段程序

路径：examples/ch01/ex01_01.py

```python
print("你好，Python！")
print("今天开始学编程。")
```

输出：
```text
你好，Python！
今天开始学编程。
```

解析：第 1 行先输出问候，第 2 行再输出计划。每次 print 默认在末尾换行。程序不会自动猜测你想改变什么。

改写：把两句话改成自己的学习目标。

## 示例 01_02 文本与计算

路径：examples/ch01/ex01_02.py

```python
print("2 + 3")
print(2 + 3)
print("结果是", 2 + 3)
```

输出：
```text
2 + 3
5
结果是 5
```

解析：第一行的加号是文本字符。第二行先计算表达式得到 5，再交给 print。第三行传入两个参数，用默认空格连接。

改写：预测 print("2" + "3") 会输出什么，再运行。

## 示例 01_03 控制输出分隔

路径：examples/ch01/ex01_03.py

```python
print("年", "月", "日", sep="/")
print("正在学习", end="：")
print("Python")
```

输出：
```text
年/月/日
正在学习：Python
```

解析：sep 指多个参数之间的分隔符；end 指本次输出结束时追加什么。第二行用中文冒号替代默认换行，所以第三行接在同一行。

改写：把 sep 改成空字符串，把 end 改成换行符。

## 示例 01_04 注释与执行顺序

路径：examples/ch01/ex01_04.py

```python
# 先计算总分，再计算平均分
print(80 + 90)
print((80 + 90) / 2)
print("# 这部分仍会显示")
```

输出：
```text
170
85.0
# 这部分仍会显示
```

解析：括号让加法先执行。除法 / 的结果是浮点数，所以显示 85.0。第一行注释不执行，最后一行引号内的 # 不是注释。

改写：删掉平均分表达式内的小括号，观察结果并解释。

## 练习 01_01 学习名片（基础）

依次输出两行：我叫小林；我每天学习 45 分钟。标点必须与示例一致。

作答文件：practice/ch01/ex01_01.py

提示：每行用一个 print，中文文字放在引号里。

参考答案：
```python
print("我叫小林")
print("我每天学习 45 分钟")
```

输出：
```text
我叫小林
我每天学习 45 分钟
```

解析：把自然语言中的两句话对应到两次输出。print 自动换行，无需自己写换行符。

## 练习 01_02 先算后显示（基础）

输出 12 与 8 的和、差、积，每个结果占一行。

作答文件：practice/ch01/ex01_02.py

提示：引号中的 12 + 8 不会计算。

参考答案：
```python
print(12 + 8)
print(12 - 8)
print(12 * 8)
```

输出：
```text
20
4
96
```

解析：加减乘分别使用 +、-、*。数学里的乘号 × 不能直接写进 Python 表达式。

## 练习 01_03 修复姓名输出（纠错）

原程序是 Print(小林)。修好后输出 小林。解释两个错误。

作答文件：practice/ch01/ex01_03.py

提示：一个错误是函数名大小写，另一个是文字的表示方法。

参考答案：
```python
print("小林")
```

输出：
```text
小林
```

解析：Print 不是内置 print；小林 不加引号会被当作变量名查找。中文变量名技术上可以使用，但教材使用英文变量名以便阅读常见代码。

## 练习 01_04 收据一行（场景）

用一次 print 输出 苹果|3|12，不要在竖线两侧加入空格。

作答文件：practice/ch01/ex01_04.py

提示：使用 sep 参数。

参考答案：
```python
print("苹果", 3, 12, sep="|")
```

输出：
```text
苹果|3|12
```

解析：三个参数分别代表品名、数量、金额。sep="|" 替换默认的空格。

## 练习 01_05 平均分与优先级（推理）

两次测验是 70 和 90，输出平均分 80.0。说明为什么 70 + 90 / 2 不对。

作答文件：practice/ch01/ex01_05.py

提示：先相加，后除以 2。

参考答案：
```python
print((70 + 90) / 2)
```

输出：
```text
80.0
```

解析：乘除优先于加减；不加括号得到 70 + 45 = 115。括号明确把总分作为除法的左边。

## 记忆要点
- 引号内是文字，引号外可能是表达式。
- print 才负责显示；表达式本身负责求值。
- 冒号后常有代码块，同一层使用 4 个空格缩进。

## 通关检查
- 我能解释 print("3") 与 print(3) 的相似和不同。
- 我能从零写出三行介绍自己的程序。
- 我能修正一处拼写、一处括号和一处引号错误。


# 02 变量 类型 运算与输入

先知道手里的数据是什么，再决定怎样处理。

## 学习目标

- 会给值命名并更新变量
- 区分 int、float、str、bool 与 None
- 会把键盘输入转换成需要的类型

### 变量是绑定到对象的名字
`age = 20` 不是数学等式，而是把右边的值交给左边的名字。读成“让 age 指向整数 20”。执行 `age = age + 1` 时，先读旧值 20，算出 21，再把 age 绑定到 21。初学可把它想成贴标签；第 07 章会说明为什么两个标签可能指向同一个列表。

名字用字母、数字和下划线组合，不能以数字开头；推荐 `study_minutes` 这种小写下划线风格。`class`、`if` 等关键字不能当变量名。不要把变量起名为 `list`、`str`、`sum`，否则会遮住同名内置工具。

### 类型决定可以做哪些操作
| 类型 | 示例 | 含义与用途 |
|---|---|---|
| int | 3、-8、0 | 整数，人数、次数、索引 |
| float | 3.5、0.01 | 浮点数，近似表示小数 |
| str | "3"、"你好" | 文本，即使看起来像数字 |
| bool | True、False | 真或假，注意首字母大写 |
| NoneType | None | 暂无结果或缺失值的一个标记 |

`type(x)` 查看类型。`3 + 2` 是数值相加；`"3" + "2"` 是拼接；`"3" + 2` 会产生 TypeError。Python 不会替你猜应该拼接还是相加。

### 算术常用符号
`+ - * /` 分别是加减乘除，`//` 是向下取整的除法，`%` 是余数，`**` 是幂。正数场景下 `125 // 60` 得到 2，`125 % 60` 得到 5，常用来把分钟换成小时和分钟。负数时要小心：`-7 // 3` 得到 -3，因为向负无穷取整；`-7 % 3` 得到 2，满足 `a == (a // b) * b + a % b`。

通常先括号，再乘方，再乘除余，再加减；拿不准时用括号表达意图。`-2 ** 2` 是 -4，`(-2) ** 2` 才是 4。`total += 5` 相当于给当前 total 加 5 后赋回；total 必须先有值。

### input 永远先给你字符串
`input("年龄：")` 会显示提示并等待输入，回车后返回字符串，不包含回车本身。输入 18，得到的是 `"18"`。要做整数运算，使用 `int(...)`；小数用 `float(...)`。`int("18.5")` 会报错，而 `int(18.5)` 得到 18，是向零截断，不是四舍五入。

`str(18)` 把数字变成文本。`bool("")` 是 False，而 `bool("False")` 是 True，因为它是非空字符串。不要用 bool 把用户输入的“否”自动转换成 False，要明确比较文本。

### 小数不是总能精确存储
二进制浮点数无法精确表示部分十进制小数。`0.1 + 0.2` 可能显示 `0.30000000000000004`。显示两位小数可以用格式化，比较近似数在第 13 章用 `math.isclose`。学习账本时把金额保存为整数“分”，例如 12.50 元表示为 1250 分。格式化改变显示，不会让底层数值变得精确。

### 把需求翻译成变量
“买 3 本书，每本 25 元，优惠 10 元”可以拆为 price、quantity、discount，再写 `total = price * quantity - discount`。变量名说明数据角色，减少神秘数字。先做固定输入，再加 input，是更容易排错的顺序。

## 示例 02_01 变量更新

路径：examples/ch02/ex02_01.py

```python
minutes = 30
minutes = minutes + 15
minutes += 5
print(minutes)
```

输出：
```text
50
```

解析：先保存 30，第二行用旧值算出 45，第三行再增加 5。变量名不变，绑定的值变了。

改写：增加一行 minutes -= 10，预测新结果。

## 示例 02_02 检查类型

路径：examples/ch02/ex02_02.py

```python
print(type(3).__name__)
print(type(3.0).__name__)
print(type("3").__name__)
print(type(True).__name__)
print(type(None).__name__)
```

输出：
```text
int
float
str
bool
NoneType
```

解析：type 返回类型对象，.__name__ 取该类型的名字。现在只需知道这套写法用于观察，不必提前掌握类。

改写：分别对 0、""、False 使用 type。

## 示例 02_03 分钟拆分

路径：examples/ch02/ex02_03.py

```python
total_minutes = 125
hours = total_minutes // 60
minutes = total_minutes % 60
print(hours, "小时", minutes, "分钟")
```

输出：
```text
2 小时 5 分钟
```

解析：整除计算装满了几个 60，取余计算不足一个 60 的部分。用 2 * 60 + 5 可以还原总数。

改写：换成 59、60、61，检查边界。

## 示例 02_04 从输入到数值

路径：examples/ch02/ex02_04.py

```python
text = input("请输入本周学习天数：")
days = int(text)
print("总分钟", days * 45)
```

输入：
```text
3
```

输出：
```text
请输入本周学习天数：总分钟 135
```

解析：示例自动核验会模拟输入 3。真实终端中你键入的 3 也会显示，但那是键盘回显，不属于程序输出。input 不自动给提示末尾加换行。

改写：输入 0 或 7；再试 abc，观察 ValueError。

## 示例 02_05 购物合计

路径：examples/ch02/ex02_05.py

```python
price_fen = 1250
quantity = 3
total_fen = price_fen * quantity
print(total_fen)
print(f"应付 {total_fen / 100:.2f} 元")
```

输出：
```text
3750
应付 37.50 元
```

解析：运算在整数分上完成，显示时除以 100 并保留两位。f 开头的字符串允许在花括号里填表达式，第 03 章展开。

改写：把数量改成 0，说明它是否符合你的业务规则。

## 示例 02_06 交换变量

路径：examples/ch02/ex02_06.py

```python
a = 10
b = 20
a, b = b, a
print(a, b)
print(-7 // 3, -7 % 3)
```

输出：
```text
20 10
-3 2
```

解析：交换时先求右侧旧的 b 和 a，再给左边两个名字赋值。第二个输出提醒你：负数整除不是简单删掉小数位。

改写：用第三个临时变量完成同样的交换。

## 练习 02_01 摄氏转华氏（基础）

已有 celsius = 25，按 F = C × 9 / 5 + 32 计算并输出 77.0。

作答文件：practice/ch02/ex02_01.py

提示：乘号写 *，不要写 ×。

参考答案：
```python
celsius = 25
fahrenheit = celsius * 9 / 5 + 32
print(fahrenheit)
```

输出：
```text
77.0
```

解析：按公式依次运算。用 0 摄氏度手算应为 32，可额外验证。

## 练习 02_02 时间拆分（场景）

把 3671 秒拆成小时、分钟、秒，并用空格分隔输出 1 1 11。

作答文件：practice/ch02/ex02_02.py

提示：先整除 3600；剩余秒数再拆成 60。

参考答案：
```python
seconds = 3671
hours = seconds // 3600
minutes = (seconds % 3600) // 60
remaining = seconds % 60
print(hours, minutes, remaining)
```

输出：
```text
1 1 11
```

解析：先取整小时，余数 71 秒含 1 分 11 秒。验算 1*3600+1*60+11=3671。

## 练习 02_03 两次输入相加（基础）

程序读入两行整数，不打印提示。输入 12 和 8 时输出 20。

作答文件：practice/ch02/ex02_03.py

提示：每次 input 后调用 int。

参考答案：
```python
a = int(input())
b = int(input())
print(a + b)
```

输出：
```text
20
```

解析：如果忘记 int，字符串相加会得到 128。先决定需要的运算，再决定数据类型。

## 练习 02_04 修复记账类型（纠错）

price = "19.5"，count = 2，输出总价 39.0；解释为什么 price * count 得不到总价。

作答文件：practice/ch02/ex02_04.py

提示：字符串乘整数是重复文本。

参考答案：
```python
price = "19.5"
count = 2
print(float(price) * count)
```

输出：
```text
39.0
```

解析：原式会得到 19.519.5，因为字符串重复两次。转换为 float 后才是数值乘法；正式账本建议整数分。

## 练习 02_05 整箱装书（场景）

53 本书，每箱最多 12 本。输出完整箱数 4、剩余本数 5、实际需要箱数 5，每个一行。

作答文件：practice/ch02/ex02_05.py

提示：最后不足一箱也占一箱；可用 (数量 + 容量 - 1) // 容量。

参考答案：
```python
books = 53
capacity = 12
print(books // capacity)
print(books % capacity)
print((books + capacity - 1) // capacity)
```

输出：
```text
4
5
5
```

解析：向上取整公式适用于非负整数数量和正整数容量。用 0、12、13 验证，得到 0、1、2 箱。

## 练习 02_06 平均速度（迁移）

跑了 5 千米，用时 1500 秒。输出平均速度 12.0，单位千米每小时。

作答文件：practice/ch02/ex02_06.py

提示：先把秒换成小时；别把分钟当小时。

参考答案：
```python
distance_km = 5
time_seconds = 1500
time_hours = time_seconds / 3600
print(distance_km / time_hours)
```

输出：
```text
12.0
```

解析：变量名带单位能减少错误。速度=路程/时间；现实程序还要保证用时大于 0，第 04 章学习判断。

## 记忆要点
- 赋值看右边，算完再绑定左边。
- input 得到 str；需要数值时显式转换。
- / 得小数，// 向下取整，% 取余，** 乘方。

## 通关检查
- 我能解释 age = age + 1 的执行过程。
- 我能让两个键盘输入的数字正确相加。
- 我能把总秒数拆成分钟和剩余秒数。


# 03 字符串与文本处理

从姓名、句子和日志中提取真正需要的信息。

## 学习目标

- 会索引、切片、清理和拆分文本
- 能用 f-string 组织清晰输出
- 理解不可变对象与返回新值

### 字符串是有顺序的文本
`text = "Python"` 包含 6 个字符，`len(text)` 得到 6。索引从 0 开始：`text[0]` 是 P，`text[1]` 是 y；负索引从末尾数，`text[-1]` 是 n。长度为 n 的非空字符串，正索引范围是 0 到 n-1。空字符串 `""` 的长度为 0，没有第一个字符。

`"你好"` 的长度是 2；但有些 emoji 和组合字符由多个 Unicode 码点组成，len 不等于所有场景下肉眼看到的图形数。普通中英文练习先按常见字符理解即可。

### 切片的左闭右开
`text[start:stop:step]` 从 start 开始，到 stop 之前停止，按 step 跨步取。`text[1:4]` 取索引 1、2、3。省略 start 通常从开头，省略 stop 通常到结尾。`text[:3]` 是前三个，`text[3:]` 是从索引 3 到末尾，`text[::-1]` 是倒序。

记忆方法：“**开始要，结束不要**”。先在纸上写索引，再圈出实际取到的位置。普通正向切片中，取几个常可用 stop-start 理解；遇到负步长要重新沿步长方向数。步长不能是 0。单个索引越界报 IndexError，切片超出边界通常会自动截到可用范围。

### 方法调用读成“对这个对象做什么”
`text.strip()` 是调用字符串对象的方法，返回去掉两端空白的新字符串；不会删除中间空格。`lower()` 变小写，`upper()` 变大写，`replace(old, new)` 替换文本，`split()` 按连续空白拆分，`split(",")` 按逗号拆分，`"-".join(parts)` 用连字符把多个字符串连接。

`" a  b ".split()` 得到两个词；`"a,,b".split(",")` 中间会保留空字符串。CSV 含引号或逗号时不要自己 split，第 12 章用 csv 模块。

### 字符串不能就地修改
`name[0] = "L"` 会失败，因为 str 不可变。`name.upper()` 返回新字符串；如果没有保存它，旧变量仍指向原字符串。写 `name = name.upper()` 才让 name 指向转换结果。不可变不等于变量不能重新赋值。

### 格式化输出
`f"你好，{name}"` 会把 name 的值放入文本；f 必须紧贴开头引号。`f"{score:.2f}"` 显示两位小数，`f"{rate:.1%}"` 将比例显示为一位小数的百分数。格式化常用于报告，不应把显示后的字符串重新拿来做数值运算。

转义符 `\n` 表示换行，`\t` 表示制表符，`\\` 表示一个反斜杠。`r"C:\notes"` 是原始字符串的一种写法，但以单个反斜杠结尾仍有语法限制。处理路径优先用第 12 章的 pathlib，少手写反斜杠。三引号可以书写多行字符串，里面的缩进也可能成为文本的一部分。

### 一个实用的文本清洗流程
用户输入账号 → 去掉两端空白 → 统一大小写 → 检查是否为空 → 保存。每一步都先打印 repr(text)，就能看到看不见的空格和换行。例如 `repr(" hi\n")` 会显示带引号和转义符的表示。不要为了“清洗”随意删除有意义的内部空格或符号。

## 示例 03_01 读字符与切片

路径：examples/ch03/ex03_01.py

```python
word = "Python"
print(word[0], word[-1])
print(word[1:4])
print(word[:2], word[2:])
print(word[::-1])
```

输出：
```text
P n
yth
Py thon
nohtyP
```

解析：索引 1 到 3 是 y、t、h。反向切片从最后一个字符向前走。

改写：预测 word[1:5:2] 和 word[99:]，再运行。

## 示例 03_02 清理姓名

路径：examples/ch03/ex03_02.py

```python
raw = "  Alice\n"
clean = raw.strip().lower()
print(repr(raw))
print(clean)
print(len(clean))
```

输出：
```text
'  Alice\n'
alice
5
```

解析：先 strip 再 lower，链式调用从左向右。repr 明确显示末尾换行，普通 print 不容易看出它。

改写：把输入改成全空格，观察 clean 是否为空。

## 示例 03_03 分词与拼接

路径：examples/ch03/ex03_03.py

```python
sentence = "  learn   python today  "
words = sentence.split()
print(words)
print("-".join(words))
```

输出：
```text
['learn', 'python', 'today']
learn-python-today
```

解析：split 无参数时把连续空白当分隔。结果是列表，第 07 章详细学习。join 的接收者是分隔字符串，参数是字符串序列。

改写：把 split() 改成 split(" ")，比较连续空格的处理。

## 示例 03_04 报告格式

路径：examples/ch03/ex03_04.py

```python
name = "小林"
score = 86.456
rate = 0.875
print(f"{name}的分数：{score:.2f}")
print(f"完成率：{rate:.1%}")
print(f"编号：{7:03d}")
```

输出：
```text
小林的分数：86.46
完成率：87.5%
编号：007
```

解析：.2f 是小数点后两位；.1% 会乘 100 并添加百分号；03d 将整数补齐为至少三位，不足用 0 填充。

改写：将完成率改成 1 与 0，解释输出。

## 示例 03_05 方法返回新文本

路径：examples/ch03/ex03_05.py

```python
text = "python is fun"
text.replace("fun", "useful")
print(text)
text = text.replace("fun", "useful")
print(text)
print("python" in text)
```

输出：
```text
python is fun
python is useful
True
```

解析：第一次 replace 的返回值被丢弃。第二次赋回 text 才改变名字指向的对象。in 判断子串是否出现，返回布尔值。

改写：检查大小写不同的 "Python" 是否在 text 中。

## 示例 03_06 AI / Agent：用字符串组织提示词

路径：examples/ch03/ex03_06.py

```python
topic = "  Python 列表  "
level = "零基础"
clean_topic = topic.strip()
prompt = f"请面向{level}读者解释：{clean_topic}。"
print(prompt)
print(f"主题字符数：{len(clean_topic)}")
```

输出：
```text
请面向零基础读者解释：Python 列表。
主题字符数：9
```

解析：输入是带首尾空格的 topic。strip 返回去掉首尾空白的新字符串，原 topic 不会被自动修改；赋值后 clean_topic 是 'Python 列表'。f-string 把两个变量的值插入文字，print 才显示结果。len 在这里数 Python 字符串的 Unicode 码位，空格也算一个；它不是模型 token 数。本例只生成提示词，没有模型回复。

改写：先把 topic 改成只有三个空格，预测清理后的字符数。再把模板改成要求输出一个例子，观察引号内文字与变量名各在哪里。

## 练习 03_01 邮箱清洗（基础）

把 "  Learner@Example.COM  " 去两端空白并转小写，输出 learner@example.com。

作答文件：practice/ch03/ex03_01.py

提示：链式调用 strip 和 lower。

参考答案：
```python
email = "  Learner@Example.COM  "
print(email.strip().lower())
```

输出：
```text
learner@example.com
```

解析：只去两端，不改变内部结构。这里是字符串操作练习，不是完整的邮箱有效性验证。

## 练习 03_02 遮盖号码（场景）

给定 phone = "13812345678"，保留前 3 位和后 4 位，中间用 4 个星号，输出 138****5678。

作答文件：practice/ch03/ex03_02.py

提示：切片 [:3] 和 [-4:]。

参考答案：
```python
phone = "13812345678"
print(phone[:3] + "****" + phone[-4:])
```

输出：
```text
138****5678
```

解析：先确认本题假定长度为 11。真实输入还需检查长度和内容，不能把遮盖当作完整隐私保护。

## 练习 03_03 提取文件扩展名（基础）

给定 filename = "report.final.csv"，按点拆分，输出最后一部分 csv。

作答文件：practice/ch03/ex03_03.py

提示：split 返回列表，[-1] 取最后一项。

参考答案：
```python
filename = "report.final.csv"
print(filename.split(".")[-1])
```

输出：
```text
csv
```

解析：这里练 split 与负索引。处理真实路径更适合 Path.suffix，尤其要注意隐藏文件和没有扩展名的文件。

## 练习 03_04 清理多余空格（场景）

把 "  I   love  Python  " 转成单个空格分隔的 "I love Python"。

作答文件：practice/ch03/ex03_04.py

提示：split() 后使用一个空格 join。

参考答案：
```python
text = "  I   love  Python  "
print(" ".join(text.split()))
```

输出：
```text
I love Python
```

解析：strip 只能去两端，无法合并内部重复空格。先拆成词，再以统一分隔符拼接。

## 练习 03_05 切片推理（推理）

给定 s = "abcdef"，依次输出 bdf、fedcba、abc，各一行。

作答文件：practice/ch03/ex03_05.py

提示：选择不同起点和步长；反向步长为 -1。

参考答案：
```python
s = "abcdef"
print(s[1::2])
print(s[::-1])
print(s[:3])
```

输出：
```text
bdf
fedcba
abc
```

解析：第一个索引序列是 1、3、5。第二个是 5、4、3、2、1、0。第三个是 0、1、2。

## 练习 03_06 学习进度百分比（迁移）

完成 7 节，共 20 节。输出 已完成 35.0%。

作答文件：practice/ch03/ex03_06.py

提示：先算比例，再用 .1% 格式。

参考答案：
```python
completed = 7
total = 20
print(f"已完成 {completed / total:.1%}")
```

输出：
```text
已完成 35.0%
```

解析：比例是 0.35；百分号格式自己乘以 100。不要先乘 100 再用 %，否则会显示 3500.0%。

## 练习 03_07 确认是否已保存清洗结果（纠错）

text="  Python  "。有人调用 text.strip() 后直接打印 text。请修复并输出 Python，再输出长度 6。

作答文件：practice/ch03/ex03_07.py

提示：方法返回新字符串，要保存返回值。

参考答案：
```python
text = "  Python  "
text = text.strip()
print(text)
print(len(text))
```

输出：
```text
Python
6
```

解析：字符串不可变，strip 不会就地改原对象。长度检查帮助你确认两端空格确实被去掉。

## 练习 03_08 AI / Agent：整理检索问题（迁移）

给定 question = "  What is Python?  "，先去掉首尾空白，再变成小写，保存为 cleaned。输出 cleaned，下一行输出它的字符数。只练字符串处理，不做真实搜索。

作答文件：practice/ch03/ex03_08.py

提示：先写 cleaned = question.strip().lower()。方法从左到右执行；len 统计的是清理后字符串。

参考答案：
```python
question = "  What is Python?  "
cleaned = question.strip().lower()
print(cleaned)
print(len(cleaned))
```

输出：
```text
what is python?
15
```

解析：原始文字 → strip 去边缘空白 → lower 统一英文大小写 → len 计数。中间的两个空格和问号都保留。常见错误是对原 question 求长度，或忘了接住方法返回的新字符串。边界试验：空字符串清理后仍为空，长度为 0；中文不因为 lower 而变成英文。

## 记忆要点
- 索引从 0 开始；切片开始要、结束不要。
- strip 去两端，split 拆开，join 拼起来。
- 字符串方法返回新值，想保留就重新赋值。

## 通关检查
- 我能手算 "Python"[1:5:2]。
- 我能清洗用户输入后生成一句格式化问候。
- 我能解释为什么 text.upper() 不会改变原字符串。


# 04 比较 条件与分支

让程序根据具体情况做出不同选择。

## 学习目标

- 使用比较和逻辑运算
- 正确组织 if、elif、else
- 检查边界、空值和短路行为

### 从比较得到真假
`==` 判断相等，`!=` 判断不等，`>`、`<`、`>=`、`<=` 比较大小。赋值 `=` 与比较 `==` 完全不同。`18 <= age < 60` 是合法链式比较，表示年龄同时落在这个区间。

`and` 要两边条件都成立，`or` 至少一边成立，`not` 取反。判断是否周末应写 `day == "周六" or day == "周日"`，不能写 `day == "周六" or "周日"`；后一项非空字符串总为真。

### if 是选一条路径
基本结构是 `if 条件:`，下一行缩进写条件为真时做的事。`elif` 是前面没有选中时继续检查，`else` 是前面全部没有选中时兜底。一条 if/elif/else 链只会执行最多一个分支。多个独立 if 则会分别检查，有可能都执行。

成绩分级如果先判断 `score >= 60`，90 分也会先进入这个分支；高等级通常从高到低写。缩进结束代表这个分支结束。程序随后继续执行分支后面的语句。

### 真值不是只有 True 和 False
`0`、`0.0`、空字符串、空列表、空字典、空集合和 None 常被当作假；非空容器通常为真。`if items:` 常用来表示“有元素”。`"0"` 和 `"False"` 都是非空字符串，是真。

`None` 经常表示缺失值，判断它用 `is None` 或 `is not None`。值相等通常用 `==`；`is` 判断是否为同一个对象，不要用它比较普通字符串或整数的数值相等。

### 短路能避免无意义运算
`and` 左边为假就不看右边；`or` 左边为真就不看右边。例如 `count > 0 and total / count > 60`，count 为 0 时不会执行除法，避免除零。技术上 and/or 返回某个操作数，而不保证一定返回 bool；刚开始尽量让两边都是明确条件。

### 用边界检查自己的判断
判断“满 100 减 10”，最有价值的测试不是只测 200，而是测 99、100、101。判断合法成绩，至少测 -1、0、60、100、101。先验证输入范围，再做业务分级，能避免把不可能的值分成正常等级。

### 把复杂判断拆成有意义的名字
不要把所有逻辑塞进一个长 if。可以先算 `has_ticket`、`is_open`，再判断 `if has_ticket and is_open:`。表达式很长时加括号，明确逻辑关系。条件中的布尔变量不必写 `== True`。

## 示例 04_01 判断是否通过

路径：examples/ch04/ex04_01.py

```python
score = 59
if score >= 60:
    print("通过")
else:
    print("继续练习")
print("记录完成")
```

输出：
```text
继续练习
记录完成
```

解析：59 >= 60 为假，走 else。最后一行没有缩进，因此不论哪条路径都会执行。

改写：把 score 换成 60、100、-1；最后一个提醒你还没验证范围。

## 示例 04_02 成绩等级

路径：examples/ch04/ex04_02.py

```python
score = 85
if not 0 <= score <= 100:
    print("成绩无效")
elif score >= 90:
    print("优秀")
elif score >= 60:
    print("通过")
else:
    print("待加强")
```

输出：
```text
通过
```

解析：先挡住不合法数据，然后从最高阈值往下判断。85 不满足 90，但满足 60。

改写：验证 -1、0、59、60、89、90、100、101。

## 示例 04_03 同时满足两个条件

路径：examples/ch04/ex04_03.py

```python
age = 20
has_ticket = True
if age >= 18 and has_ticket:
    print("允许进入")
else:
    print("条件未满足")
```

输出：
```text
允许进入
```

解析：and 要求年龄和票据条件都成立。用四种真假组合做真值表，可以检查逻辑。

改写：把 has_ticket 改为 False，再把 age 改为 17。

## 示例 04_04 短路避免除零

路径：examples/ch04/ex04_04.py

```python
count = 0
total = 80
if count > 0 and total / count >= 60:
    print("平均分通过")
else:
    print("人数不足或平均分未通过")
```

输出：
```text
人数不足或平均分未通过
```

解析：第一个条件为假，第二个除法不执行。短路可以用来保护操作，但别让重要的副作用藏在条件右边。

改写：改成 count = 2，手算第二个条件。

## 示例 04_05 缺失与零分

路径：examples/ch04/ex04_05.py

```python
score = 0
if score is None:
    print("尚未提交")
else:
    print(f"已提交，成绩 {score}")
print(bool("False"), bool(""))
```

输出：
```text
已提交，成绩 0
True False
```

解析：零分仍是已提交的有效成绩。若使用 if not score，会把 0 和缺失混在一起。

改写：把 score 改成 None，观察分支改变。

## 示例 04_06 AI / Agent：按明确规则选择处理路径

路径：examples/ch04/ex04_06.py

```python
request = " 请解释列表 "
cleaned = request.strip()
if cleaned == "":
    route = "请先输入问题"
elif "计算" in cleaned:
    route = "交给计算工具"
else:
    route = "进入文字讲解"
print(route)
```

输出：
```text
进入文字讲解
```

解析：先清理 request，再从上到下尝试条件。当前内容非空且不含“计算”，所以进入 else。这叫规则路由：由你写的条件决定走哪条路径，不包含模型理解。后面的章节会把这些文字路径换成函数调用。包含“不要计算”的句子也会命中关键词，说明关键词判断有明确局限。

改写：依次替换 request 为三个空格、“请计算 2 + 3”、“不要计算，只解释”。记录实际路线，再写一句话说明这种规则为什么不能代表理解了用户意图。

## 练习 04_01 奇偶判断（基础）

给定 n = 17，如果能被 2 整除输出 偶数，否则输出 奇数。

作答文件：practice/ch04/ex04_01.py

提示：余数等于 0 才是整除。

参考答案：
```python
n = 17
if n % 2 == 0:
    print("偶数")
else:
    print("奇数")
```

输出：
```text
奇数
```

解析：17 除以 2 余 1。补测 0 和负数，0 也属于偶数。

## 练习 04_02 运费规则（场景）

订单金额 99 元，满 100 元免运费，否则运费 8 元。输出应付总额 107。

作答文件：practice/ch04/ex04_02.py

提示：临界值 100 属于免运费。

参考答案：
```python
amount = 99
if amount >= 100:
    shipping = 0
else:
    shipping = 8
print(amount + shipping)
```

输出：
```text
107
```

解析：先决定运费，再统一计算合计，避免在每个分支重复相同输出。

## 练习 04_03 合法分数（纠错）

score = 105。若不在 0 到 100 内输出 无效，否则达到 60 输出 通过，未达到输出 未通过。

作答文件：practice/ch04/ex04_03.py

提示：合法性判断放在等级判断之前。

参考答案：
```python
score = 105
if not 0 <= score <= 100:
    print("无效")
elif score >= 60:
    print("通过")
else:
    print("未通过")
```

输出：
```text
无效
```

解析：错误写法先判断 score >= 60，会把 105 判通过。必须先限定问题域。

## 练习 04_04 周末判断修复（纠错）

day = "周一"。修复 if day == "周六" or "周日" 的错误，输出 工作日。

作答文件：practice/ch04/ex04_04.py

提示：or 两边都要完整比较，也可使用 in。

参考答案：
```python
day = "周一"
if day == "周六" or day == "周日":
    print("周末")
else:
    print("工作日")
```

输出：
```text
工作日
```

解析："周日" 作为独立非空字符串是真；修复后左右两项均为 False。

## 练习 04_05 找三个数最大值（迁移）

a=7，b=12，c=9，不用 max，输出最大值 12。

作答文件：practice/ch04/ex04_05.py

提示：先把 a 当作当前最大值，再依次比较。

参考答案：
```python
a, b, c = 7, 12, 9
largest = a
if b > largest:
    largest = b
if c > largest:
    largest = c
print(largest)
```

输出：
```text
12
```

解析：这里用两个独立 if，因为两次比较都需要执行。相等时无需更新，结果仍正确。

## 练习 04_06 区分未填和零（场景）

value = 0，None 表示未填，其他数值包括 0 都算已填。输出 已填。

作答文件：practice/ch04/ex04_06.py

提示：用 is None，而不是 if not value。

参考答案：
```python
value = 0
if value is None:
    print("未填")
else:
    print("已填")
```

输出：
```text
已填
```

解析：业务含义决定判断方式。零值和没有值是两个不同状态。

## 练习 04_07 实验启动条件（场景）

has_data=True，has_config=True，is_running=False。只有有数据、有配置且当前未运行时输出 可以启动，否则输出 暂不能启动。

作答文件：practice/ch04/ex04_07.py

提示：三个条件用 and，其中一个要取 not。

参考答案：
```python
has_data = True
has_config = True
is_running = False
if has_data and has_config and not is_running:
    print("可以启动")
else:
    print("暂不能启动")
```

输出：
```text
可以启动
```

解析：布尔名字直接放入条件，无需 == True。逐个翻转三个值，应该每次都阻止启动。

## 练习 04_08 AI / Agent：输出长度约定（迁移）

给定 reply = "  hello  "、limit = 5。清理首尾空白后：空内容输出 empty；字符数超过 limit 输出 too_long；否则输出 ok。条件按此顺序组织。

作答文件：practice/ch04/ex04_08.py

提示：长度恰好等于 5 应通过，比较符号应是 >。先判断空内容，可以把两种错误明确区分。

参考答案：
```python
reply = "  hello  "
limit = 5
cleaned = reply.strip()
if cleaned == "":
    print("empty")
elif len(cleaned) > limit:
    print("too_long")
else:
    print("ok")
```

输出：
```text
ok
```

解析：清理后 hello 长度为 5，没有超过上限，因此输出 ok。请分别用空白、四字符、五字符、六字符测试。这是我们自己制定的字符长度规则，不是实际模型的上下文长度限制；模型上下文一般需要按对应 tokenizer 的 token 计算。

## 记忆要点
- 一条 if 链选一个分支；多个 if 各自判断。
- 等级从高到低，边界围着临界值测。
- 相等用 ==，缺失用 is None。

## 通关检查
- 我能为满减活动写出 99、100、101 三个测试。
- 我能解释两个独立 if 和 if/elif 的区别。
- 我能修正 day == "周六" or "周日"。


# 05 for 循环与逐项处理

同样的动作做很多次，用循环描述规则。

## 学习目标

- 理解遍历、循环变量和 range
- 会累计、计数、筛选与查找
- 能手工追踪每一次迭代

### 为什么需要循环
三个分数可以手写三次相加，三千个就不行。`for score in scores:` 的含义是从 scores 中依次取一个元素，把它绑定给 score，然后执行缩进块。块执行完再取下一个，取完就结束。每一次重复称为一次“迭代”。

本章先把 `[70, 85, 60]` 理解成一串有顺序的数据，叫列表。第 07 章再系统学习增删改查。循环变量可以任意起名，但 `score` 比 `x` 更容易理解。

### range 生成整数序列
`range(5)` 对应 0、1、2、3、4；`range(1, 6)` 对应 1、2、3、4、5；`range(2, 10, 2)` 对应 2、4、6、8。与切片一样，终点不包含。`range(5, 0, -1)` 是 5、4、3、2、1。步长必须非零，方向与边界不匹配时可能一个数都没有。

range 本身不是列表，一般不用为了遍历把它转成 list。要观察全部内容时可以 `print(list(range(5)))`。range 使用整数边界，不接受 0.5 作为步长。

### 累计与计数模板
累计先设 `total = 0`，每次执行 `total += value`。计数先设 `count = 0`，满足条件时执行 `count += 1`。初始值写在循环前；如果写在循环体里，每次都会清零。

“及格人数”累加的是 1；“及格分数总和”累加的是 score。两个题目只差一句话，代码里的更新对象却不同。每次写循环都要问：本轮取到什么？保留什么状态？状态怎么变？什么时候结束？

### 手工追踪表
对 `[2, 4, 1]` 求和：初始 total=0；第 1 轮 value=2，更新为 2；第 2 轮 value=4，更新为 6；第 3 轮 value=1，更新为 7。不要只看最后输出，要能解释中间过程。网页的循环演示器可以逐步显示同样的状态变化。

### 空数据也要考虑
空列表的 for 一次也不执行，因此 total 保留 0。这是合理的空和，但平均值不能直接写 total/len(values)，因为人数为 0。先检查长度再除。求最大值时也要明确空数据怎么处理；第 09 章用函数返回 None 或抛异常表达约定。

### 循环之后变量还在吗
for 不会创建独立作用域。非空循环结束后，循环变量通常仍绑定最后一个值；如果循环一次未执行，这个名字可能根本没定义。不要依赖“最后的循环变量”作为结果；建立明确的结果变量更可靠。

## 示例 05_01 遍历分数

路径：examples/ch05/ex05_01.py

```python
scores = [70, 85, 60]
for score in scores:
    print("分数", score)
print("遍历结束")
```

输出：
```text
分数 70
分数 85
分数 60
遍历结束
```

解析：循环每次只取一个分数。最后的 print 在循环外，因此只执行一次。

改写：把最后的 print 缩进到循环体，预测区别。

## 示例 05_02 累计过程

路径：examples/ch05/ex05_02.py

```python
total = 0
for value in [2, 4, 1]:
    total += value
    print(value, total)
print("总和", total)
```

输出：
```text
2 2
4 6
1 7
总和 7
```

解析：total 跨越各轮保存累计状态。输出中的左列是本轮输入，右列是更新后的累计值。

改写：把 total = 0 移入循环，解释为什么结果变成 1。

## 示例 05_03 统计通过人数

路径：examples/ch05/ex05_03.py

```python
count = 0
for score in [59, 60, 80, 40]:
    if score >= 60:
        count += 1
print(count)
```

输出：
```text
2
```

解析：for 里面嵌套 if，因此 if 内再多缩进一层。只在 60 和 80 两轮更新 count。

改写：再添加 total，只累计通过者分数。

## 示例 05_04 范围与倒计时

路径：examples/ch05/ex05_04.py

```python
for number in range(1, 4):
    print(number)
print("倒数")
for number in range(3, 0, -1):
    print(number)
```

输出：
```text
1
2
3
倒数
3
2
1
```

解析：range 的第二个参数是停止边界，不是最后一个必含元素。负步长使数值递减。

改写：改成 range(3, 0)，解释为什么没有输出。

## 示例 05_05 带序号遍历

路径：examples/ch05/ex05_05.py

```python
names = ["小林", "小陈", "小王"]
for number, name in enumerate(names, start=1):
    print(number, name)
```

输出：
```text
1 小林
2 小陈
3 小王
```

解析：enumerate 把每项配上序号，左边两个名字分别接收序号与内容。start=1 改的是显示序号，不是原列表索引规则。

改写：删除 start=1，观察序号从哪里开始。

## 示例 05_06 空列表平均值

路径：examples/ch05/ex05_06.py

```python
scores = []
total = 0
for score in scores:
    total += score
if len(scores) == 0:
    print("没有成绩")
else:
    print(total / len(scores))
```

输出：
```text
没有成绩
```

解析：空列表跳过循环，再由 if 避免除零。程序要为“什么都没有”设计明确输出。

改写：填入两个分数，再测试只有一个分数。

## 练习 05_01 一到十求和（基础）

用 for 循环计算 1 到 10（包含 10）的总和，输出 55。

作答文件：practice/ch05/ex05_01.py

提示：range 的终点要写 11。

参考答案：
```python
total = 0
for number in range(1, 11):
    total += number
print(total)
```

输出：
```text
55
```

解析：10 轮分别累加 1 至 10。不要在循环内部重新初始化 total。

## 练习 05_02 偶数总和（基础）

计算 1 到 20 之间所有偶数的和，输出 110。

作答文件：practice/ch05/ex05_02.py

提示：可用步长 2，也可用 if 判断。

参考答案：
```python
total = 0
for number in range(2, 21, 2):
    total += number
print(total)
```

输出：
```text
110
```

解析：从 2 开始每次加 2，包含 20。另一种写法遍历全部整数后检查 % 2 == 0。

## 练习 05_03 学习打卡统计（场景）

学习分钟数 [0, 30, 45, 0, 60]，分别输出学习过的天数 3 和总分钟 135。

作答文件：practice/ch05/ex05_03.py

提示：计数累加 1，总量累加分钟。

参考答案：
```python
days = 0
total = 0
for minutes in [0, 30, 45, 0, 60]:
    if minutes > 0:
        days += 1
    total += minutes
print(days)
print(total)
```

输出：
```text
3
135
```

解析：总和可以对所有项累加，因为 0 不影响结果；有效天数只在大于 0 时增加。

## 练习 05_04 单词长度报告（场景）

对 ["cat", "python", "AI"] 输出 cat:3、python:6、AI:2，各占一行。

作答文件：practice/ch05/ex05_04.py

提示：每次处理一个词，len 得到长度。

参考答案：
```python
for word in ["cat", "python", "AI"]:
    print(f"{word}:{len(word)}")
```

输出：
```text
cat:3
python:6
AI:2
```

解析：把“单个词怎样处理”写好后，再用 for 应用到整个列表，这正是批量处理的基本模式。

## 练习 05_05 找最大值（迁移）

不用 max，求非空列表 [-5, -2, -9] 的最大值，输出 -2。

作答文件：practice/ch05/ex05_05.py

提示：初始最大值不能设为 0，全部为负会出错。

参考答案：
```python
numbers = [-5, -2, -9]
largest = numbers[0]
for number in numbers[1:]:
    if number > largest:
        largest = number
print(largest)
```

输出：
```text
-2
```

解析：用第一个真实元素初始化，之后逐项更新。此实现约定列表非空，空列表需另行处理。

## 练习 05_06 统计元音（迁移）

统计 "Education" 中英文字母元音 a e i o u 的数量，忽略大小写，输出 5。

作答文件：practice/ch05/ex05_06.py

提示：先 lower，再用 in 判断字符是否属于元音字符串。

参考答案：
```python
count = 0
for char in "Education".lower():
    if char in "aeiou":
        count += 1
print(count)
```

输出：
```text
5
```

解析：依次命中 e、u、a、i、o。这里只统计指定英文字母，不是一般语言的语音分析。

## 练习 05_07 最长单词（迁移）

在 ["I","study","python","daily"] 中找第一个最长词，输出 python；不用 max。

作答文件：practice/ch05/ex05_07.py

提示：用当前最长词作状态，只在严格更长时更新。

参考答案：
```python
words = ["I", "study", "python", "daily"]
longest = ""
for word in words:
    if len(word) > len(longest):
        longest = word
print(longest)
```

输出：
```text
python
```

解析：使用 > 而不是 >=，同长度时保留先出现的词。本题空列表时结果为空字符串，应在实际需求中明确是否接受。

## 记忆要点
- for 是逐项取，不是自动同时处理所有项。
- 初始化放循环外，更新放循环内。
- 求和加数值，计数加 1，平均先防空。

## 通关检查
- 我能逐行追踪 total 在三轮循环中的值。
- 我能输出 1 到 10，而不是 0 到 9。
- 我能把筛选条件加入累计程序。


# 06 while 循环与流程控制

知道重复的终止条件，才能让程序可靠结束。

## 学习目标

- 根据条件重复执行
- 区分 break、continue 与 return
- 理解嵌套循环和循环边界

### for 与 while 怎样选
有一串现成数据需要逐项处理，优先 for；需要“持续做，直到某个条件改变”，常用 while。例如反复请求输入直到合法、持续显示菜单直到用户选择退出。不要为了用 while 把简单遍历写得更复杂。

`while 条件:` 每次进入循环前都会重新检查条件。先检查，再执行，所以条件一开始为假时执行零次。构成一个可控 while 的三个部分是：初始状态、继续条件、状态更新。遗漏更新可能产生无限循环。

### 手工走一遍计数器
`n = 3`；检查 n>0 为真，输出 3，然后 n-=1；再检查 2>0，输出 2；再检查 1>0，输出 1；最后 n=0，条件为假，退出。条件不是进入时检查一次就永远不变，每轮都会重新计算。

### break 与 continue
`break` 立即结束它所在的最内层循环；`continue` 跳过本轮剩余语句，开始下一轮。嵌套循环里的 break 通常只退出内层。函数里的 `return` 则结束整个当前函数，第 09 章会讲。

while 中如果把计数器更新放在 continue 之后，某些路径可能永远跳过更新。解决方法是确保每条继续执行的路径都推进状态，或者直接用 for 遍历。

### 哨兵输入与菜单
哨兵是约定的结束标记，例如输入 q 就结束录入。先检查是不是 q，再把其他输入转换成数字，否则 int("q") 会直接报错。数据值可能本身为 0 时，别把 0 随意当结束信号；选不会与有效输入冲突的标记。

### 嵌套循环怎样执行
外层走一步，内层通常完整走一遍。两组实验学习率 × 三个批大小，会有 2×3=6 种组合。把外层看成行、内层看成列有助于理解，但它不必真是二维表。不要把“两个顺序排列的循环”误认为“两个嵌套循环”。

### 从错误中退出
终端里的长时间循环通常可以用 Ctrl+C 中断；IDLE 可用 Shell 中断或 Restart Shell。Ctrl+C 是恢复控制的方法，不是正确终止条件的替代品。调试时先限制最大次数，例如最多 5 轮，再观察状态。

Python 还有 for/while 的 else：仅在循环正常耗尽或条件变假、没有由 break 退出时执行。初学阶段用 found 变量更直白；阅读别人代码遇到循环 else 时记住“没有 break 才执行”，它不属于内部 if。

## 示例 06_01 有限倒计时

路径：examples/ch06/ex06_01.py

```python
remaining = 3
while remaining > 0:
    print(remaining)
    remaining -= 1
print("开始")
```

输出：
```text
3
2
1
开始
```

解析：remaining 每轮减少，最终使条件变假。最后的输出在循环之外。

改写：把初值改成 0，观察循环体是否执行。

## 示例 06_02 遇到目标就停止

路径：examples/ch06/ex06_02.py

```python
for value in [4, 8, 15, 16]:
    if value > 10:
        print("首个大于 10 的数", value)
        break
    print("检查过", value)
```

输出：
```text
检查过 4
检查过 8
首个大于 10 的数 15
```

解析：15 命中后 break，连本轮后面的输出也跳过，16 不再访问。

改写：把条件改成 > 100，观察没有命中的路径。

## 示例 06_03 跳过无效项

路径：examples/ch06/ex06_03.py

```python
total = 0
for value in [10, -1, 20, -5, 30]:
    if value < 0:
        continue
    total += value
print(total)
```

输出：
```text
60
```

解析：负数轮次直接进入下一轮，正数被累计。continue 不结束整个遍历。

改写：改成 break，解释为什么只得到 10。

## 示例 06_04 读取直到 q

路径：examples/ch06/ex06_04.py

```python
total = 0
while True:
    text = input().strip()
    if text == "q":
        break
    total += int(text)
print(total)
```

输入：
```text
10
20
q
```

输出：
```text
30
```

解析：模拟输入 10、20、q。while True 靠显式 break 终止；先判 q，再转整数。非数字输入的恢复在第 13 章学习。

改写：增加一行数字 0，确认它是有效数据而不是退出标记。

## 示例 06_05 嵌套组合

路径：examples/ch06/ex06_05.py

```python
for model in ["A", "B"]:
    for batch_size in [2, 4]:
        print(model, batch_size)
```

输出：
```text
A 2
A 4
B 2
B 4
```

解析：外层 A 固定时，内层先完成 2 和 4；再换 B 并重新遍历内层。

改写：增加一个批大小 8，先预测输出行数。

## 示例 06_06 无命中的处理

路径：examples/ch06/ex06_06.py

```python
found = False
for value in [1, 3, 5]:
    if value % 2 == 0:
        found = True
        print(value)
        break
if not found:
    print("没有偶数")
```

输出：
```text
没有偶数
```

解析：found 记录有没有找到，循环结束后统一检查。把初始化放在循环外，才能跨轮保留结果。

改写：在中间加入 4，确认只打印第一个偶数。

## 示例 06_07 AI / Agent：最多执行三轮的任务循环

路径：examples/ch06/ex06_07.py

```python
step = 0
max_steps = 3
finished = False
while step < max_steps:
    step += 1
    print(f"第 {step} 轮")
    if step == 2:
        finished = True
        break
print(f"已完成：{finished}")
print(f"实际轮数：{step}")
```

输出：
```text
第 1 轮
第 2 轮
已完成：True
实际轮数：2
```

解析：step 从 0 开始，每轮先加 1；第 1 轮未完成，重新判断 while；第 2 轮把 finished 改为 True 后 break，跳到循环后。max_steps 限制最多启动几轮；finished 单独记录是否完成。运行了两轮和任务完成是两个不同事实。本例用 step == 2 模拟完成条件，真实 Agent 需要根据执行结果判断。

改写：把完成条件改成 step == 5，max_steps 保持 3。应执行三轮且 finished 为 False。再把 max_steps 改成 0，说明为什么循环体一次也不执行。

## 练习 06_01 while 求和（基础）

用 while 求 1 到 5 的和，输出 15。

作答文件：practice/ch06/ex06_01.py

提示：计数器从 1 开始，每轮递增。

参考答案：
```python
number = 1
total = 0
while number <= 5:
    total += number
    number += 1
print(total)
```

输出：
```text
15
```

解析：条件用 <= 包含 5；循环后 number 为 6。不递增会一直累加同一个数。

## 练习 06_02 首次及格（场景）

在 [40, 55, 72, 90] 中找到第一个及格成绩，输出 72 后停止。

作答文件：practice/ch06/ex06_02.py

提示：条件成立后 print，再 break。

参考答案：
```python
for score in [40, 55, 72, 90]:
    if score >= 60:
        print(score)
        break
```

输出：
```text
72
```

解析：“第一个”意味着后面的 90 不应再输出。若题目改成“所有”，就不该 break。

## 练习 06_03 排除缺测值（场景）

[-1, 30, 0, 45, -1] 中 -1 表示缺测，0 有效。输出有效条数 3 与总量 75，各一行。

作答文件：practice/ch06/ex06_03.py

提示：只跳过 == -1 的项，不要写 if not value。

参考答案：
```python
count = 0
total = 0
for value in [-1, 30, 0, 45, -1]:
    if value == -1:
        continue
    count += 1
    total += value
print(count)
print(total)
```

输出：
```text
3
75
```

解析：缺失标记与 0 的业务含义不同。统计有效条数时必须保留 0。

## 练习 06_04 限次查找（推理）

给定 guesses = [2, 4, 7, 9]，目标是 7，输出第几次猜中：3。

作答文件：practice/ch06/ex06_04.py

提示：enumerate 从 1 开始，命中后 break。

参考答案：
```python
for attempt, guess in enumerate([2, 4, 7, 9], start=1):
    if guess == 7:
        print(attempt)
        break
```

输出：
```text
3
```

解析：序号表示尝试次数，元素表示猜测值。这两个概念不要混淆。

## 练习 06_05 二维坐标（迁移）

输出两行三列的所有坐标，行与列都从 0 开始，顺序为 0 0 到 1 2，每对一行。

作答文件：practice/ch06/ex06_05.py

提示：外层行号 range(2)，内层列号 range(3)。

参考答案：
```python
for row in range(2):
    for column in range(3):
        print(row, column)
```

输出：
```text
0 0
0 1
0 2
1 0
1 1
1 2
```

解析：外层 2 次，每次内层 3 次，一共 6 对。日后张量维度也需要先辨认每一轴表示什么。

## 练习 06_06 修复 continue 死循环（纠错）

写 while 输出 1 到 5 中的奇数，每个一行；确保遇到偶数也会推进计数器。

作答文件：practice/ch06/ex06_06.py

提示：一种简单方法是先递增，再决定是否 continue。

参考答案：
```python
n = 0
while n < 5:
    n += 1
    if n % 2 == 0:
        continue
    print(n)
```

输出：
```text
1
3
5
```

解析：更新放在 continue 前，所有轮次都能推进。若从 n=1 开始并把更新写在末尾，n=2 时可能永远跳过更新。

## 练习 06_07 整数各位求和（挑战）

对非负整数 2048，用 while、// 和 % 求各位数字之和，输出 14。

作答文件：practice/ch06/ex06_07.py

提示：%10 取末位，//10 去末位。

参考答案：
```python
number = 2048
total = 0
while number > 0:
    total += number % 10
    number //= 10
print(total)
```

输出：
```text
14
```

解析：逐轮末位为 8、4、0、2；number 变为 204、20、2、0。输入 0 时循环零次，总和仍为 0，符合约定。

## 练习 06_08 AI / Agent：完成前先扣减工作量（迁移）

给定 remaining = 5、steps = 0。每轮处理一项，remaining 减 1，steps 加 1；只要有剩余且 steps 小于 3 就继续。最后分别输出 steps、remaining。不要写无限循环。

作答文件：practice/ch06/ex06_08.py

提示：while 条件同时检查 remaining > 0 和 steps < 3。两个计数器都在循环体中更新。

参考答案：
```python
remaining = 5
steps = 0
while remaining > 0 and steps < 3:
    remaining -= 1
    steps += 1
print(steps)
print(remaining)
```

输出：
```text
3
2
```

解析：执行前为 (steps, remaining) = (0, 5)，三轮后依次为 (1, 4)、(2, 3)、(3, 2)。下一次条件检查因 steps < 3 为 False 停止。没有完成的两项仍保留，不能把达到轮数上限写成“任务完成”。改 remaining 为 0 和 1，分别应得到 0/0 与 1/0。

## 记忆要点
- while 看条件，for 看序列。
- break 退出循环；continue 跳过本轮后半段。
- 状态必须推进，才能走向结束条件。

## 通关检查
- 我能指出一个 while 的初始值、条件和更新。
- 我能写出输入 q 退出的程序。
- 我能解释两层循环的真实执行顺序。


# 07 列表 元组与对象关系

这是后续数据处理最常用、也最容易产生隐蔽错误的一章。

## 学习目标

- 会对列表增删改查和切片
- 区分修改原对象与返回新对象
- 理解别名、浅拷贝与嵌套列表

### 列表解决一组数据的问题
`values = [10, 20, 30]` 把多个对象放在有顺序的容器中，可按索引取元素，也可以遍历。列表可以混合类型，但同一业务列表尽量保持结构一致，便于处理。`len(values)` 是元素个数；嵌套列表的长度只数外层元素，不自动数所有叶子。

单项赋值 `values[1] = 99` 改变原列表。`append(x)` 在末尾加入一个对象；`extend(xs)` 逐项加入另一个可迭代对象中的内容；`insert(i, x)` 插入；`pop()` 删除并返回末尾元素；`pop(i)` 按索引删除并返回；`remove(x)` 删除第一个相等的值，找不到会报 ValueError。`del values[i]` 也能按索引删除。

### append 与 extend 的区别
对 `[1, 2]` 执行 append([3,4]) 得到 `[1,2,[3,4]]`，因为加入的是一个列表对象；extend([3,4]) 得到 `[1,2,3,4]`，因为逐项加入。初学可记：“append 装一个，extend 逐个装”。append("ab") 装入一个字符串，extend("ab") 会装入 a 和 b 两个字符。

### sort 与 sorted 的区别
`values.sort()` 修改原列表，并返回 None；`sorted(values)` 返回新的排序列表，原对象不变。不要写 `values = values.sort()`，否则 values 会变成 None。`reverse()` 也是就地修改并返回 None。降序可用 `sorted(values, reverse=True)`。

### 两个名字可能指向同一个对象
`b = a` 没有复制列表，只是让 b 也指向 a 指向的对象。因此 `b.append(3)` 后，通过 a 也会看到变化。与之不同，`b = [9]` 只是让 b 改绑到另一个列表，不会修改 a 指向的旧列表。

读代码时问两个问题：“这是在修改对象，还是在改变名字的绑定？” 列表方法、元素赋值经常修改对象；普通 `=` 给名字重新绑定。列表上的 `+=` 通常会就地扩展，不能简单把所有类型上的 += 都理解为创建新对象。

### 浅拷贝复制外壳
`b = a.copy()` 或 `b = a[:]` 创建新外层列表。如果元素是整数等不可变对象，通常足够；若元素本身是列表，内层仍可能共享。`copy.deepcopy(a)` 会递归复制常见嵌套结构，但不是所有对象都适合或需要深拷贝。

`grid = [[0] * 3] * 2` 的两行指向同一个内层列表，改一行会影响另一行。用循环每次新建 `[0] * 3`，或第 10 章的 `[[0] * 3 for _ in range(2)]`，让每行独立。

### 不要一边遍历一边删同一列表
删除会让后面的元素向前移动，迭代位置却继续推进，容易跳过元素。更直白的方法是建立新列表，只把要保留的元素 append 进去。第 10 章可写成列表推导式。

### 元组是不可变的序列
`point = (3, 4)` 常表示一组固定结构的数据。`x, y = point` 是解包，元素数量要匹配。单元素元组写 `(3,)`，逗号不能省；`(3)` 只是整数外加括号。元组的槽位不能重新赋值，但若里面放了可变列表，那个列表仍能改变，所以“元组不可变”并非内部所有东西都永远不变。

### 精讲：列表复制，到底复制到哪一层
先把名字想成指向对象的箭头。`b = a` 多画了一根箭头，并没有新建列表；`b = a.copy()` 新建一个外层列表，但外层里面装着的元素仍指向原来的对象。

以 `a = [[1], [2]]`、`b = a.copy()` 为例，先在纸上画出两个外层方框，再画两个内层方框。两个外层各自独立，但它们都指向那两个内层方框。

| 操作（各行都从上面的初始状态重新开始） | a | b | 原因 |
| --- | --- | --- | --- |
| b.append([3]) | [[1], [2]] | [[1], [2], [3]] | 只改 b 的外层 |
| b[0].append(9) | [[1, 9], [2]] | [[1, 9], [2]] | 改了共享的内层 |
| b[0] = [9] | [[1], [2]] | [[9], [2]] | 只换 b 外层第 0 格的箭头 |

回到示例 07_05，逐行运行，再分别替换成表中的操作。不要把三个操作连着执行后拿结果与表比较：表中每一行都假设你重新初始化了 a 和 b。

**迁移到会话记录：** `messages` 常是“列表里面装字典”。外层 `.copy()` 不能隔离每条字典里的修改。如果只是给复制后的列表追加新消息，外层复制常已够用；如果还要修改旧消息里的字段，就需要明确哪些层应独立。第 08 章先学会读写字典，第 14 章再处理不同会话的独立状态。

## 示例 07_01 基本增删改查

路径：examples/ch07/ex07_01.py

```python
tasks = ["阅读", "练习"]
tasks.append("复习")
tasks[0] = "读第 7 章"
last = tasks.pop()
print(tasks)
print(last)
```

输出：
```text
['读第 7 章', '练习']
复习
```

解析：append 加末尾，索引赋值改首项，pop 同时执行删除并返回被删内容。

改写：用 insert 在最前面插入 "预习"。

## 示例 07_02 append 和 extend

路径：examples/ch07/ex07_02.py

```python
a = [1, 2]
a.append([3, 4])
b = [1, 2]
b.extend([3, 4])
print(a)
print(b)
```

输出：
```text
[1, 2, [3, 4]]
[1, 2, 3, 4]
```

解析：a 外层长度为 3，b 长度为 4。嵌套结构不同，后面的遍历方式也不同。

改写：分别打印 len(a)、len(b)、a[-1][0]。

## 示例 07_03 排序与返回值

路径：examples/ch07/ex07_03.py

```python
scores = [80, 60, 90]
ordered = sorted(scores)
print(scores)
print(ordered)
result = scores.sort()
print(scores)
print(result)
```

输出：
```text
[80, 60, 90]
[60, 80, 90]
[60, 80, 90]
None
```

解析：sorted 没改 scores。sort 随后改变 scores，但它的返回值是 None。不要把“执行效果”与“返回值”混在一起。

改写：用 reverse=True 做降序，并确保原数据保留。

## 示例 07_04 共享与重新绑定

路径：examples/ch07/ex07_04.py

```python
a = [1, 2]
b = a
b.append(3)
print(a)
b = [9]
print(a)
print(b)
```

输出：
```text
[1, 2, 3]
[1, 2, 3]
[9]
```

解析：append 改共享对象；b = [9] 创建并改绑到另一个对象。重新绑定 b 不会沿着旧关系去修改 a。

改写：把 b = a 换成 b = a.copy()，比较第一条输出。

## 示例 07_05 浅拷贝的边界

路径：examples/ch07/ex07_05.py

```python
import copy
a = [[1], [2]]
b = a.copy()
c = copy.deepcopy(a)
b[0].append(9)
print(a)
print(b)
print(c)
```

输出：
```text
[[1, 9], [2]]
[[1, 9], [2]]
[[1], [2]]
```

解析：b 外层独立，b[0] 却和 a[0] 共享内层列表；c 的常见嵌套列表被递归复制。import 的语法第 11 章系统学习。

改写：尝试 b.append([3])，观察为什么不会给 a 增加第三项。

## 示例 07_06 二维列表共享陷阱

路径：examples/ch07/ex07_06.py

```python
bad = [[0] * 2] * 2
bad[0][0] = 9
print(bad)
good = []
for _ in range(2):
    good.append([0] * 2)
good[0][0] = 9
print(good)
```

输出：
```text
[[9, 0], [9, 0]]
[[9, 0], [0, 0]]
```

解析：重复的是内层对象的引用，不是重新创建两行。循环每轮产生新列表，因此 good 的两行彼此独立。下划线表示这个循环值本身不重要。

改写：把网格变成 3 行 4 列，只修改最后一格。

## 示例 07_07 元组与解包

路径：examples/ch07/ex07_07.py

```python
point = (3, 4)
x, y = point
print(x, y)
print(type((3,)).__name__)
print(type((3)).__name__)
```

输出：
```text
3 4
tuple
int
```

解析：逗号建立元组结构。解包按位置赋值，变量个数与元素个数不匹配会报错。

改写：尝试 a, b, c = point，读懂错误后恢复。

## 练习 07_01 保留合格成绩（场景）

从 [30, 60, 85, 59] 中生成新列表 [60, 85]，并输出。不要修改遍历中的原列表。

作答文件：practice/ch07/ex07_01.py

提示：建立空列表，满足条件就 append。

参考答案：
```python
passed = []
for score in [30, 60, 85, 59]:
    if score >= 60:
        passed.append(score)
print(passed)
```

输出：
```text
[60, 85]
```

解析：筛选模式保留原顺序，也避开边遍历边删除导致的跳项。

## 练习 07_02 最高三个分数（场景）

scores = [70, 95, 80, 60, 90]，输出降序前三名 [95, 90, 80]，再输出原列表。

作答文件：practice/ch07/ex07_02.py

提示：sorted 返回新列表，然后 [:3]。

参考答案：
```python
scores = [70, 95, 80, 60, 90]
print(sorted(scores, reverse=True)[:3])
print(scores)
```

输出：
```text
[95, 90, 80]
[70, 95, 80, 60, 90]
```

解析：两步分别做排序和取前缀。若不足三项，切片返回全部已有项，不会越界。

## 练习 07_03 修复复制错误（纠错）

a = [1,2]，复制给 b 后给 b 加入 3。要求输出 a 为 [1, 2]，b 为 [1, 2, 3]。

作答文件：practice/ch07/ex07_03.py

提示：用 copy，而不是仅赋值。

参考答案：
```python
a = [1, 2]
b = a.copy()
b.append(3)
print(a)
print(b)
```

输出：
```text
[1, 2]
[1, 2, 3]
```

解析：此题元素是不可变整数，浅拷贝足够。嵌套可变元素需要进一步考虑共享关系。

## 练习 07_04 移动末尾任务（迁移）

tasks = ["A", "B", "C"]，把最后一个移动到最前，输出 ["C", "A", "B"] 对应的 Python 列表表示。

作答文件：practice/ch07/ex07_04.py

提示：pop 返回被删项，再 insert 到索引 0。

参考答案：
```python
tasks = ["A", "B", "C"]
last = tasks.pop()
tasks.insert(0, last)
print(tasks)
```

输出：
```text
['C', 'A', 'B']
```

解析：题目默认列表非空。空列表调用 pop 会失败，真实函数可先判断 tasks。

## 练习 07_05 二维求和（迁移）

grid = [[1,2,3],[4,5,6]]，输出所有数字的总和 21。

作答文件：practice/ch07/ex07_05.py

提示：外层取一行，内层取该行的一个数。

参考答案：
```python
grid = [[1, 2, 3], [4, 5, 6]]
total = 0
for row in grid:
    for value in row:
        total += value
print(total)
```

输出：
```text
21
```

解析：len(grid) 是 2，不是 6。两层循环逐个访问叶子数值。

## 练习 07_06 按顺序去重（挑战）

对 ["a","b","a","c","b"] 去重并保留首次出现顺序，输出 ["a","b","c"] 的列表表示。

作答文件：practice/ch07/ex07_06.py

提示：若元素不在结果列表中，再 append。

参考答案：
```python
unique = []
for item in ["a", "b", "a", "c", "b"]:
    if item not in unique:
        unique.append(item)
print(unique)
```

输出：
```text
['a', 'b', 'c']
```

解析：本章用列表即可表达算法。大数据中反复做列表成员判断会较慢，第 08 章可增加 seen 集合提升通常的查找效率。

## 练习 07_07 修复 sort 赋值（纠错）

values=[3,1,2]。修复 values=values.sort() 导致 None 的问题；要求保留 values 的列表并输出升序。

作答文件：practice/ch07/ex07_07.py

提示：sort 自己修改原列表，不用接收返回值。

参考答案：
```python
values = [3, 1, 2]
values.sort()
print(values)
```

输出：
```text
[1, 2, 3]
```

解析：另一种正确方法是 values=sorted(values)。关键是辨认方法的副作用与返回值。

## 练习 07_08 复制二维数表（挑战）

original=[[1,2],[3,4]]，用逐行 copy 建立新二维列表，修改新表左上角为 9。依次输出原表、新表。

作答文件：practice/ch07/ex07_08.py

提示：外层新建，每行也新建；本题叶子为整数，复制两层足够。

参考答案：
```python
original = [[1, 2], [3, 4]]
copied = []
for row in original:
    copied.append(row.copy())
copied[0][0] = 9
print(original)
print(copied)
```

输出：
```text
[[1, 2], [3, 4]]
[[9, 2], [3, 4]]
```

解析：只 original.copy() 仍共享内层行。本题逐行 copy 解决两层结构，任意深层结构应重新分析或使用适当深拷贝。

## 记忆要点
- append 加一个，extend 逐个加。
- sort 改原地并返回 None，sorted 给新列表。
- 赋值不等于复制；浅拷贝只复制外层。

## 通关检查
- 我能画出 a、b 与列表对象之间的指向。
- 我能独立建立两行互不影响的网格。
- 我能在不修改原列表的情况下筛选与排序。


# 08 字典 集合与嵌套数据

从一串数，走向现实中的一条条记录。

## 学习目标

- 用键组织有名字的字段
- 用字典做计数和分组
- 选择合适容器并处理嵌套结构

### 字典是键到值的映射
`student = {"name": "小林", "score": 85}` 用字段名取值：`student["score"]`。键必须可哈希，常见字符串和整数可以作为键，列表不行。相同键只能保存一个当前值，再赋值会覆盖旧值。

`student["city"]` 在键不存在时抛 KeyError；`student.get("city", "未知")` 返回默认值但不把它写入字典。判断键是否存在可以用 `"city" in student`，字典上的 in 默认检查键，不检查值。

### 遍历方式要明确
`for key in data` 遍历键，`data.values()` 提供值，`data.items()` 提供键值对。常用 `for key, value in data.items():`。现代 Python 字典保留插入顺序，但这不代表按键的大小排序；需要排序就显式调用 sorted。

不要遍历字典时添加或删除键。可以先生成要删除的键列表，再进行删除；或创建新字典。只更新已有键的值是另一回事，但初学仍建议保持流程清楚。

### 计数的核心公式
`counts[word] = counts.get(word, 0) + 1` 的意思：先读旧次数，没有就视作 0，再加 1，最后写回。get 本身不负责更新。这种“读旧值 → 计算 → 写回”的模式与变量累计相同，只是状态按键分开保存。

### 一条记录与多条记录
字典表示一个学生，字典列表表示多个学生：`[{"name":"A","score":80}, ...]`。先用 for 取一个 record，再用 record["score"] 取该记录字段。不要对整个 records 直接写 records["score"]，外层是列表，索引需要整数或切片。

面对复杂结构，先逐层问 type 和 len，再用一个很小的数据例子。很多所谓“AI 数据问题”本质是把列表、字典、字符串层次搞错。

### 集合用于去重和成员判断
`set([1,1,2])` 得到只含 1 与 2 的集合。空集合写 `set()`，`{}` 是空字典。集合元素也需要可哈希；集合不提供索引，迭代顺序不作为程序结果约定。想稳定展示可排序元素，用 sorted。

交集 `a & b` 是两边都有；并集 `a | b` 是任一边有；差集 `a - b` 是只在 a；对称差 `a ^ b` 是只在一边。set 去重不保留你想要的原列表顺序；保序去重可组合 seen 集合与结果列表。

### 怎样选容器
| 需求 | 优先考虑 | 原因 |
|---|---|---|
| 有顺序、可增删的一组数据 | list | 按位置遍历和修改 |
| 固定结构的一组值 | tuple | 明确槽位结构且槽位不可改 |
| 字段名到字段值、词到次数 | dict | 通过键访问 |
| 唯一元素、快速成员判断 | set | 去重与集合运算 |

不要为了显得高级强行选择复杂结构。先画出一条数据，再决定“一条用什么、多条用什么”。

## 示例 08_01 读取与默认值

路径：examples/ch08/ex08_01.py

```python
student = {"name": "小林", "score": 85}
student["score"] = 90
print(student["name"], student["score"])
print(student.get("city", "未知"))
print("city" in student)
```

输出：
```text
小林 90
未知
False
```

解析：get 返回默认值没有修改字典，所以最后仍为 False。

改写：使用 student["city"] 读取，观察 KeyError。

## 示例 08_02 词频统计

路径：examples/ch08/ex08_02.py

```python
counts = {}
for word in "python is fun python is useful".split():
    counts[word] = counts.get(word, 0) + 1
for word, count in counts.items():
    print(word, count)
```

输出：
```text
python 2
is 2
fun 1
useful 1
```

解析：每个词有独立计数器。插入顺序是首次见到这些词的顺序。

改写：加上大小写和逗号，分析当前清洗规则还缺什么。

## 示例 08_03 记录列表

路径：examples/ch08/ex08_03.py

```python
students = [
    {"name": "A", "score": 55},
    {"name": "B", "score": 80},
]
for student in students:
    if student["score"] >= 60:
        print(student["name"])
```

输出：
```text
B
```

解析：外层先取字典，内层再按键取字段。列表与字典各自承担一层组织任务。

改写：给每条记录添加 city，再筛选城市与分数双条件。

## 示例 08_04 集合关系

路径：examples/ch08/ex08_04.py

```python
train_ids = {1, 2, 3}
test_ids = {3, 4}
print(sorted(train_ids & test_ids))
print(sorted(train_ids | test_ids))
print(sorted(train_ids - test_ids))
```

输出：
```text
[3]
[1, 2, 3, 4]
[1, 2]
```

解析：交集发现 ID 3 重复出现在两组数据中。用于训练测试分离时，ID 检查只是第一层，还需考虑同源内容或同一用户泄漏。

改写：让两组完全无交集，观察空交集的输出。

## 示例 08_05 按类别累计

路径：examples/ch08/ex08_05.py

```python
records = [("书", 30), ("餐饮", 20), ("书", 50)]
totals = {}
for category, amount in records:
    totals[category] = totals.get(category, 0) + amount
print(totals)
```

输出：
```text
{'书': 80, '餐饮': 20}
```

解析：先按类别找到对应累计值，再加本次金额。计数和金额累计只在“加 1 还是加 amount”上不同。

改写：改成按类别统计笔数。

## 示例 08_06 保序去重与 seen

路径：examples/ch08/ex08_06.py

```python
seen = set()
unique = []
for word in ["a", "b", "a", "c"]:
    if word not in seen:
        seen.add(word)
        unique.append(word)
print(unique)
```

输出：
```text
['a', 'b', 'c']
```

解析：seen 用于判断见没见过，unique 用于保存顺序。两个结构服务不同需求；不要直接输出集合当成有序结果。

改写：解释为什么 seen.add 后仍需要 unique.append。

## 示例 08_07 AI / Agent：列表保存顺序，字典保存消息字段

路径：examples/ch08/ex08_07.py

```python
messages = [
    {"role": "user", "content": "什么是列表？"},
    {"role": "assistant", "content": "列表保存有顺序的元素。"},
]
messages.append({"role": "user", "content": "给我一个例子。"})
for message in messages:
    print(f"{message['role']}: {message['content']}")
print(len(messages))
```

输出：
```text
user: 什么是列表？
assistant: 列表保存有顺序的元素。
user: 给我一个例子。
3
```

解析：messages 外层列表保存时间顺序；每个内层字典保存一条消息。messages[0] 取得第一条字典，再用 ['content'] 取得正文；for 每次让 message 指向一条字典。这里 role 只是我们约定的字段，assistant 文字是预先写好的，没有模型参与。真实服务的消息格式和角色语义要查对应接口。

改写：只输出 role 为 user 的消息，再统计用户消息的条数。不要删除遍历中的元素；先用 if 选择要处理的记录。

## 练习 08_01 库存更新（基础）

stock = {"apple":3,"pear":2}，苹果补货 5 个，新增 banana 4 个，输出 stock 的 Python 表示。

作答文件：practice/ch08/ex08_01.py

提示：已有键重新赋值，新键直接赋值。

参考答案：
```python
stock = {"apple": 3, "pear": 2}
stock["apple"] += 5
stock["banana"] = 4
print(stock)
```

输出：
```text
{'apple': 8, 'pear': 2, 'banana': 4}
```

解析：+= 在这里先取出整数再加回；字典的键顺序由首次插入决定。

## 练习 08_02 字母次数（场景）

统计 banana 每个字母出现次数，输出字典 {"b":1,"a":3,"n":2} 对应的 Python 表示。

作答文件：practice/ch08/ex08_02.py

提示：遍历字符串，每个字符作为键。

参考答案：
```python
counts = {}
for char in "banana":
    counts[char] = counts.get(char, 0) + 1
print(counts)
```

输出：
```text
{'b': 1, 'a': 3, 'n': 2}
```

解析：首次出现按 0 起步，后续读取已有次数。插入顺序 b、a、n 与出现次数大小无关。

## 练习 08_03 读取可选配置（基础）

config = {"batch_size":16}。读取缺失的 epochs，默认 3，输出 3；再输出字典，证明没有新增键。

作答文件：practice/ch08/ex08_03.py

提示：get 的第二个参数是默认值。

参考答案：
```python
config = {"batch_size": 16}
print(config.get("epochs", 3))
print(config)
```

输出：
```text
3
{'batch_size': 16}
```

解析：默认值只是本次读取结果。如果要存入字典，需要显式赋值或学习 setdefault。

## 练习 08_04 检测重复 ID（场景）

ids = ["s1","s2","s1","s3"]。若有重复输出 True，否则 False。

作答文件：practice/ch08/ex08_04.py

提示：比较原列表长度与集合长度。

参考答案：
```python
ids = ["s1", "s2", "s1", "s3"]
print(len(ids) != len(set(ids)))
```

输出：
```text
True
```

解析：集合合并重复元素，长度变小意味着至少有一个重复。此方法只回答有没有，不能列出具体重复项及次数。

## 练习 08_05 两班共同选课（场景）

a={"python","math"}，b={"python","english"}，输出共同课程的排序列表 ["python"]。

作答文件：practice/ch08/ex08_05.py

提示：求交集后 sorted。

参考答案：
```python
a = {"python", "math"}
b = {"python", "english"}
print(sorted(a & b))
```

输出：
```text
['python']
```

解析：排序是为了稳定展示，不是集合本身有顺序。

## 练习 08_06 按城市分组（挑战）

记录 [{"name":"A","city":"台北"},{"name":"B","city":"高雄"},{"name":"C","city":"台北"}]，构建城市到姓名列表的字典并输出。

作答文件：practice/ch08/ex08_06.py

提示：遇到新城市先创建空列表，再 append。

参考答案：
```python
records = [
    {"name": "A", "city": "台北"},
    {"name": "B", "city": "高雄"},
    {"name": "C", "city": "台北"},
]
groups = {}
for record in records:
    city = record["city"]
    if city not in groups:
        groups[city] = []
    groups[city].append(record["name"])
print(groups)
```

输出：
```text
{'台北': ['A', 'C'], '高雄': ['B']}
```

解析：每个新城市得到自己独立的列表。不要把同一个预建列表反复赋给多个城市，否则它们会共享内容。

## 练习 08_07 按标签统计（场景）

records=[{"label":1},{"label":0},{"label":1}]，输出标签到数量的字典 {1:2,0:1}。

作答文件：practice/ch08/ex08_07.py

提示：先取 record["label"]，再对该键累计。

参考答案：
```python
records = [{"label": 1}, {"label": 0}, {"label": 1}]
counts = {}
for record in records:
    label = record["label"]
    counts[label] = counts.get(label, 0) + 1
print(counts)
```

输出：
```text
{1: 2, 0: 1}
```

解析：这是类别分布的最小统计，有助于发现标签不平衡。JSON 保存这种字典时整数键会变成字符串，读回时不要假定类型不变。

## 练习 08_08 读取嵌套配置（迁移）

config={"training":{"batch_size":8,"epochs":3}}，输出 batch_size 与 epochs 的乘积 24。

作答文件：practice/ch08/ex08_08.py

提示：先取 training 得到内层字典。

参考答案：
```python
config = {"training": {"batch_size": 8, "epochs": 3}}
training = config["training"]
print(training["batch_size"] * training["epochs"])
```

输出：
```text
24
```

解析：逐层命名帮助观察结构。这里乘积只作语法练习，不代表训练步数，真实步数还取决于数据量和批数。

## 练习 08_09 AI / Agent：统计用户消息（迁移）

messages 为 [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}, {"role": "user", "content": "bye"}]。只输出用户消息正文，并在最后输出用户消息数。所有记录都保证有这两个键。

作答文件：practice/ch08/ex08_09.py

提示：count 从 0 开始；只有 role 等于 user 时才打印正文并加 1。

参考答案：
```python
messages = [
    {"role": "user", "content": "hi"},
    {"role": "assistant", "content": "hello"},
    {"role": "user", "content": "bye"},
]
count = 0
for message in messages:
    if message["role"] == "user":
        print(message["content"])
        count += 1
print(count)
```

输出：
```text
hi
bye
2
```

解析：先取 role 决定是否进入分支，再取 content。count 不应在分支外增加，否则会把 assistant 也算进去。空列表没有任何正文输出，最后应输出 0；将所有 role 改为 assistant 也应得到 0。字段缺失的处理留到第 13 章，不能把本题的输入保证推广到任何外部数据。

## 记忆要点
- 一条记录用字典，多条记录常用列表套字典。
- get 读默认值，赋值才写回。
- 集合无索引，稳定展示先 sorted。

## 通关检查
- 我能从学生记录列表中提取所有及格者姓名。
- 我能从零写一个词频统计循环。
- 我能解释 {} 和 set() 的区别。


# 09 函数 参数 返回值与作用域

把会重复的处理步骤变成能独立检查的小工具。

## 学习目标

- 定义并调用函数，写清输入输出约定
- 区分 print、return 和 None
- 理解作用域、参数传递与默认参数陷阱
- 追踪连续调用中的旧值、返回值与接收位置

### 为什么需要函数
同一段“清洗文本、计算平均分、检查范围”的代码如果复制三次，改规则时就要改三处。函数把一个明确任务装进有名字的代码块，调用时传入数据，再得到结果。好函数名通常是动词，例如 clean_text、calculate_total。

基本形式是 `def 函数名(参数):`，缩进部分叫函数体。def 执行时创建函数，并不立即跑函数体；写 `函数名(实际值)` 才是调用。形参是定义中的名字，实参是调用时传入的值。例如 `def double(number)` 里的 number 是形参，`double(5)` 里的 5 是实参。

### 从调用到返回的四步
看到 `answer = add(2, 3)`：先求实参得到 2 和 3；进入函数，把它们分别绑定给 a 和 b；执行函数体并求出 5；return 把 5 交回调用位置，最后 answer 绑定 5。把函数调用想成表达式中的一个可求值步骤，而不只是“跳到另一段代码”。

### print 与 return 必须分清
print 把内容显示到屏幕，便于人看；return 把对象交还给调用者，便于后续程序继续使用。一个只 print 没有 return 的函数，调用结果默认是 None。`print(add(2, 3))` 先由 add 返回值，再让 print 显示它。

执行 return 后，当前函数立即结束，后面的函数体语句不执行。所有需要结果的路径都应有明确返回，否则有些输入会意外得到 None。返回多个值时常用 `return total, count`，实际返回的是一个元组，调用者可以解包。

### 参数的几种写法
位置参数按顺序对应；关键字参数按名字对应。`power(3, exponent=2)` 更容易看出第二项含义。`def power(base, exponent=2)` 给了默认值，调用者可以省略 exponent。普通调用中位置参数放在关键字参数前面，避免给同一个参数赋值两次。

默认值在函数定义时求值，不是每次调用重新创建。不要使用 `items=[]` 作为想要“每次一个新列表”的默认值，改为 `items=None`，进入后 `if items is None: items=[]`。这个检查必须用 is None，不能简单 if not items，否则调用者传入的空列表也会被替换。

### 作用域决定去哪里找名字
函数内部赋值创建的名字通常是局部变量，外面不能直接使用。函数可以读取外层可见名字，但把所有输入写成参数更容易理解和测试。不要一开始就依赖 global 修改全局变量。

如果函数里给 x 赋值，Python 通常把该函数中的 x 当作局部名字，即使你在赋值之前试图读外部 x，也可能得到 UnboundLocalError。改为传参和返回值，比到处加 global 更清楚。

### 传入列表会不会被修改
函数参数获得的是对象引用的绑定。函数内执行 `items.append(x)` 会修改调用者共享的列表；执行 `items = []` 只改变局部参数的绑定，不会替换调用者的名字。不能简单说“Python 都是值传递”或“所有赋值都修改外部”。要看是否修改了同一个对象。

若希望函数不改输入，可先建立新列表再处理。题目应声明是否允许修改输入，测试也应检查这一点。

### 写函数前的输入输出约定
先用一句话描述职责；列出输入类型、合法范围、返回值；决定空输入怎么办；给出至少三个样例。平均分函数可约定空列表返回 None，也可约定抛 ValueError，两种都可能合理，关键在于调用者知道并处理。不要让约定靠读函数内部猜。

本章开始的部分习题附带额外函数测试。你除了打印展示结果，还要按指定函数名与参数实现功能；自检器会用其他输入调用函数，检查边界和是否修改了输入。

### 精讲：把一次函数调用拆成五个时刻
读示例 09_01 时，按“实参 → 形参 → 函数体 → 返回值 → 接收位置”追踪。对于 `answer = add(2, 3)`：

| 时刻 | 正在发生什么 | 目前能知道什么 |
| --- | --- | --- |
| 1 | 计算调用括号里的 2 和 3 | 准备两个实参 |
| 2 | 进入 add，建立局部名字 a 和 b | a 是 2，b 是 3 |
| 3 | 计算 a + b | 得到 5，但外面的赋值尚未完成 |
| 4 | 执行 return 5 | 本次调用结束，add(2, 3) 的值是 5 |
| 5 | 调用位置继续赋值 | answer 现在是 5 |

`def` 出现时先定义函数，函数体要等调用才运行。形参是函数定义里的名字，实参是调用时提供的值。第二次调用会有新的一次局部执行环境。

### 精讲：print 与 return 分别通向哪里
可以把 `print` 看作显示窗口，把 `return` 看作把结果交回调用位置。函数可以一边打印、一边返回，两件事互不替代。没有执行到带值的 return 时，正常结束的函数返回 None；None 是一个值，表示这里没有提供具体结果，不是字符串 `"None"`。

先运行示例 09_02，只把函数体中的 `print(x * 2)` 改成 `return x * 2`，预测输出会从几行变为几行。外面的 `print(answer)` 保持原样，这样才能单独观察返回值的变化。

**为什么 AI 工具要 return？** 后面的程序需要拿到结果继续处理。若工具只打印“找到 3 条记录”，调用者拿到的可能是 None，不能把它当成列表遍历。调试日志可以 print；供下游使用的数据要设计明确的返回结构。

### 精讲：局部名字变化与共享对象变化
示例 09_05 中 `value = value + 10` 让局部名字 value 指向新整数，外面的 score 仍指向旧整数。示例 09_06 中 `items.append(...)` 修改双方指向的同一个列表。两者都符合“参数先绑定到传入的对象”，不需要死记“列表传引用、整数传值”这种容易误导的口诀。

默认列表陷阱的检查方法：连续调用两次，再观察第二次是否带上第一次的记录。修复时用 `items is None` 判断有没有提供列表；不要用 `if not items`，否则调用者明确传入的空列表也会被替换。这在需要修改调用者列表时会改变约定。

**闭卷小问：** 函数中有三个 return，一次调用最多执行几个？答案是一个；执行到第一个 return 后，本次调用就结束。不同输入可以走到不同的 return。

### 精讲：为什么函数调用了三次，外面的计数仍是 0
假设 next_round(current) 返回 current + 1。写 `next_round(rounds)` 只计算出新值，没有把它存回 rounds；写 `rounds = next_round(rounds)` 才会更新调用位置的名字。记住这条完整路线：**旧值作为实参进入 → 函数算出新值 → return 交回 → 调用者赋值保存**。

| 写法 | 三次调用各自收到的 current | 三次返回值 | 最后的 rounds |
| --- | --- | --- | --- |
| 只写 next_round(rounds) | 0、0、0 | 1、1、1，但没保存 | 0 |
| 写 rounds = next_round(rounds) | 0、1、2 | 1、2、3，逐次保存 | 3 |

这两行写法可以放进相同的 for 循环，分别运行验证。每次普通函数调用都有自己的局部名字；整数值不会因为函数里给形参重新赋值，就自动改变调用者的名字。第 09 章新增示例 09_10 让你同时观察两个调用方式。

### 实操：把任务预算写成明确的输入与返回值
想象一个离线 Agent 练习，每做一步扣掉一部分预算。先定义 remaining_budget(remaining, used) 只负责计算“还剩多少”，再由外面决定是否保存新预算、打印或继续执行。这样可以分别手算单步结果和连续多步的结果。

给自己画三列：调用前余额、传入的 used、返回的新余额。先从 60 扣 15，再从新余额扣 50；按本题约定余额最低为 0，应得到 45、0。若两次都拿原始 60 作为输入，第二次会得到 10，那是调用方的数据流写错了，未必是函数公式写错。

**闭卷检查：** return 是否等于“修改调用者变量”？不等于。修改列表对象、重新绑定局部名字、返回一个值，是三个需要分别观察的动作。

### 作用域报错的下一站
在函数里直接写 `rounds += 1`，却没有把 rounds 设成参数或先在本次调用中赋值，会涉及 UnboundLocalError。第 13 章的示例 13_07 提供可运行的错误与修复对照；它会用该章学过的 try/except 捕获错误，便于继续观察修复结果。此处先练好参数与返回值，不必提前记异常处理语法。

## 示例 09_01 定义与调用

路径：examples/ch09/ex09_01.py

```python
def add(a, b):
    return a + b

result = add(2, 3)
print(result)
print(add(10, 20) * 2)
```

输出：
```text
5
60
```

解析：第一处先计算 2+3 再给 result。第二处先得到 30，再在调用处乘 2。函数体不依赖特定数字，可用于不同输入。

改写：改写成 subtract(a,b)，说明参数顺序为何重要。

## 示例 09_02 打印不等于返回

路径：examples/ch09/ex09_02.py

```python
def show_double(number):
    print(number * 2)

result = show_double(3)
print(result)
```

输出：
```text
6
None
```

解析：函数先显示 6；没有 return，所以调用表达式的值是 None，第二个 print 显示它。

改写：改成 return number * 2，再由外部显示结果。

## 示例 09_03 提前返回处理空输入

路径：examples/ch09/ex09_03.py

```python
def mean(values):
    if not values:
        return None
    return sum(values) / len(values)

print(mean([80, 100]))
print(mean([]))
```

输出：
```text
90.0
None
```

解析：sum 是内置求和函数。空输入路径提前返回，后面的除法不会执行；调用者必须理解 None 表示没有平均值。

改写：将空输入约定改成抛 ValueError，并在第 13 章练习捕获。

## 示例 09_04 默认参数与关键字

路径：examples/ch09/ex09_04.py

```python
def greet(name, prefix="你好"):
    return f"{prefix}，{name}"

print(greet("小林"))
print(greet(prefix="晚上好", name="小陈"))
```

输出：
```text
你好，小林
晚上好，小陈
```

解析：第一调用省略 prefix，用默认值。第二调用按名字对应，书写次序不影响参数绑定。

改写：调用 greet("小林", name="小陈")，观察重复赋值错误。

## 示例 09_05 作用域与返回

路径：examples/ch09/ex09_05.py

```python
score = 60

def improve(value):
    value = value + 10
    return value

new_score = improve(score)
print(score, new_score)
```

输出：
```text
60 70
```

解析：局部 value 重新绑定到 70，不会把外部 score 改成 70。想更新外部名字可显式写 score = improve(score)。

改写：删除 return，观察 new_score。

## 示例 09_06 修改对象与改绑参数

路径：examples/ch09/ex09_06.py

```python
def add_item(items):
    items.append("B")

def replace_items(items):
    items = ["C"]

tasks = ["A"]
add_item(tasks)
replace_items(tasks)
print(tasks)
```

输出：
```text
['A', 'B']
```

解析：add_item 对共享列表追加 B；replace_items 只把局部 items 改绑到新列表。函数返回后这个局部绑定消失。

改写：让 replace_items 返回新列表，并由外面 tasks = replace_items(tasks)。

## 示例 09_07 默认列表陷阱与修复

路径：examples/ch09/ex09_07.py

```python
def bad_add(value, items=[]):
    items.append(value)
    return items

print(bad_add("A"))
print(bad_add("B"))

def good_add(value, items=None):
    if items is None:
        items = []
    items.append(value)
    return items

print(good_add("A"))
print(good_add("B"))
```

输出：
```text
['A']
['A', 'B']
['A']
['B']
```

解析：bad_add 的默认列表在定义时创建一次，多次调用共享。good_add 在每次省略 items 时创建新列表；若显式传入列表，仍会修改那个列表。

改写：显式传入 items=[]，验证 good_add 保留对该列表的修改。

## 示例 09_08 多个结果与文档字符串

路径：examples/ch09/ex09_08.py

```python
def summarize(values):
    """返回数值列表的总和与项数。"""
    return sum(values), len(values)

total, count = summarize([2, 4, 6])
print(total, count)
```

输出：
```text
12 3
```

解析：函数体第一条字符串常作为文档字符串，可用 help(summarize) 查看。return 的逗号把两个值组成元组。

改写：再返回最大值，并为无元素情况写约定。

## 示例 09_09 AI / Agent：工具函数把结果交回调用者

路径：examples/ch09/ex09_09.py

```python
def count_words(text):
    words = text.split()
    return {"count": len(words), "empty": len(words) == 0}

result = count_words("  learn Python   today ")
print(result["count"])
print(result["empty"])
print(count_words("")["count"])
```

输出：
```text
3
False
0
```

解析：输入字符串进入 text；split 得到三个按空白分开的词；函数构造字典并 return；外层 result 接收字典后取字段。函数体不打印，所以可以重复用于统计、测试或保存文件。它要求 text 是字符串；第 13 章再学习怎样验证来自外部的数据。按空白分词只适合此处演示，既不是通用中文分词器，也不是模型 tokenizer。

改写：把 return 改成 print 字典，再运行，观察 result 的值为什么变成 None。修复后传入纯空白字符串，预测 count 和 empty。

## 示例 09_10 AI / Agent：返回的新轮数要由调用者接住

路径：examples/ch09/ex09_10.py

```python
def next_round(current):
    return current + 1

rounds = 0
for _ in range(3):
    next_round(rounds)
print(f"未接收：{rounds}")

for _ in range(3):
    rounds = next_round(rounds)
    print(f"保存后：{rounds}")
```

输出：
```text
未接收：0
保存后：1
保存后：2
保存后：3
```

解析：第一个循环每次都传入 0，并得到 1，但没有变量接收这三个返回值。第二个循环的右边先计算 next_round(rounds)，然后左边 rounds 才绑定新值；所以下一轮能接着上次的结果计算。下划线只是一个变量名，表示本例不需要循环索引。这是离线计数实验，没有执行真实 Agent 或模型调用。

改写：把初始 rounds 改为 5，先写出全部四行输出再运行。随后只删掉第二个循环中的赋值，解释为什么三次打印都不再增长。

## 练习 09_01 温度转换函数（基础）

实现 to_fahrenheit(celsius)，返回摄氏转华氏的值。展示 print(to_fahrenheit(25)) 输出 77.0。

作答文件：practice/ch09/ex09_01.py

提示：函数负责返回，外面负责显示。

参考答案：
```python
def to_fahrenheit(celsius):
    return celsius * 9 / 5 + 32

print(to_fahrenheit(25))
```

输出：
```text
77.0
```

解析：把第 02 章公式放进函数，调用者可以多次使用不同输入。

## 练习 09_02 安全平均值（场景）

实现 mean(values)，非空返回平均值，空列表返回 None，不修改输入。依次打印 mean([60,90]) 与 mean([])。

作答文件：practice/ch09/ex09_02.py

提示：先检查空，再除以长度。

参考答案：
```python
def mean(values):
    if not values:
        return None
    return sum(values) / len(values)

print(mean([60, 90]))
print(mean([]))
```

输出：
```text
75.0
None
```

解析：空平均没有本题定义下的数值。用 None 表达缺失，而非用 0 冒充。

## 练习 09_03 限制范围（迁移）

实现 clamp(value, low, high)，假定 low<=high：低于 low 返回 low，高于 high 返回 high，否则原样返回。打印 clamp(120,0,100)。

作答文件：practice/ch09/ex09_03.py

提示：依次判断上下界。

参考答案：
```python
def clamp(value, low, high):
    if value < low:
        return low
    if value > high:
        return high
    return value

print(clamp(120, 0, 100))
```

输出：
```text
100
```

解析：最后 return 覆盖区间内部与两个端点。别漏掉“都不越界”这条路径。

## 练习 09_04 清洗一组姓名（场景）

实现 clean_names(names)，去每项两端空白，丢弃空结果，保留其余顺序且不修改输入。打印对 [" A ","  ","B"] 的结果。

作答文件：practice/ch09/ex09_04.py

提示：每轮先 strip，再判断 clean 是否非空。

参考答案：
```python
def clean_names(names):
    result = []
    for name in names:
        clean = name.strip()
        if clean:
            result.append(clean)
    return result

print(clean_names([" A ", "  ", "B"]))
```

输出：
```text
['A', 'B']
```

解析：结果建立在新列表中。空白字符串经 strip 变空后再过滤，否则会保留无内容项。

## 练习 09_05 统计通过率（挑战）

实现 pass_rate(scores, threshold=60)，返回通过人数/总人数，空列表返回 None。打印 pass_rate([50,60,90])，用 .1% 显示为 66.7%。

作答文件：practice/ch09/ex09_05.py

提示：把计数和平均逻辑结合；结果是 0 到 1 的比例。

参考答案：
```python
def pass_rate(scores, threshold=60):
    if not scores:
        return None
    count = 0
    for score in scores:
        if score >= threshold:
            count += 1
    return count / len(scores)

print(f"{pass_rate([50, 60, 90]):.1%}")
```

输出：
```text
66.7%
```

解析：threshold 可以通过关键字修改。显示百分比放在外部，函数返回数值便于后续运算。

## 练习 09_06 无共享默认值（纠错）

实现 append_new(value, items=None)，未传 items 时每次建立独立列表；传入列表时追加到该列表。依次打印 append_new("A") 与 append_new("B")。

作答文件：practice/ch09/ex09_06.py

提示：必须检查 is None。

参考答案：
```python
def append_new(value, items=None):
    if items is None:
        items = []
    items.append(value)
    return items

print(append_new("A"))
print(append_new("B"))
```

输出：
```text
['A']
['B']
```

解析：默认值只用不可变的 None 作标记。显式提供空列表时仍保留该对象，因此测试也检查对象身份。

## 练习 09_07 是否回文（迁移）

实现 is_palindrome(text)，按 lower 后 split/join 去除全部空白，再比较正反文本。打印对 "Never odd or even" 的结果。标点本题不删除。

作答文件：practice/ch09/ex09_07.py

提示：用 "".join(...) 去空白，再与 [::-1] 比较。

参考答案：
```python
def is_palindrome(text):
    cleaned = "".join(text.lower().split())
    return cleaned == cleaned[::-1]

print(is_palindrome("Never odd or even"))
```

输出：
```text
True
```

解析：先写清规范化规则。按本题定义空字符串也满足与逆序相等，不同需求可以另作约定。

## 练习 09_08 优惠后的新列表（场景）

实现 discounted(prices, rate=0.9)，返回每个价格乘 rate 的新列表，不改输入。打印 [100,200] 的默认结果。

作答文件：practice/ch09/ex09_08.py

提示：用一个新列表收集各价格的折后值。

参考答案：
```python
def discounted(prices, rate=0.9):
    result = []
    for price in prices:
        result.append(price * rate)
    return result

print(discounted([100, 200]))
```

输出：
```text
[90.0, 180.0]
```

解析：rate=0.9 表示付原价的 90%，不是减去 90%。此题使用浮点练习，精确金额仍建议整数分或 Decimal。

## 练习 09_09 AI / Agent：先约定工具的输入和返回结构（场景）

实现 summarize_text(text)，输入保证是字符串。先 strip；返回字典，字段 chars 为清理后的长度、preview 为前 4 个字符、empty 表示是否为空。函数不能打印。用 "  Python  " 调用，依次打印三个字段。

作答文件：practice/ch09/ex09_09.py

提示：先保存 cleaned，再构造一个字典 return。切片 [:4] 遇到不足 4 个字符时不会越界报错。

参考答案：
```python
def summarize_text(text):
    cleaned = text.strip()
    return {
        "chars": len(cleaned),
        "preview": cleaned[:4],
        "empty": cleaned == "",
    }

result = summarize_text("  Python  ")
print(result["chars"])
print(result["preview"])
print(result["empty"])
```

输出：
```text
6
Pyth
False
```

解析：清理后的 Python 长度为 6，前四字符是 Pyth。把计算与输出分开后，调用者可以只显示 preview，或把整个结果写成 JSON。边界：空白返回 chars=0、preview=''、empty=True；短字符串原样作为预览。本题额外检查空白、短字符串和函数没有打印的约定。

## 练习 09_10 AI / Agent：把剩余预算逐步传下去（迁移）

实现 remaining_budget(remaining, used)，两个输入保证为非负整数，返回扣减后的余额，最低为 0；函数不得打印或依赖全局余额。先设 budget=60，接收扣 15 的结果并打印；再接收扣 50 的结果并打印。

作答文件：practice/ch09/ex09_10.py

提示：函数先计算 remaining - used，负数返回 0，否则返回计算值。调用时要把返回值赋给 budget，再进行下一次调用。

参考答案：
```python
def remaining_budget(remaining, used):
    result = remaining - used
    if result < 0:
        return 0
    return result

budget = 60
budget = remaining_budget(budget, 15)
print(budget)
budget = remaining_budget(budget, 50)
print(budget)
```

输出：
```text
45
0
```

解析：第一步 60-15 得到 45，第二步把 45 传回函数，45-50 小于 0，按约定返回 0。若没有保存第一次的返回值，就会错误地从 60 再扣 50。本题的预算是人为设定的整数额度，不是 API 账单或模型 token 数。边界：扣 0 不变、恰好扣完为 0、超过余额也为 0；重复输入应得到相同结果。

## 记忆要点
- def 先定义，括号才调用。
- print 给人看，return 给程序用。
- 参数获得对象绑定；修改对象与重新绑定不同。
- 默认列表用 None 创建，别让多次调用共用一个。

## 通关检查
- 我能让函数返回结果后继续参与乘法。
- 我能解释为什么显示了 5，外面却收到 None。
- 我能写一个不修改输入列表的清洗函数并测空输入。
- 我能解释丢弃返回值与保存返回值时，外部计数为什么不同。


# 10 推导式 排序与常见组合

先会写清楚的循环，再读懂更紧凑的写法。

## 学习目标

- 将简单循环转成推导式
- 用 key 指定排序依据
- 使用 enumerate、zip、any 与 all

### 列表推导式按“结果 遍历 条件”读
`[x * x for x in numbers if x > 0]`：遍历 numbers；只保留大于 0 的 x；对每个保留项计算 x*x；收集到新列表。写法里结果在前，执行时仍先从遍历中拿到 x。

初学先展开成四行：result=[]；for；if；append。展开后理解了再缩写。列表推导式会一次生成完整列表；并非更省内存。多个嵌套 for 可以表达展开矩阵，但可读性差时普通循环更好。

### 两种 if 写法的区别
`[x for x in nums if x > 0]` 是筛选，会减少元素数量；`[x if x > 0 else 0 for x in nums]` 是逐项选择结果，每个输入都有一个输出。前者的 if 在 for 后；后者的 if/else 属于前面的结果表达式。

字典推导式如 `{word: len(word) for word in words}`；集合推导式如 `{word.lower() for word in words}`。字典若产生重复键，后面的值覆盖前面。

### 排序的 key 是“拿什么来比”
`sorted(records, key=...)` 默认会用 key 返回的值排序。可以先定义普通函数 `def get_score(record): return record["score"]`。`lambda record: record["score"]` 是只含一个表达式的小函数，不是必须掌握的炫技写法。

多条件排序可返回元组，例如 `key=lambda r: (-r["score"], r["name"])` 表示分数降序，同分姓名升序。先比较第一项，相同再看第二项。Python 排序是稳定的，键相等时保留输入相对顺序。

### zip 把多个序列并排配对
`zip(names, scores)` 每次给一个姓名和一个分数。默认遇到较短序列结束，较长序列余下元素会被忽略。需要一一对应时，先检查长度，或在本教材支持的 Python 3.10+ 中使用 `zip(..., strict=True)`，不等长会报 ValueError。

enumerate 给单个序列配索引；zip 把多个序列配成行。zip 和 enumerate 返回可迭代工具对象，要观察所有结果可 list(...)，但遍历时不必先转列表。

### any 与 all
`any(条件序列)` 问是否至少一个为真；`all(条件序列)` 问是否全部为真。空输入时 any 返回 False，all 返回 True。这是逻辑约定，不能用 all([]) 推断“有数据而且合格”，业务上还要检查非空。

### 先保证正确，再追求简洁
一行代码不一定更容易维护。清洗文本、解析数字、记录错误同时发生时，普通 for 更清楚。函数职责明确、变量名直白、测试覆盖边界，通常比把所有代码缩成一行重要。

## 示例 10_01 循环与推导式等价

路径：examples/ch10/ex10_01.py

```python
numbers = [-2, 0, 3, 4]
result = []
for number in numbers:
    if number > 0:
        result.append(number ** 2)
print(result)
print([number ** 2 for number in numbers if number > 0])
```

输出：
```text
[9, 16]
[9, 16]
```

解析：两种写法都先筛正数，再平方。不是对所有数平方后再筛正数，否则 -2 也会留下。

改写：写一个只保留长度至少 3 的单词的推导式。

## 示例 10_02 过滤与替换

路径：examples/ch10/ex10_02.py

```python
numbers = [-2, 0, 3]
print([x for x in numbers if x > 0])
print([x if x > 0 else 0 for x in numbers])
```

输出：
```text
[3]
[0, 0, 3]
```

解析：第一行长度变为 1；第二行长度仍为 3，负数与零都按规则映射到 0。

改写：把替换值改成 None，说明缺失标记和数值零的区别。

## 示例 10_03 按记录字段排序

路径：examples/ch10/ex10_03.py

```python
records = [
    {"name": "B", "score": 90},
    {"name": "A", "score": 90},
    {"name": "C", "score": 80},
]
ordered = sorted(records, key=lambda row: (-row["score"], row["name"]))
print([row["name"] for row in ordered])
```

输出：
```text
['A', 'B', 'C']
```

解析：分数取负实现第一项降序，姓名保持升序。同为 90 时 A 排在 B 前。

改写：改成分数升序、同分姓名升序。

## 示例 10_04 配对与序号

路径：examples/ch10/ex10_04.py

```python
names = ["A", "B"]
scores = [80, 90]
for index, (name, score) in enumerate(zip(names, scores, strict=True), start=1):
    print(index, name, score)
```

输出：
```text
1 A 80
2 B 90
```

解析：zip 先产生 (name,score) 对，enumerate 再在外面配序号。左侧嵌套解包与右侧结构匹配。

改写：给 scores 多加一个元素，观察 strict 的错误。

## 示例 10_05 全部与至少一个

路径：examples/ch10/ex10_05.py

```python
scores = [50, 80, 90]
print(any(score >= 60 for score in scores))
print(all(score >= 60 for score in scores))
print(any([]), all([]))
```

输出：
```text
True
False
False True
```

解析：这里括号内是生成器表达式，第 15 章详细解释。any 找到真就可停止，all 找到假就可停止。

改写：把 scores 换成空列表，补上非空判断表达“所有已有学生均通过且确实有学生”。

## 示例 10_06 字典推导式

路径：examples/ch10/ex10_06.py

```python
words = ["cat", "python", "AI"]
lengths = {word: len(word) for word in words}
print(lengths)
```

输出：
```text
{'cat': 3, 'python': 6, 'AI': 2}
```

解析：每个输入生成一个键值对。重复单词会覆盖同一键，因此结果不一定与输入长度相等。

改写：用 lower 生成集合，给重复大小写单词去重。

## 练习 10_01 平方正数（基础）

实现 positive_squares(values)，返回正数的平方列表，保持顺序；打印对 [-2,0,3] 的结果。

作答文件：practice/ch10/ex10_01.py

提示：只保留 >0 的元素。

参考答案：
```python
def positive_squares(values):
    return [value ** 2 for value in values if value > 0]

print(positive_squares([-2, 0, 3]))
```

输出：
```text
[9]
```

解析：先筛再算；负数的平方虽为正，但原输入不符合条件。

## 练习 10_02 单词按长度排序（场景）

实现 sort_words(words)，长度升序，同长度字母升序，不改输入。打印对 ["dog","a","cat","python"] 的结果。

作答文件：practice/ch10/ex10_02.py

提示：key 返回 (len(word), word)。

参考答案：
```python
def sort_words(words):
    return sorted(words, key=lambda word: (len(word), word))

print(sort_words(["dog", "a", "cat", "python"]))
```

输出：
```text
['a', 'cat', 'dog', 'python']
```

解析：长度先决定分组，字母顺序决定同长词的先后。

## 练习 10_03 负值截为零（迁移）

实现 replace_negative(values)，负数变 0，其他数不变。打印对 [-2,0,5] 的结果。

作答文件：practice/ch10/ex10_03.py

提示：这里是替换，不是过滤。

参考答案：
```python
def replace_negative(values):
    return [0 if value < 0 else value for value in values]

print(replace_negative([-2, 0, 5]))
```

输出：
```text
[0, 0, 5]
```

解析：输入输出长度相等。筛掉负数会改变位置对应关系，可能损坏样本与标签的配对。

## 练习 10_04 建立姓名成绩映射（场景）

实现 make_scores(names, scores)，同长度且姓名唯一时返回字典；长度不同抛 ValueError。打印 ["A","B"] 与 [80,90] 的结果。

作答文件：practice/ch10/ex10_04.py

提示：zip 使用 strict=True。

参考答案：
```python
def make_scores(names, scores):
    return dict(zip(names, scores, strict=True))

print(make_scores(["A", "B"], [80, 90]))
```

输出：
```text
{'A': 80, 'B': 90}
```

解析：strict 捕获长度不一致。姓名唯一是本题前提；若重复需要另行确定覆盖、报错或分组的业务规则。

## 练习 10_05 所有项均有效（推理）

实现 all_positive(values)，必须非空且每个数字大于 0 才返回 True。依次打印对 [1,2] 和 [] 的结果。

作答文件：practice/ch10/ex10_05.py

提示：bool(values) 与 all 组合。

参考答案：
```python
def all_positive(values):
    return bool(values) and all(value > 0 for value in values)

print(all_positive([1, 2]))
print(all_positive([]))
```

输出：
```text
True
False
```

解析：单独 all([]) 是 True，不符合本题业务约定。显式的非空条件解决这一差异。

## 练习 10_06 展开二维列表（挑战）

实现 flatten(rows)，按行展开一层二维列表。打印 [[1,2],[],[3]] 的结果 [1,2,3]。

作答文件：practice/ch10/ex10_06.py

提示：先 for row，再 for value in row。

参考答案：
```python
def flatten(rows):
    return [value for row in rows for value in row]

print(flatten([[1, 2], [], [3]]))
```

输出：
```text
[1, 2, 3]
```

解析：多个 for 的顺序与普通嵌套循环一致。此函数只展开一层，不递归展开任意深度结构。

## 记忆要点
- 推导式先拿元素，再过滤，再计算结果。
- key 返回比较依据，不是返回排序后的对象。
- zip 默认以短的为准；严格对应要检查。

## 通关检查
- 我能把一个推导式展开成普通 for。
- 我能给记录按分数和姓名排序。
- 我能解释 zip 不等长时发生什么。


# 11 模块 标准库与环境

学会组织自己的代码，也能读懂别人项目的启动方式。

## 学习目标

- 理解 import 和模块边界
- 会使用常见标准库
- 知道解释器、pip 与虚拟环境的对应关系

### 模块就是组织代码的文件
一个 `.py` 文件可以作为模块。`import math` 导入模块，然后 `math.sqrt(9)` 使用其中的函数。`from math import sqrt` 直接导入名字，调用时写 sqrt(9)。前者来源清晰，后者更短；避免 `from module import *`，它会让名字来源难以追踪。

Python 首次导入模块时会执行模块顶层代码，然后通常缓存模块。因此不要在工具模块顶层直接开启交互输入、训练过程或大量文件写入。函数定义放顶层可以，真正运行的入口放在 main 保护下。

### 入口保护是什么意思
`if __name__ == "__main__":` 判断这个文件是否作为入口直接运行。如果直接 `python app.py`，条件为真；如果另一个文件 `import app`，条件通常为假。常见结构是定义 main()，然后在这条 if 中调用 main()。

这让同一个文件既可直接运行，也可被测试或其他文件导入复用。入口保护不是防止别人使用代码的安全机制，它只是运行方式判断。

### 两个文件怎样合作
建立同一文件夹中的 `helpers.py`，定义 `def double(x): return x*2`；另一个 `main.py` 写 `from helpers import double`，再调用它。从该文件夹启动 main.py。不要把你的文件命名为 `random.py`、`json.py`、`csv.py` 或 `math.py`，否则可能遮住标准库并造成看起来奇怪的导入错误。

模块搜索与解释器路径有关。发生 ModuleNotFoundError 时，先检查名称、当前使用的解释器和项目结构，不要反复随机安装。`import sys; print(sys.executable)` 可显示当前解释器路径。

### 标准库与第三方包
标准库随正常的 Python 安装提供，例如 math、random、pathlib、json、csv、collections、datetime、statistics、unittest。第三方包需要额外安装，例如 NumPy 和 PyTorch。本教材第一阶段只用标准库，你无需联网安装这些库才能完成作业。

`pip` 是安装 Python 包的工具。优先 `python -m pip ...`，意思是让“这个 Python”运行 pip，减少包装到另一个解释器里的问题。包名和导入名不一定相同，要看包的官方文档。

### 虚拟环境为什么有用
不同项目可能需要不同版本依赖。venv 为每个项目建立独立的解释器入口和包目录。它不是虚拟机，也不需要给每个练习建一个环境；整个学习包用一个即可。第一阶段没有第三方包，虚拟环境是操作练习，不是运行基础示例的硬性条件。

在学习包根目录打开终端，Windows 执行：

```terminal
py -m venv .venv
.\.venv\Scripts\python.exe --version
.\.venv\Scripts\python.exe examples/ch01/ex01_01.py
.\.venv\Scripts\python.exe -m pip --version
```

Mac 执行：

```terminal
python3 -m venv .venv
./.venv/bin/python --version
./.venv/bin/python examples/ch01/ex01_01.py
./.venv/bin/python -m pip --version
```

上面使用精确路径，无需先激活。可选激活方式：Windows PowerShell 为 `.\.venv\Scripts\Activate.ps1`，Mac 为 `source .venv/bin/activate`，激活后可用 `python`，退出用 `deactivate`。若 PowerShell 阻止激活脚本，直接使用上面的解释器完整路径即可，不必为了课程放宽系统执行策略。

换电脑时复制源代码和依赖清单，**重新创建 .venv，不要搬运旧 .venv**。不同操作系统路径和二进制依赖不通用。后续有第三方依赖时，可在旧环境用 `python -m pip freeze > requirements.txt` 保存安装快照，在新环境用 `python -m pip install -r requirements.txt` 安装；跨系统或新版本仍可能需要调整。当前教材无需安装第三方依赖。

### 随机数与实验记录
`random.Random(42)` 建立局部随机数生成器，可帮助在相同环境与调用顺序下复现实验。不要把固定 seed 理解为跨所有库、硬件、版本的绝对一致保证。后续学习 PyTorch 时还需要记录版本、硬件和确定性设置。

环境操作依据 [Python Packaging 官方指南](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/)；具体安装选项变化时请以官方页面为准。

## 示例 11_01 数学工具

路径：examples/ch11/ex11_01.py

```python
import math
print(math.sqrt(81))
print(math.ceil(2.1))
print(math.floor(-2.1))
print(math.isclose(0.1 + 0.2, 0.3))
```

输出：
```text
9.0
3
-3
True
```

解析：ceil 向上取整，floor 向下取整，isclose 判断浮点近似相等。金融等精确十进制需求还需更合适的数据表示。

改写：预测 floor(2.9) 与 ceil(-2.9)。

## 示例 11_02 Counter 计数

路径：examples/ch11/ex11_02.py

```python
from collections import Counter
counts = Counter("a b a c b a".split())
print(counts["a"])
print(counts["missing"])
print(counts.most_common(2))
```

输出：
```text
3
0
[('a', 3), ('b', 2)]
```

解析：Counter 封装了第 08 章的常见计数逻辑。先理解手写版本，再用标准库减少重复。

改写：用 Counter 统计一段英文文本，先统一大小写。

## 示例 11_03 日期间隔

路径：examples/ch11/ex11_03.py

```python
from datetime import date
start = date(2026, 1, 1)
end = date(2026, 1, 8)
print((end - start).days)
```

输出：
```text
7
```

解析：这里使用固定日期而非“今天”，确保你在不同日期运行也得到相同输出。结果表示间隔天数，不把两端各算一次。

改写：计算同一天之间的间隔。

## 示例 11_04 局部随机性

路径：examples/ch11/ex11_04.py

```python
import random
first = random.Random(42)
second = random.Random(42)
a = [first.random() for _ in range(3)]
b = [second.random() for _ in range(3)]
print(a == b)
print(all(0 <= value < 1 for value in a))
```

输出：
```text
True
True
```

解析：两个生成器在当前环境里使用相同 seed 和调用序列，得到一致序列。random() 返回 [0,1) 的值。

改写：只让一个生成器先多调用一次，观察序列是否仍相同。

## 示例 11_05 可导入的入口

路径：examples/ch11/ex11_05.py

```python
def double(value):
    return value * 2

def main():
    print(double(5))

if __name__ == "__main__":
    main()
```

输出：
```text
10
```

解析：直接运行时 main 被调用；导入时只定义函数，不执行 main。综合项目都会使用这个结构。

改写：在同目录新建文件导入这个示例模块并调用 double。

## 练习 11_01 均值与中位数（场景）

用 statistics 模块计算 [1,2,100] 的 mean 和 median，分别输出并把均值格式化为两位小数。

作答文件：practice/ch11/ex11_01.py

提示：中位数是排序后的中间值，不等于平均值。

参考答案：
```python
import statistics
values = [1, 2, 100]
print(f"{statistics.mean(values):.2f}")
print(statistics.median(values))
```

输出：
```text
34.33
2
```

解析：异常大值显著影响均值，中位数在这里仍为 2。这里只练库函数，不作完整统计推断。

## 练习 11_02 词频最多者（基础）

用 Counter 统计 "red blue red green red blue"，输出 most_common(1) 的结果。

作答文件：practice/ch11/ex11_02.py

提示：先 split。

参考答案：
```python
from collections import Counter
counts = Counter("red blue red green red blue".split())
print(counts.most_common(1))
```

输出：
```text
[('red', 3)]
```

解析：most_common 返回列表，其中元素是词和次数组成的元组，即使只取一个也是列表。

## 练习 11_03 向上取整批次数（迁移）

23 条数据，每批最多 8 条，用 math.ceil 输出需要几批：3。

作答文件：practice/ch11/ex11_03.py

提示：先做除法再向上取整。

参考答案：
```python
import math
print(math.ceil(23 / 8))
```

输出：
```text
3
```

解析：前两批各 8 条，最后 7 条。对大整数计数可优先使用整数公式避免浮点精度问题。

## 练习 11_04 十进制精确相加（拓展）

用 decimal.Decimal 从字符串构造 0.1 和 0.2，相加并输出 0.3。

作答文件：practice/ch11/ex11_04.py

提示：从字符串构造，而非先构造二进制 float。

参考答案：
```python
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2"))
```

输出：
```text
0.3
```

解析：Decimal(0.1) 会带入浮点近似值。先从精确文本表示构造，才符合此例目的。

## 练习 11_05 复现实验打乱（场景）

列表 range(5)，分别复制两份并用两个 random.Random(7) 打乱，输出两份是否相同 True，再输出原列表。

作答文件：practice/ch11/ex11_05.py

提示：shuffle 原地修改，所以先 copy。

参考答案：
```python
import random
original = list(range(5))
a = original.copy()
b = original.copy()
random.Random(7).shuffle(a)
random.Random(7).shuffle(b)
print(a == b)
print(original)
```

输出：
```text
True
[0, 1, 2, 3, 4]
```

解析：不要写 a = rng.shuffle(a)，shuffle 返回 None。复制保护原始顺序。

## 记忆要点
- import 找工具，模块名避免撞上标准库。
- 入口保护让文件可运行也可导入。
- 用哪个 Python，就用哪个 Python -m pip。
- 换电脑重建环境，源代码和数据一起带走。

## 通关检查
- 我能解释 import 为什么可能执行顶层语句。
- 我能创建虚拟环境并用其中解释器运行文件。
- 我能定位当前解释器，而不是只看编辑器按钮。


# 12 路径 文件 CSV 与 JSON

让程序的结果在关闭窗口后仍然存在。

## 学习目标

- 理解工作目录与脚本目录
- 用 with 安全读写 UTF-8 文本
- 选择 CSV、JSON、JSONL 表示结构化数据

### 相对路径相对于哪里
`Path("data.txt")` 默认相对于**当前工作目录**，不一定是代码文件所在目录。你从不同目录启动脚本，可能读到不同地方。终端先 cd 到学习包根目录可以减少混乱。

需要固定相对脚本位置时用 `BASE = Path(__file__).resolve().parent`，再写 `BASE / "data" / "scores.csv"`。`__file__` 通常在脚本中存在，但交互窗口和某些 notebook 环境中没有。Path 的 `/` 在这里是拼接路径，不是数值除法；它会按操作系统处理分隔符。

### 文本读写与覆盖
`path.read_text(encoding="utf-8")` 返回整个文件字符串；`path.write_text(text, encoding="utf-8")` 写入文本，**已有同名文件会被覆盖**。父目录不存在时先 `mkdir(parents=True, exist_ok=True)`。本章示例将演示文件写入当前工作目录下的 `outputs/`，可反复运行并覆盖这些专用示例文件；不要替换成你自己的重要文件路径。

用 `with path.open("r", encoding="utf-8") as file:` 打开文件，离开 with 块后自动关闭。`"r"` 读取，`"w"` 覆盖写，`"a"` 追加，`"x"` 只在文件不存在时新建。二进制模式如 rb 面向字节，本阶段先专注文本。

### 一行一行处理
`for line in file` 可以逐行处理，避免一次把大文件读进内存。line 通常保留行末换行符。若只想去掉行末换行，用 rstrip("\n")；strip() 会连两端其他空白一起删除，是否合适取决于数据。`splitlines()` 把完整字符串拆成行，适合小文件。

### CSV 是表格，不是随便按逗号拆
CSV 一行一条记录，通常第一行是列名。字段里可能包含逗号、引号甚至换行，所以使用 csv.DictReader / DictWriter。打开 CSV 时加 `newline=""`，让 csv 模块正确处理换行。

CSV 读取的字段默认是字符串，"80" 要 int 后才能计算。不要把表头拼错或空字符串当成正常分数；第 13 章学习识别与记录错误。Excel 导出的文件可能含 BOM，可尝试 `encoding="utf-8-sig"` 读取 UTF-8 BOM 文件，不要对所有编码问题盲目使用 errors="ignore"。

### JSON 保存嵌套结构
JSON 能表示对象、数组、字符串、数字、布尔值和 null。读入 Python 后对应 dict、list、str、int/float、bool、None。JSON 文本里是 true/false/null，Python 源码里是 True/False/None。JSON 要求对象键为字符串；集合等类型不能直接序列化。

`json.dumps(obj)` 返回 JSON 字符串，`json.loads(text)` 把字符串解析为对象。`json.dump(obj,file)` 和 `json.load(file)` 面向文件对象。`ensure_ascii=False` 保留中文便于阅读，`indent=2` 让缩进清晰。数据不合法时会报 JSONDecodeError；不要使用 eval 读取 JSON。

### JSONL 是每行一个 JSON 对象
每行独立解析，适合逐条流式处理记录。空行怎么处理、坏行是跳过还是终止，都要制定约定。AI 数据集常见这种格式；文本中的换行应由 JSON 转义，而不是直接破坏一行一条记录的结构。

### 跨电脑可靠性
使用 UTF-8、pathlib、明确的相对脚本路径，避免硬编码 `C:\Users\你的名字` 或 `/Users/你的名字`。把数据和代码一起打包。文件写出后读回来核对条数和关键字段，比“没有报错”更有说服力。

### 精讲：路径正确，文件才会出现在你预期的位置
`Path("data/input.json")` 从当前工作目录开始寻找；当前工作目录由你怎样启动程序决定。`Path(__file__).resolve().parent` 从脚本文件的位置定位。把项目放到另一台电脑后，后者更适合寻找随项目一起复制的数据。`__file__` 用于脚本；在交互窗口或某些笔记本里不要假设它存在。

请从学习包根目录运行示例 12_06，再换一个目录用完整路径运行同一个文件，观察它打印的路径字段。该示例只解析路径，不会创建文件。真正需要写文件时，先确认目标目录；`mkdir(parents=True, exist_ok=True)` 可以创建缺失目录。

| 模式 | 目标文件不存在时 | 目标文件已存在时 | 常见用途 |
| --- | --- | --- | --- |
| r | 报 FileNotFoundError | 读取，不截断 | 读课件或已有记录 |
| w | 创建文件 | 一打开就截断原内容 | 重写完整快照 |
| a | 创建文件 | 在末尾追加 | 每行一条的日志 |

“先读再改再写完整 JSON”与“每行追加一条 JSONL”是两种不同设计。不要在 JSON 数组末尾直接追加另一个 JSON 对象；拼在一起通常不再是一个合法的 JSON 文档。JSONL 则约定一行一个 JSON 值，读取时逐行解析。本教材日志只使用一行一个对象。

### 精讲：字符串看起来像数字，不等于已经是数字
CSV 读回的 `"80"` 是字符串；先用 int 转换再计算。JSON 读回的值则取决于原 JSON 的类型：`80` 读成整数，`"80"` 仍是字符串，`null` 读成 None。成功解析文件只说明格式可以读取，不能证明字段类型和业务范围正确。

编码统一使用 UTF-8。CSV 文件使用 `newline=""` 交给 csv 模块处理换行；来自部分表格软件的 UTF-8 BOM 文件可以按明确需求用 `utf-8-sig` 读取。不要靠到处删首字符来修复表头。

**自查顺序：** 找到文件 → 确认编码 → 解析格式 → 检查字段 → 转换类型 → 执行业务。后面 JSON 会话示例先解决保存与读回；第 13 章补上输入校验。

## 示例 12_01 写入与读回文本

路径：examples/ch12/ex12_01.py

```python
from pathlib import Path
folder = Path("outputs")
folder.mkdir(exist_ok=True)
path = folder / "example12_01.txt"
path.write_text("第一天：变量\n第二天：循环\n", encoding="utf-8")
text = path.read_text(encoding="utf-8")
print(text, end="")
```

输出：
```text
第一天：变量
第二天：循环
```

解析：目录先建立，再写固定演示文件。读取结果末尾已有换行，所以 print 用 end="" 避免多一行。

改写：追加第三天内容，再读回确认没有覆盖前两天。

## 示例 12_02 逐行读取与清理

路径：examples/ch12/ex12_02.py

```python
from pathlib import Path
folder = Path("outputs")
folder.mkdir(exist_ok=True)
path = folder / "example12_02.txt"
path.write_text(" Python \n\n AI \n", encoding="utf-8")
with path.open(encoding="utf-8") as file:
    for line in file:
        clean = line.strip()
        if clean:
            print(clean)
```

输出：
```text
Python
AI
```

解析：with 管理文件关闭；for 提供一行；strip 和非空检查完成简单清洗。这是第 16 章文本管道的基础。

改写：保留行内空格，确认 strip 不会删掉中间空格。

## 示例 12_03 CSV 写读转换

路径：examples/ch12/ex12_03.py

```python
import csv
from pathlib import Path
folder = Path("outputs")
folder.mkdir(exist_ok=True)
path = folder / "example12_03.csv"
with path.open("w", encoding="utf-8", newline="") as file:
    writer = csv.DictWriter(file, fieldnames=["name", "score"])
    writer.writeheader()
    writer.writerows([{"name": "小林", "score": 80}, {"name": "小陈", "score": 90}])
with path.open(encoding="utf-8", newline="") as file:
    rows = list(csv.DictReader(file))
print(type(rows[0]["score"]).__name__)
print(sum(int(row["score"]) for row in rows))
```

输出：
```text
str
170
```

解析：写时是整数，CSV 文本再读回来是字符串。列名由表头决定。数值转换放在明确处理步骤里。

改写：把名字改成带英文逗号的文本，确认 csv 模块能正确读写。

## 示例 12_04 JSON 往返

路径：examples/ch12/ex12_04.py

```python
import json
config = {"name": "练习", "batch_size": 8, "enabled": True}
text = json.dumps(config, ensure_ascii=False)
restored = json.loads(text)
print(text)
print(restored == config)
```

输出：
```text
{"name": "练习", "batch_size": 8, "enabled": true}
True
```

解析：JSON true 与 Python True 的拼写不同。dumps/loads 在内存字符串间转换，配合 Path 就可以持久化。

改写：加一个 None 字段，观察 JSON 中变成什么。

## 示例 12_05 逐行 JSONL

路径：examples/ch12/ex12_05.py

```python
import json
from pathlib import Path
folder = Path("outputs")
folder.mkdir(exist_ok=True)
path = folder / "example12_05.jsonl"
records = [{"id": 1, "text": "你好"}, {"id": 2, "text": "Python"}]
with path.open("w", encoding="utf-8") as file:
    for record in records:
        file.write(json.dumps(record, ensure_ascii=False) + "\n")
with path.open(encoding="utf-8") as file:
    for line in file:
        record = json.loads(line)
        print(record["id"], record["text"])
```

输出：
```text
1 你好
2 Python
```

解析：每次 dumps 生成一个 JSON 文本，再追加真实换行。读取时每行都是独立 JSON。

改写：让 text 含换行，查看文件中的转义后再读回。

## 示例 12_06 仅解析路径

路径：examples/ch12/ex12_06.py

```python
from pathlib import Path
path = Path("data") / "train.jsonl"
print(path.name)
print(path.stem)
print(path.suffix)
```

输出：
```text
train.jsonl
train
.jsonl
```

解析：name 是最后一部分文件名，stem 去最后一个扩展名，suffix 带点。构造 Path 不会自动创建文件。

改写：尝试 report.final.csv，观察 stem。

## 示例 12_07 AI / Agent：把会话快照写入 JSON 再读回

路径：examples/ch12/ex12_07.py

```python
import json
from pathlib import Path

messages = [{"role": "user", "content": "学习函数"}]
path = Path("outputs/ai_chat_demo.json")
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(messages, ensure_ascii=False, indent=2)
path.write_text(text, encoding="utf-8")
restored = json.loads(path.read_text(encoding="utf-8"))
print(type(restored).__name__)
print(restored[0]["content"])
print(restored == messages)
```

输出：
```text
list
学习函数
True
```

解析：messages 是内存列表；dumps 把它变成 JSON 字符串；write_text 把字符串写到当前工作目录下的 outputs/ai_chat_demo.json；read_text 读回字符串；loads 再得到列表。两次转换方向相反，值相等不代表还是同一个内存对象。重复运行会重写这个演示文件；在学习包根目录运行时，可到 outputs 查看内容。这里叫持久化，因为文件在程序退出后仍在；不是训练模型或修改模型权重。

改写：给列表追加一条 assistant 消息再运行，确认文件里出现两条。随后只修改内存列表而不写文件，再读文件，说明为什么文件不会自动跟着改变。

## 练习 12_01 提取非空行（场景）

实现 nonempty_lines(text)，去各行两端空白，丢弃空行。打印对 " A \n\n B " 的结果。

作答文件：practice/ch12/ex12_01.py

提示：先 splitlines，再 strip。

参考答案：
```python
def nonempty_lines(text):
    result = []
    for line in text.splitlines():
        clean = line.strip()
        if clean:
            result.append(clean)
    return result

print(nonempty_lines(" A \n\n B "))
```

输出：
```text
['A', 'B']
```

解析：函数先只负责文本转换，文件读写留给外部，这样容易独立验证。

## 练习 12_02 配置解析（基础）

实现 read_batch_size(text)，解析 JSON 字符串并读取 batch_size，缺失默认 8。打印对 {"name":"demo"} 的结果。假定输入 JSON 对象合法。

作答文件：practice/ch12/ex12_02.py

提示：json.loads 后 get。

参考答案：
```python
import json

def read_batch_size(text):
    config = json.loads(text)
    return config.get("batch_size", 8)

print(read_batch_size('{"name":"demo"}'))
```

输出：
```text
8
```

解析：此题聚焦解析和默认值。真实应用还需要验证 batch_size 是正整数，不能接受任意字段类型。

## 练习 12_03 CSV 字符串求和（场景）

实现 csv_total(text)，CSV 含 amount 列，内容为整数字符串；返回合计。打印对 "amount\n10\n20\n" 的结果 30。

作答文件：practice/ch12/ex12_03.py

提示：io.StringIO 可以把内存字符串当文本文件使用。

参考答案：
```python
import csv
import io

def csv_total(text):
    reader = csv.DictReader(io.StringIO(text))
    return sum(int(row["amount"]) for row in reader)

print(csv_total("amount\n10\n20\n"))
```

输出：
```text
30
```

解析：StringIO 免去测试中创建实体文件，但 csv 解析规则相同。空数据只有表头时 sum 得到 0。

## 练习 12_04 JSONL 有效记录（迁移）

实现 parse_jsonl(text)，跳过空行，解析其余每行 JSON，返回列表。假定非空行都是合法 JSON。打印两条 {"id":1}、{"id":2} 的结果。

作答文件：practice/ch12/ex12_04.py

提示：逐行 strip，非空才 loads。

参考答案：
```python
import json

def parse_jsonl(text):
    records = []
    for line in text.splitlines():
        if line.strip():
            records.append(json.loads(line))
    return records

print(parse_jsonl('{"id":1}\n\n{"id":2}\n'))
```

输出：
```text
[{'id': 1}, {'id': 2}]
```

解析：空行不是合法 JSON，所以应先按约定跳过。坏行处理会在下一章增加。

## 练习 12_05 扩展名清单（基础）

实现 suffixes(names)，用 Path.suffix 返回各文件扩展名的列表；打印 ["a.txt","b.tar.gz","README"] 的结果。

作答文件：practice/ch12/ex12_05.py

提示：suffix 只取最后一个扩展名，没有则为空字符串。

参考答案：
```python
from pathlib import Path

def suffixes(names):
    return [Path(name).suffix for name in names]

print(suffixes(["a.txt", "b.tar.gz", "README"]))
```

输出：
```text
['.txt', '.gz', '']
```

解析：要所有扩展名可查看 suffixes 属性；本题只要最后一个。此函数不要求文件实际存在。

## 练习 12_06 读取数字文件（场景）

实现 read_numbers(path)，path 为 pathlib.Path，读 UTF-8 文本，每个非空行是合法整数，返回整数列表。演示时在临时目录建文件，内容 10、20，输出 [10,20]。

作答文件：practice/ch12/ex12_06.py

提示：复用 splitlines、strip 与 int；临时目录离开 with 后自动清理。

参考答案：
```python
from pathlib import Path
from tempfile import TemporaryDirectory

def read_numbers(path):
    return [int(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]

with TemporaryDirectory() as folder:
    path = Path(folder) / "numbers.txt"
    path.write_text("10\n20\n", encoding="utf-8")
    print(read_numbers(path))
```

输出：
```text
[10, 20]
```

解析：临时目录只用于演示和检查，不适合保存你长期需要的学习记录。函数不负责跳过非法数字，本题约定输入合法。

## 练习 12_07 AI / Agent：读回合法的 JSONL 消息（场景）

实现 parse_messages(text)：输入为多行文本，每个非空行保证是含 role、content 的合法 JSON 对象；跳过空行，返回解析后的字典列表。调用文本为两行 {"role":"user","content":"hi"} 与 {"role":"assistant","content":"hello"}，中间有一空行。输出列表长度和第二条正文。

作答文件：practice/ch12/ex12_07.py

提示：用 splitlines 遍历行，strip 后为空就 continue；否则 json.loads 后 append。此题只处理合法记录，坏 JSON 留到下一章。

参考答案：
```python
import json

def parse_messages(text):
    messages = []
    for line in text.splitlines():
        if not line.strip():
            continue
        messages.append(json.loads(line))
    return messages

text = '{"role":"user","content":"hi"}\n\n{"role":"assistant","content":"hello"}'
messages = parse_messages(text)
print(len(messages))
print(messages[1]["content"])
```

输出：
```text
2
hello
```

解析：splitlines 把文本拆成行；空行被跳过；每个非空行独立 loads，而不是把整段 JSONL 当一个 JSON 文档。最后返回列表供调用者使用。尝试末尾换行、Windows 的 CRLF 换行和全空输入；这些都不应多产生一条消息。下一章再区分格式错误和字段错误。

## 记忆要点
- 工作目录不等于脚本目录；固定位置用 __file__。
- w 会覆盖，a 会追加，x 防止覆盖。
- CSV 字段通常是 str；JSON 可保存嵌套结构。
- 小文件整体读，大文件逐行读。

## 通关检查
- 我能从任意工作目录运行一个用脚本目录定位数据的程序。
- 我能将记录写入 JSON 再读回验证。
- 我能解释为什么 CSV 不能总是直接 split(",")。


# 13 错误 调试与验证

报错是定位线索，能解释错误比只会复制正确代码更有价值。

## 学习目标

- 按顺序阅读 traceback
- 只捕获预期异常并保留诊断信息
- 用正常、边界和错误输入验证函数
- 逐行检查 JSONL，记录原始行号并保留后续合法数据
- 将 CSV 字段分步转换，保留坏记录的结束行号

### 三种不同的问题
语法错误让代码无法按语法解析，例如少冒号；运行时错误发生在执行到某一步时，例如 int("abc")；逻辑错误可能完全不报错，却给出错误结果，例如平均分除以固定 3 而不是实际人数。第三种尤其需要测试发现。

### 读 traceback 的步骤
先看最后一行的异常类型和消息，再往上找到你自己文件的行号，打开那一行，检查参与操作的变量类型和值。若这个值来自上一步，再追到它的来源。长报错栈不意味着有很多独立错误；它经常是在描述同一个错误的调用路径。

| 异常 | 常见含义 | 首先检查 |
|---|---|---|
| SyntaxError / IndentationError | 语法或缩进不对 | 冒号、括号、引号、缩进 |
| NameError | 名字不存在 | 拼写、是否先赋值 |
| TypeError | 类型或调用方式不匹配 | type、参数数目 |
| ValueError | 类型可用但内容不合法 | int 的输入、范围 |
| IndexError / KeyError | 位置或键不存在 | 长度、键集合 |
| FileNotFoundError | 找不到文件 | 当前目录、路径、是否解压 |
| ModuleNotFoundError | 找不到模块 | 解释器、文件名、环境 |
| ZeroDivisionError | 除数为零 | 空数据、计数初始化 |

### 最小化问题
把大程序缩成还能复现错误的最小例子。打印 `repr(value)` 看不可见字符，打印 type(value) 看字符串还是数字，打印 len(values) 看是否为空。用小数据手算结果，避免一开始在上万条数据里找错。

### try 与 except
把“确实可能失败的一小段”放入 try。`except ValueError as error:` 处理特定错误并获取消息。不要用裸 except 吞掉所有异常，不然 Ctrl+C 等中断也可能被拦截；也不要 `except Exception: pass` 把错误静默丢掉。

多个错误可以分别处理，例如文件不存在与 JSON 格式错误。`else` 可放 try 成功后执行的代码，`finally` 常用于不论成功失败都要执行的清理。文件关闭优先使用 with，减少手写 finally。

### 什么时候主动 raise
输入不符合函数约定时，可以 `raise ValueError("batch_size 必须大于 0")`。清晰消息比后来意外除零好。交互层可以捕获并让用户重试；核心处理函数通常报告错误，让调用者决定如何处理。

### assert 与正式输入校验
`assert actual == expected` 常用在开发和测试中核对内部预期。它失败会抛 AssertionError。不要用 assert 替代处理外部输入的验证，因为 Python 优化模式可能移除 assert；用户输入校验应使用 if 和 raise。

浮点测试用 `math.isclose(actual, expected, rel_tol=..., abs_tol=...)`，容差应由问题精度决定。接近 0 时绝对容差尤其有用。不要不加思考地对所有问题套同一个容差。

### 为一个函数设计测试
以 mean 为例：正常输入 [2,4] →3；单元素 [5] →5；空输入 [] →None；负数 [-2,2] →0；输入不被修改。不要只选“好看的输入”。测试通过说明满足这些检查，不证明对所有可能输入绝对正确。

### 怎样用本教材自检器
先编辑 `practice/ch09/ex09_02.py` 并保存。在学习包根目录执行：

```terminal
python tools/check_practice.py 09_02
python tools/check_practice.py --chapter 09
```

Windows 无虚拟环境时把 python 换成 py；Mac 换成 python3。自检先比较示例输出，再对部分函数题使用其他输入。它只运行学习包中的指定题目文件，不会替你保存编辑器里尚未保存的内容。未完成题显示未通过是正常的。不要用 `--solutions` 代替作答；该选项仅用于核验参考答案。

失败时按报告找到：运行报错、输出不同，或函数额外测试失败。先看题目函数名是否一致、return 是否遗漏、空数据是否处理，再看格式。若靠硬编码示例输出过关，并没有达到学习目的；至少自己再换两个输入。

### 精讲：Traceback 从哪里开始读
遇到报错先保留完整输出，再读最后一行的异常类型和原因。往上找你自己代码对应的文件名、行号和那一行表达式。最靠近末尾的调用帧通常是异常出现的地方；更上面的调用帧解释程序怎样走到那里。

练习时建立一个临时文件，只写 `value = int("abc")` 并运行。你应看到 ValueError。现在把输入改成 `"12"` 再运行：异常消失，但没有 print 就不会显示 12。这说明“运行成功”和“输出符合需求”要分别检查。不要为了看到内容而把所有函数都改成 print。

| 现象 | 首先检查 | 下一步实验 |
| --- | --- | --- |
| NameError | 名字拼写、定义是否执行 | 在调用前确认名字已定义 |
| UnboundLocalError | 函数内是否给同名变量赋值后又提前读取 | 把依赖改成显式参数并 return 新值 |
| TypeError | 当前操作两边实际是什么类型 | 临时打印 type(value) |
| IndexError / KeyError | 索引、键、输入是否为空或缺字段 | 手工构造最小的空输入 |
| 输出不对但无异常 | 条件边界、更新顺序、数据约定 | 列输入 → 中间值 → 预期输出表 |

### 精讲：解析成功只是第一道检查
`json.loads` 可以读出列表、数字、字符串或字典。因此不能在解析后直接假设结果有 `result["answer"]`。结构化模型输出也需要检查：最外层类型是什么、必需字段是否存在、字段类型是否符合约定、值是否在范围内。

Python 中 bool 是 int 的子类。如果约定“次数必须是整数，不能是 True”，仅 `isinstance(value, int)` 会把 True 放进来。可以使用 `type(value) is int`，或在 isinstance 检查之外明确排除 bool。请理解约定后选择，不要机械地把所有 isinstance 改掉。

只有可恢复的错误才适合重试。格式错误可以要求重新生成；重复执行一个已经成功写入的工具可能造成重复记录。后面的离线示例只重试读取预设回复，最多三次；它不代表网络重试或有副作用工具的完整策略。

### 精讲：自检失败时怎样缩小问题
输出比较失败时，自检器会显示第一处不同的行，用引号让行首行尾空格可见。先比这一行，再检查多余的提示语和换行。函数测试失败时，Traceback 会显示 assert 对应的条件；把那组输入单独送进自己的函数，逐步打印中间值。

assert 适合本教材的开发检查。运行 Python 时若使用 `-O`，断言可能被移除；真正需要始终执行的外部输入校验，应写 if 和 raise。

### 修复实验：函数里为什么读不到外面的计数
先看新增示例 13_07 的 broken_round。函数体中出现 `rounds += 1`，Python 会把这里的 rounds 视为这个函数的局部名字；而 += 又要先读取旧值，所以在局部值尚未建立时抛出 UnboundLocalError。即使外面已有 rounds=0，也不能在这处读取中自动补上局部值。

示例专门捕获这一个异常并打印它的类型，随后显示外面的 rounds 仍是 0。修复版显式接收 current 并返回 current+1，再由外面赋值保存。这样每一次调用的输入和结果都能单独测试。

想看完整 Traceback，可以在临时副本里只保留 rounds、broken_round 的定义及 `broken_round()` 调用，去掉捕获部分再运行。此时出现异常并退出是预期现象。不同 Python 版本的错误措辞可能有差别；先看类型、文件行号和出错表达式，不必背整句英文。

### 实操：JSONL 的一行数据要经过三道检查
下面训练记录的约定由本教材自行制定：每行是一个对象；text 是字符串，去首尾空白后非空；label 是整数 0 或 1，不能是 True/False。它只是小型练习格式，不代表任何模型厂商的训练接口。

| 检查层 | 当前拿到什么 | 失败例子 | 本例错误类别 |
| --- | --- | --- | --- |
| JSON 解析 | 原始的一行字符串 | 缺少右花括号 | json |
| 结构与字段类型 | loads 得到的 Python 值 | 最外层是列表，或 text 是数字 | schema |
| 业务取值 | 类型已正确的字段 | text 仅空白，或 label 为 2 | value |

先确保最外层是字典，才能按字段读取；先确保 text 是字符串，才能 strip；先确保 label 是本题允许的整数类型，才能检查值域。**格式能解析、字段类型正确、符合使用约定，是三件事。**

### 为什么原始行号必须在跳过空行之前取得
用 `enumerate(text.splitlines(), start=1)` 先得到物理行号，再跳过空行。如果先删掉空行再编号，第 3 行的错误就可能被写成第 2 行，回原文件时会找错位置。

新增示例 13_08 有 6 行：第 1 行合法、第 2 行为空、第 3 行 JSON 损坏、第 4 行字段类型错误、第 5 行 label 超范围、第 6 行合法。先手算：保留 2 条、跳过 1 行、记录 3 个错误，错误行号应为 3、4、5。运行后逐个比对。

### 坏行之后还能继续，是因为明确选择了 continue
解析失败后先保存行号和错误类别，再 continue 到下一行；合法行才加入 records。try 只包住 json.loads，并只捕获 JSONDecodeError。若把整个处理函数都包进宽泛的 except 后默默跳过，拼错变量名等程序缺陷也可能被隐藏。

这个例子处理内存字符串，所以每次运行不改任何数据文件。接入第 12 章文件读取时，可以把 `Path(...).read_text(encoding="utf-8")` 的结果交给函数。文件不存在或编码不对属于读取层错误，不能冒充“某一行 JSON 不合法”；大文件逐行流式读取留作后续改写。

### CSV 精修：从文本到可以计算的数字，中间发生了什么
前置：第 07 章列表、第 09 章函数、第 12 章 csv 和 StringIO，以及本章的 ValueError。先完成 12_03 的 CSV 字符串求和，再做下面的坏数据练习。StringIO 把内存字符串包装成可读取的文本流，不会创建文件。

本例约定表头严格为 name,score，顺序也相同；每条记录两列，姓名去掉首尾空白后非空，成绩是 int 能转换的整数字符串，范围 0 到 100。先检查列数，再检查姓名，再转换成绩，最后检查范围。第一处失败就记下原因并 continue，不能把同一条坏记录加进 valid。

| 当前步骤 | 当前值 | 类型或含义 | 下一步 |
| --- | --- | --- | --- |
| csv.reader 解析一条记录 | ['小何', '0'] | 两个字符串组成的列表 | 检查 len(row) |
| row[1].strip() | '0' | 字符串，不是数字 | int 转换 |
| int 得到结果 | 0 | 整数，转换成功 | 检查 0 <= score <= 100 |
| 加入 valid | {'name': '小何', 'score': 0} | 可以交给统计函数的字典 | 继续下一条 |

**0 是有效成绩。** 不要用 `if not score` 判断“没有填成绩”，因为整数 0 的真假值为 False。应在转换前看字符串是否为空，或让 int('') 抛出 ValueError。`int('80.5')` 也会失败；`int(' 80 ')` 和 `int('+80')` 可以成功，本题接受 int 的这些常规写法。

### 为什么不直接 split(',') 或按记录数冒充行号
`"林,一",80` 的姓名中含逗号，csv.reader 能把它读成两列；split(',') 会错误拆成三部分。CSV 的引号内还可以合法换行，所以“第几条记录”和“原文件第几行”并不总相同。

示例 13_09 使用 reader.line_num，表示解析完当前记录时已经读到的物理行号，即**记录结束行号**。完全空的物理行（没有任何字符）解析成 [] 后跳过；`小李` 是一列，不是空行；`小李,` 是两列，第二列是空字符串，属于整数转换失败。坏记录只报告第一处错误。

| 示例原始行 | 数据情况 | 处理结果 |
| --- | --- | --- |
| 1 | name,score | 表头，只检查一次 |
| 2 | 姓名含英文逗号，成绩 80 | 保留 |
| 3 | 完全空的物理行 | 跳过 |
| 4 | 成绩 bad | integer |
| 5 | 成绩 101 | range |
| 6 | 只有姓名，没有成绩列 | columns |
| 7 | 成绩 0 | 保留 |

### 三层实操：每次只增加一种困难
1. **读懂：** 先预测示例 13_09 保留几条、错误几条，以及错误行号。运行后逐项对照上表。两个列表不是重复数据：valid 给统计层，errors 给修复原始数据的人。
2. **改写：** 将 bad 改为 90，预测合法记录变为 3 条；将 101 改为 100，预测它通过；将 0 改为 -1，预测 range 错误；把“小李”改为“小李,”，预测错误从 columns 变为 integer。每次只改一项，保留其他输入。
3. **迁移：** 完成习题 13_10 的离线工具耗时汇总。沿用检查顺序，但独立写出函数和边界；不看成绩例子的代码也能说明每层输入与输出，再接第 17 章成绩表清洗项目。

本例会继续处理字段错误，但错误表头会直接抛出 ValueError。严重引号错误可能让 csv.reader 抛出 csv.Error；该异常向调用者报告并停止，因为无法确定下一条记录从哪里开始。不要将所有 CSV 错误都理解成“可以安全跳过一行”。读取真实文件时使用 `open(path, encoding="utf-8", newline="")`，让 csv 模块处理换行。

## 示例 13_01 具体捕获转换错误

路径：examples/ch13/ex13_01.py

```python
texts = ["10", "bad", "20"]
values = []
for text in texts:
    try:
        value = int(text)
    except ValueError:
        print("跳过无效值", repr(text))
    else:
        values.append(value)
print(sum(values))
```

输出：
```text
跳过无效值 'bad'
30
```

解析：只捕获 int 可能产生的 ValueError，失败记录原因；成功才加入数值列表。不要把 int 后所有业务逻辑都塞进一个宽泛 try。

改写：加上空字符串和 "2.5"，观察 int 的规则。

## 示例 13_02 主动验证约定

路径：examples/ch13/ex13_02.py

```python
def divide(total, count):
    if count <= 0:
        raise ValueError("count 必须为正数")
    return total / count

try:
    divide(10, 0)
except ValueError as error:
    print(error)
```

输出：
```text
count 必须为正数
```

解析：在执行除法之前验证数据。调用处决定怎样显示错误，核心函数不强行 print。

改写：用 count=-1 与 count=2 测试。

## 示例 13_03 测试浮点结果

路径：examples/ch13/ex13_03.py

```python
import math
result = 0.1 + 0.2
print(result == 0.3)
print(math.isclose(result, 0.3, rel_tol=1e-9, abs_tol=1e-12))
```

输出：
```text
False
True
```

解析：直接相等对二进制浮点近似可能不合适。给出容差后比较数值接近程度。

改写：对 0 与 1e-13 比较，观察 abs_tol 的作用。

## 示例 13_04 小型回归检查

路径：examples/ch13/ex13_04.py

```python
def clamp(value, low, high):
    return min(max(value, low), high)

assert clamp(-1, 0, 10) == 0
assert clamp(5, 0, 10) == 5
assert clamp(11, 0, 10) == 10
print("3 个检查通过")
```

输出：
```text
3 个检查通过
```

解析：每个断言对应区间的一种区域。修改函数后再运行这些检查，可以发现旧行为是否被破坏。

改写：补上恰好等于 0 和 10 的检查。

## 示例 13_05 带行号记录坏数据

路径：examples/ch13/ex13_05.py

```python
lines = ["80", "oops", "90"]
for line_number, text in enumerate(lines, start=1):
    try:
        score = int(text)
    except ValueError:
        print(f"第 {line_number} 行不是整数：{text}")
        continue
    if not 0 <= score <= 100:
        print(f"第 {line_number} 行超出范围")
        continue
    print("保留", score)
```

输出：
```text
保留 80
第 2 行不是整数：oops
保留 90
```

解析：语法可解析与业务合法是两层检查。能 int 转换不代表分数一定在 0—100 内。

改写：加一条 120，验证范围检查。

## 示例 13_06 AI / Agent：解析、校验、有限次重试

路径：examples/ch13/ex13_06.py

```python
import json

def read_answer(text):
    data = json.loads(text)
    if not isinstance(data, dict):
        raise ValueError("最外层必须是对象")
    answer = data.get("answer")
    if not isinstance(answer, str) or not answer.strip():
        raise ValueError("answer 必须是非空字符串")
    return answer.strip()

replies = ["not-json", '{"answer": 12}', '{"answer": "先学函数"}']
for attempt, reply in enumerate(replies, start=1):
    try:
        answer = read_answer(reply)
    except ValueError:
        print(f"第 {attempt} 次：格式或内容不合格")
        continue
    print(f"第 {attempt} 次：{answer}")
    break
else:
    print("已用完预设回复，停止")
```

输出：
```text
第 1 次：格式或内容不合格
第 2 次：格式或内容不合格
第 3 次：先学函数
```

解析：第 1 条连 JSON 语法都不满足；第 2 条能解析，但 answer 是整数；第 3 条才满足本题结构约定。json.JSONDecodeError 属于 ValueError，因此此处一起捕获；正式调试时可以分别记录原因。try 只包住解析函数，成功后打印并 break。for 的 else 仅在没有 break、正常遍历结束时执行。本例读取三条预设字符串，不会请求模型重试；通过校验也只说明结构合格，不保证答案事实正确。

改写：把第三条改成空字符串字段，确认出现“停止”。再把第二条改为合法答案，确认第三条不会读取。

## 示例 13_07 作用域排错：先复现，再用参数和返回值修复

路径：examples/ch13/ex13_07.py

```python
rounds = 0

def broken_round():
    rounds += 1
    return rounds

try:
    broken_round()
except UnboundLocalError as error:
    print(type(error).__name__)
print(f"外部轮数：{rounds}")

def fixed_round(current):
    return current + 1

rounds = fixed_round(rounds)
rounds = fixed_round(rounds)
print(f"修复后：{rounds}")
```

输出：
```text
UnboundLocalError
外部轮数：0
修复后：2
```

解析：broken_round 的 += 要读取还不存在的局部 rounds，赋值未能完成就出错。捕获异常后，外部 rounds 仍为 0。fixed_round 两次分别收到 0 和 1，返回 1 和 2，调用者保存返回值后得到 2。捕获错误本身没有修复逻辑；修复来自明确的参数和返回值。

改写：把第二次修复调用改成单独的 fixed_round(rounds)，不接收返回值，预测最后输出。再对照第 09 章 09_10，解释“报错”和“能运行但没保存结果”的区别。

## 示例 13_08 AI 数据排错：保留坏行位置，继续处理下一行

路径：examples/ch13/ex13_08.py

```python
import json

def read_training_lines(text):
    records, errors = [], []
    blank_lines = 0
    for line_number, line in enumerate(text.splitlines(), start=1):
        if not line.strip():
            blank_lines += 1
            continue
        try:
            item = json.loads(line)
        except json.JSONDecodeError:
            errors.append({"line": line_number, "kind": "json"})
            continue
        if not isinstance(item, dict):
            errors.append({"line": line_number, "kind": "schema"})
            continue
        content = item.get("text")
        label = item.get("label")
        if not isinstance(content, str) or type(label) is not int:
            errors.append({"line": line_number, "kind": "schema"})
            continue
        if not content.strip() or label not in (0, 1):
            errors.append({"line": line_number, "kind": "value"})
            continue
        records.append({"text": content.strip(), "label": label})
    return records, errors, blank_lines

text = '\n'.join([
    '{"text": " Python ", "label": 1}',
    '',
    '{"text": "broken", "label": 0',
    '{"text": 42, "label": 0}',
    '{"text": "hello", "label": 2}',
    '{"text": "Agent", "label": 0}',
])
records, errors, blank_lines = read_training_lines(text)
print(f"保留 {len(records)} 条，跳过 {blank_lines} 个空行")
for error in errors:
    print(f"第 {error['line']} 行：{error['kind']}")
print(records)
```

输出：
```text
保留 2 条，跳过 1 个空行
第 3 行：json
第 4 行：schema
第 5 行：value
[{'text': 'Python', 'label': 1}, {'text': 'Agent', 'label': 0}]
```

解析：输入文本先分行并编号；空行跳过但保留原行号；每行依次经过解析、类型和取值检查；只有合格记录进入 records。坏行的类别与行号进入 errors，不影响第 6 行被保留。多个字段同时出错时，只报告本程序最先检查到的类别，不代表已经列出所有问题。这里只检查数据格式，没有训练模型。

改写：把第 4 行的 label 改为 true、text 改为 "ok"，预测它仍属于 schema；再把第 5 行 text 改为空白且 label 改为 0，预测属于 value。最后在首行前加一个空行，核对所有原错误行号加 1。

## 示例 13_09 CSV 清洗：空行、缺列与零分分别处理

路径：examples/ch13/ex13_09.py

```python
import csv
import io

def clean_scores_csv(text):
    reader = csv.reader(io.StringIO(text, newline=""), strict=True)
    if next(reader, None) != ["name", "score"]:
        raise ValueError("表头必须为 name,score")
    valid, errors = [], []
    for row in reader:
        end_line = reader.line_num
        if not row:
            continue
        if len(row) != 2:
            errors.append((end_line, "columns"))
            continue
        name = row[0].strip()
        if not name:
            errors.append((end_line, "name"))
            continue
        try:
            score = int(row[1].strip())
        except ValueError:
            errors.append((end_line, "integer"))
            continue
        if not 0 <= score <= 100:
            errors.append((end_line, "range"))
            continue
        valid.append({"name": name, "score": score})
    return valid, errors

text = 'name,score\n"林,一",80\n\n小陈,bad\n小周,101\n小李\n小何,0\n'
valid, errors = clean_scores_csv(text)
print(valid)
for end_line, reason in errors:
    print(f"结束行 {end_line}：{reason}")
```

输出：
```text
[{'name': '林,一', 'score': 80}, {'name': '小何', 'score': 0}]
结束行 4：integer
结束行 5：range
结束行 6：columns
```

解析：reader 先把文本解析成字段列表。列数与姓名合格后才尝试 int，只有转换成功才判断分数范围。第 3 行空行不应挤掉后面的物理行号，所以从 reader.line_num 取得结束行。第 7 行的 0 转换成功且在范围内，必须保留。所有错误分支都 continue，因此错误记录不会落入最后的 valid.append。

改写：先做正文的四次单项改写，再试只有表头的输入，确认返回两个空列表；改错表头时应得到 ValueError。最后用带引号换行的姓名，观察结束行号为什么可能跳两个数。

## 练习 13_01 容错转整数（基础）

实现 parse_int(text)，转换成功返回整数，ValueError 时返回 None。依次打印对 "12" 和 "abc" 的结果。输入约定为字符串。

作答文件：practice/ch13/ex13_01.py

提示：只捕获 ValueError。

参考答案：
```python
def parse_int(text):
    try:
        return int(text)
    except ValueError:
        return None

print(parse_int("12"))
print(parse_int("abc"))
```

输出：
```text
12
None
```

解析：默认 None 表示解析失败，但 0 是有效返回值；调用者应使用 is None 判断。

## 练习 13_02 分数合法性（场景）

实现 valid_score(text)，字符串可转整数且在 0—100 内返回该整数，否则返回 None。打印对 "101" 和 "60" 的结果。

作答文件：practice/ch13/ex13_02.py

提示：先转换，再检查范围。

参考答案：
```python
def valid_score(text):
    try:
        value = int(text)
    except ValueError:
        return None
    if not 0 <= value <= 100:
        return None
    return value

print(valid_score("101"))
print(valid_score("60"))
```

输出：
```text
None
60
```

解析：两阶段防止可解析但业务无效的数据进入后续统计。

## 练习 13_03 正批大小（迁移）

实现 batch_count(total, size)，假定 total 为非负整数，size 为整数；size<=0 抛 ValueError，否则向上取整计算批次数。打印 batch_count(17,8)。

作答文件：practice/ch13/ex13_03.py

提示：先验证 size，再使用整数公式。

参考答案：
```python
def batch_count(total, size):
    if size <= 0:
        raise ValueError("size 必须为正数")
    return (total + size - 1) // size

print(batch_count(17, 8))
```

输出：
```text
3
```

解析：明确 total 的输入前提，不把本题测试范围误当作任意类型都能自动处理。

## 练习 13_04 保留坏行位置（场景）

实现 parse_numbers(lines)，返回 (values, bad_lines)，有效整数字符串进 values，解析失败的 1 起始行号进 bad_lines。打印对 ["1","x","3"] 的结果。

作答文件：practice/ch13/ex13_04.py

提示：用 enumerate，并分别累计两个列表。

参考答案：
```python
def parse_numbers(lines):
    values = []
    bad_lines = []
    for number, line in enumerate(lines, start=1):
        try:
            values.append(int(line))
        except ValueError:
            bad_lines.append(number)
    return values, bad_lines

print(parse_numbers(["1", "x", "3"]))
```

输出：
```text
([1, 3], [2])
```

解析：错误位置也是输出的一部分，用户才能回到原数据修复；不要只说“有错误”。

## 练习 13_05 防止空字符串输入（纠错）

实现 require_name(text)，strip 后为空则抛 ValueError，否则返回清理后的姓名。打印对 " A " 的结果。

作答文件：practice/ch13/ex13_05.py

提示：检查的是清理后的字符串。

参考答案：
```python
def require_name(text):
    name = text.strip()
    if not name:
        raise ValueError("姓名不能为空")
    return name

print(require_name(" A "))
```

输出：
```text
A
```

解析：仅检查原字符串是否非空会放过全空格；先规范化再验证。

## 练习 13_06 只捕获预期解析错误（挑战）

实现 json_object(text)，JSON 解析失败或解析结果不是字典时返回 None，否则返回字典。分别打印对 {"a":1}、[]、bad 的结果。

作答文件：practice/ch13/ex13_06.py

提示：json.loads 后检查 isinstance(data, dict)。

参考答案：
```python
import json

def json_object(text):
    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        return None
    if not isinstance(data, dict):
        return None
    return data

print(json_object('{"a":1}'))
print(json_object('[]'))
print(json_object('bad'))
```

输出：
```text
{'a': 1}
None
None
```

解析：语法合法的 JSON 不一定具有你要求的结构。解析层与结构验证层分别检查，后续才能安全按键访问。

## 练习 13_07 AI / Agent：验证工具调用数据（场景）

实现 read_call(text)：解析 JSON，要求最外层为字典、name 恰好为 "char_count"、arguments 为字典且其中 text 为字符串。成功时返回 arguments["text"]；不满足任一条件时 raise ValueError。空字符串允许。调用 {"name":"char_count","arguments":{"text":"Python"}} 并打印返回文本。

作答文件：practice/ch13/ex13_07.py

提示：分层检查，不要连写 data['arguments']['text'] 后才判断。get 取不到键会得到 None，正好可以进入错误分支。JSON 语法错误本身也是 ValueError 的一种。

参考答案：
```python
import json

def read_call(text):
    data = json.loads(text)
    if not isinstance(data, dict):
        raise ValueError("调用必须是对象")
    if data.get("name") != "char_count":
        raise ValueError("不支持的工具名")
    arguments = data.get("arguments")
    if not isinstance(arguments, dict):
        raise ValueError("arguments 必须是对象")
    content = arguments.get("text")
    if not isinstance(content, str):
        raise ValueError("text 必须是字符串")
    return content

request = '{"name":"char_count","arguments":{"text":"Python"}}'
print(read_call(request))
```

输出：
```text
Python
```

解析：先解析，再按层检查类型和字段，最后才返回允许的数据。本题只负责验证，不执行任何工具。name 的允许值只有 char_count；其他名字应明确拒绝，不能 eval 数据中的文本。空文本是字符串，符合本题约定；数字、null 和缺失字段不符合。额外测试会检查这些分支，并要求错误类型为 ValueError。

## 练习 13_08 作用域修复：统计工具执行成功次数（场景）

坏思路是在函数中直接给外部 total += 1。请改为实现 count_successes(results, start=0)：results 保证是布尔列表，start 保证为非负整数。以 start 为起点，只为 True 增加 1，返回新计数，不修改 results，不依赖全局计数，不打印。设 total=10，用 [True, False, True] 调用后保存到 total 并打印。

作答文件：practice/ch13/ex13_08.py

提示：进入函数先用 count=start 建立局部计数；循环中按结果更新 count，最后 return。外面用 total = count_successes(..., start=total)。

参考答案：
```python
def count_successes(results, start=0):
    count = start
    for succeeded in results:
        if succeeded:
            count += 1
    return count

total = 10
total = count_successes([True, False, True], start=total)
print(total)
```

输出：
```text
12
```

解析：局部 count 有明确初始值，所以 += 有旧值可读。三次判断后依次是 11、11、12；调用者再把返回的 12 存为 total。空列表应原样返回 start；全 False 不增加；重复调用默认 start=0 时不能记住上一次结果。这里的 True/False 是固定输入，只表示练习中的状态标记，不证明真实模型回答质量。

## 练习 13_09 AI / Agent：逐行检查工具日志并记录错误（综合）

实现 read_tool_log(text)，输入保证是字符串。每个非空行应为 JSON 对象，tool 为非空字符串，ok 为 bool。跳过空行；合法记录将 tool.strip() 后保存为只含 tool、ok 的新字典。返回 (records, errors)，errors 每项为 {"line":原始行号,"kind":类别}；坏 JSON 类别为 json，非字典/缺字段/类型错误为 schema，tool 仅空白为 value。每行最多报一个错误，按上述顺序检查。五行依次为 {"tool":" search ","ok":true}、空行、bad-json、{"tool":"count","ok":1}、{"tool":"save","ok":false}。最后打印 records 和 errors，各占一行。

作答文件：practice/ch13/ex13_09.py

提示：沿用示例的 enumerate → 跳空行 → try loads → 检查类型 → 检查取值顺序；ok=1 不是本题允许的 bool。不要在编号之前删除空行。

参考答案：
```python
import json

def read_tool_log(text):
    records, errors = [], []
    for line_number, line in enumerate(text.splitlines(), start=1):
        if not line.strip():
            continue
        try:
            item = json.loads(line)
        except json.JSONDecodeError:
            errors.append({"line": line_number, "kind": "json"})
            continue
        if not isinstance(item, dict):
            errors.append({"line": line_number, "kind": "schema"})
            continue
        tool = item.get("tool")
        ok = item.get("ok")
        if not isinstance(tool, str) or not isinstance(ok, bool):
            errors.append({"line": line_number, "kind": "schema"})
            continue
        if not tool.strip():
            errors.append({"line": line_number, "kind": "value"})
            continue
        records.append({"tool": tool.strip(), "ok": ok})
    return records, errors

text = '\n'.join([
    '{"tool":" search ","ok":true}',
    '',
    'bad-json',
    '{"tool":"count","ok":1}',
    '{"tool":"save","ok":false}',
])
records, errors = read_tool_log(text)
print(records)
print(errors)
```

输出：
```text
[{'tool': 'search', 'ok': True}, {'tool': 'save', 'ok': False}]
[{'line': 3, 'kind': 'json'}, {'line': 4, 'kind': 'schema'}]
```

解析：第 2 行为空，但第 3 行仍编号为 3。第 4 行 ok 是整数 1，不能当作 bool。第 5 行 ok=False 表示日志记录的工具状态失败，但这条日志本身格式合法，所以应保留。日志的格式合格与记录的操作成功是两件事；额外字段按题目约定不进入新记录。本题既不重新执行工具，也不证明日志内容真实。

## 练习 13_10 AI / Agent：汇总本地工具耗时 CSV（迁移）

实现 tool_time_total(text)，严格要求表头为 tool,ms，否则抛出 ValueError（包括空文本）。用 csv.reader 解析，忽略完全空行；逐条按顺序检查：恰好两列、工具名 strip 后非空、ms 能经 int 转换、ms 不小于 0。失败只记录第一项原因，代码依次为 columns、tool、integer、range，位置为该记录结束物理行号。返回 (合法耗时之和, 错误列表)，错误项为 (结束行号, 原因)。不打印，不写文件。打印对 'tool,ms\n"lookup,local",12\n\nfetch,bad\ncache,-1\nsave\nfinish,0\n' 的返回值。严重 CSV 语法错误交给 csv 模块抛出。

作答文件：practice/ch13/ex13_10.py

提示：先读表头；total=0、errors=[]。每次 next(reader) 由 for 自动完成后，reader.line_num 已指向记录结束行。所有检查通过才 total += ms。不要把合法的 0 当作缺失。

参考答案：
```python
import csv
import io

def tool_time_total(text):
    reader = csv.reader(io.StringIO(text, newline=""), strict=True)
    if next(reader, None) != ["tool", "ms"]:
        raise ValueError("表头必须为 tool,ms")
    total, errors = 0, []
    for row in reader:
        end_line = reader.line_num
        if not row:
            continue
        if len(row) != 2:
            errors.append((end_line, "columns"))
            continue
        if not row[0].strip():
            errors.append((end_line, "tool"))
            continue
        try:
            ms = int(row[1].strip())
        except ValueError:
            errors.append((end_line, "integer"))
            continue
        if ms < 0:
            errors.append((end_line, "range"))
            continue
        total += ms
    return total, errors

text = 'tool,ms\n"lookup,local",12\n\nfetch,bad\ncache,-1\nsave\nfinish,0\n'
print(tool_time_total(text))
```

输出：
```text
(12, [(4, 'integer'), (5, 'range'), (6, 'columns')])
```

解析：两条合法记录的耗时为 12 与 0，所以合计 12。空行不参与求和，错误仍对应第 4、5、6 物理行。缺失第二列属于 columns，存在第二列但为空属于 integer。额外列也必须拒绝。只有表头返回 (0, [])；空文本连表头都没有，应抛出 ValueError。这是人为填写的离线日志，毫秒不是 token；记录格式合法和耗时非负，不代表工具执行成功，也不代表最终任务正确。

## 记忆要点
- 先看最后一行，再找自己的代码行号。
- 抓具体异常，记录原因，不静默吞错。
- 测试正常、空、边界、错误输入和副作用。

## 通关检查
- 我能区分 SyntaxError、TypeError 和 ValueError。
- 我能写出一个合法输入和一个非法输入测试。
- 我能根据自检失败结果定位到自己的实现。
- 我能区分 JSON 解析错误、字段类型错误和业务取值错误。
- 我能解释 CSV 空行、缺列、空字段与数字 0 的区别。


# 14 类 对象与方法

为读懂数据集、模型和训练器代码建立基础。

## 学习目标

- 区分类与实例
- 理解 __init__、self 和实例属性
- 知道何时简单函数已经足够

### 先从熟悉对象出发
你已经用过 `names.append(...)` 和 `text.strip()`：数据与可执行操作放在对象上。类定义一类对象的结构与行为，实例是按这个类创建出的具体对象。例如 StudyRecord 是类，“小林今天学了 45 分钟”的记录是一个实例。

不要因为学了类就把所有函数都改写成类。没有持续状态、只做一次输入到输出转换时，普通函数通常足够。多个操作围绕同一份状态协作时，类比较自然。

### __init__ 与 self
`class Counter:` 定义类。`def __init__(self, start=0): self.value = start` 在实例初始化时设置状态。`counter = Counter(3)` 创建实例并初始化为 3；你不手动传 self，Python 的方法调用机制会提供当前实例。

`counter.add(2)` 可理解为让 Counter.add 接收 counter 作为 self，再接收 2。self 是约定俗成的参数名，应保持这个惯例。`self.value` 是实例上的属性；局部变量 value 与 self.value 不是同一个名字。

### 各实例应有各自的可变状态
在 __init__ 里写 `self.items = []`，每次创建对象都会新建列表。若直接在 class 缩进下写 `items = []`，它是类属性，可能被多个实例共享。这个陷阱与函数可变默认参数类似：你以为每个对象新建了一份，实际上只创建了一次。

类属性适合共享常量等状态，例如课程名称；实例属性适合个人进度、账户余额、单个数据集记录。

### 方法也要有明确约定
`add(minutes)` 可以检查分钟数是否为非负整数，合法才更新状态；`average()` 可以在没有记录时返回 None。类内部也会遇到空数据、输入验证与副作用问题，不能因为包了一层 class 就省略这些思考。

### 阅读特殊方法
`__len__` 支持 len(obj)，`__repr__` 返回开发者友好的字符串表示，`__getitem__` 支持 obj[index]。后续 PyTorch 数据集常使用 `__len__` 和 `__getitem__`；这一章只学习对应的 Python 机制，不需要安装深度学习库。

### 继承先会读就够
`class Dog(Animal):` 表示 Dog 从 Animal 继承可用行为，子类也可以定义同名方法覆盖它。`super()` 常用于调用父类相关实现。第一阶段重心是对象状态、方法调用和组合，复杂继承、多重继承与元类暂时不用展开。

组合是“一个对象持有另一个对象或数据”，例如一个 StudyTracker 持有 records 列表。真实程序中它通常比构造很深的继承树更直白。

### 精讲：self 是哪一个对象
对本章普通实例方法，调用 `a.add("hello")` 时，方法中的 self 就是 a；调用 `b.add("hello")` 时，self 就是 b。self 是惯用的形参名，不是“所有实例共用的总对象”。

`self.messages = []` 放在 `__init__` 中，每新建一个实例就执行一次，创建各自的列表。如果把 `messages = []` 直接放在 class 缩进内、方法外，这个类属性通常会被尚未设置同名实例属性的实例共同访问。聊天程序中，这会造成不同会话混用记录。

检查这种错误时不要只建一个对象。建立 a、b 两个对象，只向 a 添加一条记录，然后分别检查两者长度。a 应为 1，b 应为 0。再考虑返回值：若方法直接 return 内部列表，调用者就可以从外部修改内部状态。后面的练习要求返回列表长度，使这个约定简单而明确。

第 07 章对象关系、第 09 章参数、第 14 章 self 讲的是同一个基础：名字指向对象，修改对象与重新绑定名字要分清。先在纸上画箭头，再看代码。

## 示例 14_01 最小计数器

路径：examples/ch14/ex14_01.py

```python
class Counter:
    def __init__(self, start=0):
        self.value = start

    def add(self, amount=1):
        self.value += amount

counter = Counter(3)
counter.add()
counter.add(2)
print(counter.value)
```

输出：
```text
6
```

解析：初始化 3，默认加 1，再加 2。value 是这个实例保存的状态；方法本身没有 return，调用结果为 None。

改写：创建第二个 Counter，验证它不受第一个影响。

## 示例 14_02 实例各自保存记录

路径：examples/ch14/ex14_02.py

```python
class StudyLog:
    def __init__(self):
        self.minutes = []

    def add(self, value):
        self.minutes.append(value)

    def total(self):
        return sum(self.minutes)

a = StudyLog()
b = StudyLog()
a.add(30)
print(a.total(), b.total())
```

输出：
```text
30 0
```

解析：两个 __init__ 分别创建列表，因此 a 的追加不会进入 b。

改写：把 minutes=[] 移成类属性，观察共享问题后恢复。

## 示例 14_03 方法中的校验

路径：examples/ch14/ex14_03.py

```python
class Wallet:
    def __init__(self):
        self.balance_fen = 0

    def deposit(self, amount_fen):
        if amount_fen < 0:
            raise ValueError("存入金额不能为负")
        self.balance_fen += amount_fen

wallet = Wallet()
wallet.deposit(1500)
print(wallet.balance_fen)
```

输出：
```text
1500
```

解析：金额使用整数分。验证发生在修改前，避免非法输入已经改变状态后才报错。

改写：传入 -1 并捕获错误，再确认余额不变。

## 示例 14_04 数据集接口预习

路径：examples/ch14/ex14_04.py

```python
class TextDataset:
    def __init__(self, texts):
        self.texts = list(texts)

    def __len__(self):
        return len(self.texts)

    def __getitem__(self, index):
        return self.texts[index]

dataset = TextDataset(["hello", "python"])
print(len(dataset))
print(dataset[1])
```

输出：
```text
2
python
```

解析：len 会调用 __len__，索引访问会调用 __getitem__。这里仅保存文本，未涉及 token 化或模型张量。

改写：传入一个原列表后修改原列表，观察 list(texts) 的浅复制效果。

## 示例 14_05 简单继承

路径：examples/ch14/ex14_05.py

```python
class Reporter:
    def describe(self):
        return "普通报告"

class StudyReporter(Reporter):
    def describe(self):
        return "学习报告"

print(Reporter().describe())
print(StudyReporter().describe())
```

输出：
```text
普通报告
学习报告
```

解析：子类覆盖同名方法。调用时看具体对象所属类型提供的实现。

改写：给父类加另一个方法，观察子类未覆盖时是否能调用。

## 示例 14_06 AI / Agent：两个会话不能共用消息列表

路径：examples/ch14/ex14_06.py

```python
class ChatSession:
    def __init__(self, name):
        self.name = name
        self.messages = []

    def add(self, text):
        self.messages.append(text)
        return len(self.messages)

alice = ChatSession("Alice")
bob = ChatSession("Bob")
count = alice.add("我在学习 Python")
print(alice.name, count)
print(bob.name, len(bob.messages))
print(alice.messages is bob.messages)
```

输出：
```text
Alice 1
Bob 0
False
```

解析：每次 ChatSession(...) 都调用 __init__，其中 [] 各创建一次。alice.add 调用时 self 是 alice，只修改她的 messages；方法把新长度 return 给 count。最后的 is 比较是否为同一对象，不是比较内容是否相等。两个会话当前只在内存保存消息；关闭程序后要靠第 12 章的文件操作恢复。

改写：在临时副本里把 messages = [] 移到类的顶层并删除 self.messages 初始化，重新运行并解释结果变化。随后恢复实例属性写法。

## 练习 14_01 步数计数器（基础）

实现 StepCounter 类，初始 steps=0，add(count) 累加步数；本题 count 约定非负整数。创建对象加 100 再加 50，打印 steps。

作答文件：practice/ch14/ex14_01.py

提示：状态放在 self.steps。

参考答案：
```python
class StepCounter:
    def __init__(self):
        self.steps = 0

    def add(self, count):
        self.steps += count

counter = StepCounter()
counter.add(100)
counter.add(50)
print(counter.steps)
```

输出：
```text
150
```

解析：用两个实例测试状态隔离；仅打印一个对象无法发现所有共享状态问题。

## 练习 14_02 学习记录平均值（场景）

实现 StudyLog 类，实例 minutes 为空列表，add(value) 追加，mean() 空时 None，否则平均值。添加 30、60 后打印 mean()。

作答文件：practice/ch14/ex14_02.py

提示：每个对象在 __init__ 创建自己的列表。

参考答案：
```python
class StudyLog:
    def __init__(self):
        self.minutes = []

    def add(self, value):
        self.minutes.append(value)

    def mean(self):
        if not self.minutes:
            return None
        return sum(self.minutes) / len(self.minutes)

log = StudyLog()
log.add(30)
log.add(60)
print(log.mean())
```

输出：
```text
45.0
```

解析：方法组合前面学过的列表追加和安全平均值。类新增的是状态归属，不是全新的运算规则。

## 练习 14_03 可索引的记录集（迁移）

实现 NumberDataset 类，构造时复制 values 到实例，支持 len 和 [index]。用 [10,20] 创建后依次打印长度与索引 0 的值。

作答文件：practice/ch14/ex14_03.py

提示：实现 __len__、__getitem__。

参考答案：
```python
class NumberDataset:
    def __init__(self, values):
        self.values = list(values)

    def __len__(self):
        return len(self.values)

    def __getitem__(self, index):
        return self.values[index]

data = NumberDataset([10, 20])
print(len(data))
print(data[0])
```

输出：
```text
2
10
```

解析：复制外层列表避免调用者后来 append 改变数据集长度；嵌套对象仍是浅复制。

## 练习 14_04 只接受有效分数（场景）

实现 GradeBook，实例 scores=[]，add(score) 接受 0—100 的整数，越界抛 ValueError 且不修改状态。这里只要求范围验证。添加 80 后打印 scores。

作答文件：practice/ch14/ex14_04.py

提示：检查应在 append 前面。

参考答案：
```python
class GradeBook:
    def __init__(self):
        self.scores = []

    def add(self, score):
        if not 0 <= score <= 100:
            raise ValueError("分数超出范围")
        self.scores.append(score)

book = GradeBook()
book.add(80)
print(book.scores)
```

输出：
```text
[80]
```

解析：本题输入类型约定为整数，所以只做范围校验。若对外部 JSON 开放，还需额外验证类型。

## 练习 14_05 AI / Agent：有条数上限的会话记忆（场景）

实现 RecentMessages(limit)。limit 必须为大于 0 的 int，bool 不接受，否则 raise ValueError。每个实例有自己的 messages 列表；add(text) 只接受字符串，添加后保留最后 limit 条，返回当前条数。用 limit=2 依次添加 "a"、"b"、"c"，打印 messages。

作答文件：practice/ch14/ex14_05.py

提示：先完成参数验证，再初始化实例列表。超过上限时可以用切片 self.messages[-self.limit:]。验证应在修改状态之前。

参考答案：
```python
class RecentMessages:
    def __init__(self, limit):
        if type(limit) is not int or limit <= 0:
            raise ValueError("limit 必须为正整数")
        self.limit = limit
        self.messages = []

    def add(self, text):
        if not isinstance(text, str):
            raise ValueError("text 必须为字符串")
        self.messages.append(text)
        self.messages = self.messages[-self.limit:]
        return len(self.messages)

session = RecentMessages(2)
session.add("a")
session.add("b")
session.add("c")
print(session.messages)
```

输出：
```text
['b', 'c']
```

解析：前三次添加后的列表依次为 ['a']、['a','b']、['b','c']。切片创建新列表，再绑定给这个实例的属性。两个实例必须分别初始化；非法添加不能先污染已有记录再报错。这里只按消息条数截取，不按 token 总量，也没有保证对话语义完整；真实上下文管理需要另外设计。

## 记忆要点
- 类是定义，实例是具体对象。
- self 指当前实例，self.x 是它的属性。
- 每个实例自己的列表放进 __init__ 创建。

## 通关检查
- 我能建立两个互不影响的计数器对象。
- 我能解释为什么方法定义有 self，调用时却不传它。
- 我能读懂一个实现 __len__ 与 __getitem__ 的数据集类。


# 15 读懂进阶 Python 写法

这一章先练阅读能力，暂时不追求所有写法都背下来。

## 学习目标

- 理解迭代器与生成器的一次性消费
- 读懂类型标注、星号解包和参数收集
- 能把紧凑代码还原成执行步骤

### 可迭代对象与迭代器
列表、字符串、字典等能被 for 遍历，叫可迭代对象。`iter(values)` 取得迭代器，`next(iterator)` 取下一个元素；取完再取会抛 StopIteration。for 会自动处理这个终止信号。

列表通常可以多次遍历，每次重新获得迭代器；同一个迭代器消费过的内容不会自动恢复。`zip`、`enumerate`、生成器也有类似的一次性消费特征。调试时先 `list(iterator)` 看内容，可能已经把后续循环要用的数据消费完了。

### yield 一次给一个
函数中出现 yield，它就成为生成器函数。调用生成器函数通常先得到生成器对象，代码在迭代取值时逐步执行。执行到 yield 暂停并交出当前值，下次继续从暂停处往后走，局部状态得以保留。

`return` 结束普通函数并给出最终结果；生成器中的 return 结束迭代。yield 是“这次先给一个，后面还可能有”。它避免一次保存全部输出，但输入本身是否已经全部在内存、内部是否保留大量状态，仍影响内存使用。

### 列表推导式与生成器表达式
`[x*x for x in values]` 立刻生成完整列表；`(x*x for x in values)` 产生按需计算的生成器。sum、any、all 等可以逐个消费生成器，通常无需额外列表。生成器不能直接 len，也不能普通索引访问。

### 星号在不同位置含义不同
赋值里的 `first, *middle, last = values` 收集剩余项；调用里的 `func(*args)` 把可迭代对象展开为位置参数，`func(**kwargs)` 把映射展开为关键字参数；定义里的 `def func(*args, **kwargs)` 收集额外参数为元组和字典。不要把这些星号与数字乘法混为一谈。

默认参数、位置参数、关键字参数的复杂组合以后可以继续学。当前目标是读懂训练代码中 `model(**batch)` 表示按批次字典的键把值作为命名参数传入，具体键必须符合函数接口。

### 类型标注是说明，不是自动转换
`def mean(values: list[float]) -> float | None:` 表明希望接收浮点列表，返回浮点或 None。Python 默认不会因为标注自动检查或转换输入；`"3"` 不会自动变成 3.0。实际验证需要 if/raise，静态类型检查则是额外工具。

本教材使用的 `list[...]` 与 `| None` 写法要求相应现代 Python 版本，整体学习包按 Python 3.10+ 编写。类型标注可以逐步添加，先保证函数的实际行为正确。

### with 与装饰器先会辨认
with 把一段需要进入和退出管理的工作包起来，例如打开与关闭文件。后续可能看到禁用梯度等上下文，其行为由具体库定义，不是所有 with 都在处理文件。

函数上方的 `@something` 是装饰器语法，可理解为函数定义完成后再交给 something 包装并绑定回来。第一阶段不必自己构造复杂装饰器；先查具体装饰器的官方说明，别凭 @ 符号猜它做什么。

### 暂时可以放后的主题
异步编程、并发、复杂正则、元类、多重继承、描述符和性能优化不是本阶段通关要求。看到陌生语法先确认输入、输出和调用路径；一个可运行的 10 行实验比一次背完语言参考更有效。

## 示例 15_01 迭代器逐项取值

路径：examples/ch15/ex15_01.py

```python
iterator = iter([10, 20, 30])
print(next(iterator))
print(list(iterator))
print(list(iterator))
```

输出：
```text
10
[20, 30]
[]
```

解析：第一个元素已由 next 消费；第一次 list 消费剩余两项；最后再消费已无内容。

改写：重新对原列表调用 iter，确认可以重新开始。

## 示例 15_02 生成器暂停

路径：examples/ch15/ex15_02.py

```python
def countdown(start):
    while start > 0:
        yield start
        start -= 1

numbers = countdown(3)
print(next(numbers))
print(list(numbers))
```

输出：
```text
3
[2, 1]
```

解析：第一次 next 运行到 yield 3；下一次从后面的 start -= 1 继续，不会从函数第一行重来。

改写：在 yield 前后各加 print，观察真实执行时机。

## 示例 15_03 星号解包

路径：examples/ch15/ex15_03.py

```python
first, *middle, last = [1, 2, 3, 4]
print(first, middle, last)

def add(a, b):
    return a + b

print(add(*[2, 3]))
print(add(**{"a": 5, "b": 6}))
```

输出：
```text
1 [2, 3] 4
5
11
```

解析：赋值时收集到 middle 列表；调用时 * 展开位置，** 展开键值为命名参数。

改写：尝试给 ** 字典增加不存在的键，阅读调用错误。

## 示例 15_04 收集可变参数

路径：examples/ch15/ex15_04.py

```python
def describe(*args, **kwargs):
    print(args)
    print(kwargs)

describe("A", "B", score=90)
```

输出：
```text
('A', 'B')
{'score': 90}
```

解析：args 是元组，kwargs 是字典。名字可更改，但这两个名称是常用惯例。

改写：将位置参数变成 0 个，观察空元组。

## 示例 15_05 带标注的函数

路径：examples/ch15/ex15_05.py

```python
def average(values: list[float]) -> float | None:
    if not values:
        return None
    return sum(values) / len(values)

print(average([1.0, 3.0]))
print(average([]))
```

输出：
```text
2.0
None
```

解析：标注让接口更容易阅读，函数体仍需要自己处理空列表。标注也不会强制每项自动变成 float。

改写：自己用错误类型调用，确认真正失败发生在哪个操作。

## 示例 15_06 AI / Agent：用生成器模拟逐段到达的文字

路径：examples/ch15/ex15_06.py

```python
def simulated_stream():
    yield "先学"
    yield " Python"
    yield "，再做项目。"

pieces = []
stream = simulated_stream()
for piece in stream:
    pieces.append(piece)
    print(f"收到：{piece}")
print("完整：" + "".join(pieces))
print(list(stream))
```

输出：
```text
收到：先学
收到： Python
收到：，再做项目。
完整：先学 Python，再做项目。
[]
```

解析：调用 simulated_stream 得到生成器，此时还未产出任何片段。for 每次索取下一个值，函数执行到 yield 暂停；下一轮从暂停处继续。遍历结束后，这个 stream 已耗尽，再转成列表是 []；重新调用函数才能得到新的生成器。片段是预设字符串，不是实际模型 token，也没有真实网络延迟。join 用空字符串连接，保留片段自身的空格。

改写：把 join 的分隔符改成一个空格，观察为何会多出空格。再创建一个新的 stream，验证它能重新输出全部片段。

## 练习 15_01 偶数生成器（基础）

实现 even_numbers(stop)，使用 yield 产生 0 到 stop 之前的偶数，stop 为非负整数。打印 list(even_numbers(7))。

作答文件：practice/ch15/ex15_01.py

提示：range 的步长用 2。

参考答案：
```python
def even_numbers(stop):
    for number in range(0, stop, 2):
        yield number

print(list(even_numbers(7)))
```

输出：
```text
[0, 2, 4, 6]
```

解析：函数返回生成器，list 在展示时消费它。空范围自然产生空列表。

## 练习 15_02 按批产生列表（场景）

实现 batches(values,size)，size 是整数，<=0 时在消费时抛 ValueError；每次 yield 一个至多 size 项的切片，保留最后不足一批的数据。打印对 [1,2,3,4,5] 与 2 的结果。

作答文件：practice/ch15/ex15_02.py

提示：range(0,len(values),size)，再切 [start:start+size]。

参考答案：
```python
def batches(values, size):
    if size <= 0:
        raise ValueError("size 必须为正数")
    for start in range(0, len(values), size):
        yield values[start:start + size]

print(list(batches([1, 2, 3, 4, 5], 2)))
```

输出：
```text
[[1, 2], [3, 4], [5]]
```

解析：最后切片自动截到结尾。生成器中的验证在迭代开始时执行，而不是仅调用函数拿到对象时执行。

## 练习 15_03 配置展开调用（迁移）

实现 format_run(name,epochs)，返回 "名称:轮数"。用 config={"name":"demo","epochs":3} 的 ** 展开调用并打印。

作答文件：practice/ch15/ex15_03.py

提示：字典键必须与形参名匹配。

参考答案：
```python
def format_run(name, epochs):
    return f"{name}:{epochs}"

config = {"name": "demo", "epochs": 3}
print(format_run(**config))
```

输出：
```text
demo:3
```

解析：函数不知道参数是否来自字典展开，它收到的只是相应绑定。

## 练习 15_04 生成器消费观察（推理）

建立 (x*x for x in range(3))，连续两次 print(list(g))，写出结果并解释。

作答文件：practice/ch15/ex15_04.py

提示：第一次 list 取走所有元素。

参考答案：
```python
g = (x * x for x in range(3))
print(list(g))
print(list(g))
```

输出：
```text
[0, 1, 4]
[]
```

解析：想重新得到数据需要新建生成器，或第一次就保留生成的列表。不要把已经被日志打印消耗的生成器继续当作完整输入。

## 练习 15_05 AI / Agent：从事件流中提取文字片段（迁移）

实现 text_pieces(events) 生成器。events 中每项保证是字典；只 yield kind 为 "text" 且 content 为非空字符串的内容，跳过其他项。用 [{"kind":"start"}, {"kind":"text","content":"Py"}, {"kind":"usage","content":3}, {"kind":"text","content":"thon"}] 生成片段并拼接打印。

作答文件：practice/ch15/ex15_05.py

提示：先判 kind，再取 content 并检查类型与是否等于空字符串。不要 strip 后再输出，文本片段的空格可能是正文的一部分。

参考答案：
```python
def text_pieces(events):
    for event in events:
        if event.get("kind") != "text":
            continue
        content = event.get("content")
        if isinstance(content, str) and content != "":
            yield content

events = [
    {"kind": "start"},
    {"kind": "text", "content": "Py"},
    {"kind": "usage", "content": 3},
    {"kind": "text", "content": "thon"},
]
print("".join(text_pieces(events)))
```

输出：
```text
Python
```

解析：start 和 usage 是非文本事件，不能把整个事件字典都拼进答案。生成器只产出 Py 和 thon，join 后得到 Python。额外测试保留单个空格片段、跳过空串和非字符串内容。本题使用自定义事件格式；实际 SDK 的事件名称与字段可能不同。

## 记忆要点
- 列表可反复遍历，同一迭代器会被消费。
- yield 给一次结果并暂停，return 结束。
- 标注是约定，不会自动转换或验证。

## 通关检查
- 我能解释为什么 list(generator) 第二次为空。
- 我能把 **config 看成关键字参数展开。
- 我能写一个每次产生一批数据的生成器。


# 16 面向 AI 的 Python 数据实操

把前面的语法组合成真实的数据处理步骤。

## 学习目标

- 建立清洗、验证、分割、编码与分批的顺序
- 区分文本、词、token ID 与张量
- 实现简单指标并识别数据泄漏风险

### 先看清数据走过哪些步骤
原始记录 → 检查字段类型 → 文本清洗 → 去空与去重 → 划分数据集 → 仅用训练数据建立统计规则 → 转换为数值表示 → 分批。这个顺序不是所有项目唯一答案，但它能帮助你识别哪些步骤可能把测试信息带入训练。

这一章所有数据都是很小的教学样本，只验证 Python 逻辑。它们不能支持模型效果结论，也不构成完整的深度学习训练系统。

### 文本清洗不要破坏任务信息
空白标准化可以 `" ".join(text.split())`。小写化对英文词频方便，但有时大小写本身有意义。数字、标点、代码缩进、换行对不同任务意义不同，不应统一无脑删除。先写明本次实验的清洗规则，再保存原始数据以便回溯。

### 词频不等于大模型 token 化
这里的 `text.split()` 只是按空白分词。中文一句话可能没有空格，专业 tokenizer 还可能把单词拆成子词、字节或其他单元。字典映射得到的 toy token ID 只供理解，不可直接喂给任意预训练模型；每个模型需要与其配套的 tokenizer 和特殊 token 约定。

### 划分时让记录保持完整
不要把 texts 和 labels 分别随机打乱，否则对应关系可能丢失。把它们放在同一条字典记录内，打乱记录列表，再切分。需要复现时使用局部随机生成器并记录 seed。

随机划分不是所有数据的正确策略。时间序列通常考虑时间先后；同一用户、病人、文档来源产生的相近记录通常要按组隔离；重复文本也可能让训练测试互相泄漏。先定义什么是独立样本，再选划分方式。

### 为什么词表只从训练集建立
假设测试集中出现从未见过的词，模型真实使用时也会遇到这种情况。若提前看全量数据建立词表或统计量，就把测试信息带入了预处理。教学管道中用训练集建词表，验证和测试遇到新词映射到 `<UNK>`。

同样原则适用于基于数据学习的均值、标准差、特征选择等。固定的、与样本无关的字符规则是另一类处理，但仍要明确记录。

### 形状与批大小
列表 `[1,2,3]` 可类比长度为 3 的一维数据；`[[1,2],[3,4]]` 可类比 2×2。Python 嵌套列表不自动保证每行等长，而真实张量的规则由数值库定义。常见文本批次有“批大小 × 序列长度”，之后才可能再有特征维度。先说清每一轴的含义，再谈 shape。

padding 是把短序列补到约定长度，attention mask 常用来标记哪些位置是真实内容；具体 0/1 含义需查所用模型接口。这里把 pad ID 设为 0，真实词 ID 从 2 开始，1 留给未知词，只是本课程自定义规则。

### 指标的分母决定含义
分类准确率是预测等于真实标签的个数除以样本数。长度必须一致；空集合的指标要明确约定。严重类别不平衡时，准确率可能具有误导性，后续还需学习精确率、召回率和 F1。不要把一个简单指标当作模型能力的完整描述。

### 这一阶段到下一阶段的桥
你现在应能读懂列表推导式、字典批次、函数参数、类方法和迭代器。下一阶段再学 NumPy 数组、广播与矩阵乘法，接着 PyTorch 张量、自动求导、Dataset/DataLoader，最后才是 tokenizer、Transformer 和训练/推理流程。新库安装时按其当时的官方兼容矩阵选择 Python 与硬件配置；本教材不预设未来深度学习环境的版本。

### 开源项目启发：先把工具流程拆成能单独检查的部分
参考微软 AI Agents for Beginners 的工具使用章节，我们把“提出动作”和“执行动作”分别写清楚。后面的自编离线示例用 decide 选择动作，用 search_notes 执行检索。把函数分开后，可以先检查工具本身，再检查控制流程；接入真实模型时，也更容易发现问题发生在哪一层。

| 时刻 | 输入或已有状态 | 本步动作 | 可检查的结果 |
| --- | --- | --- | --- |
| 开始 | query 是“怎么理解函数？” | observation 初始化为 None | 还没有执行检索 |
| 第 1 步 | observation 为 None | decide 提出 search，执行器调用工具 | 得到正文与 note-02 来源 |
| 第 2 步 | observation 已有工具结果 | decide 提出 finish | 输出正文与来源，停止 |
| 只允许 1 步 | 第 1 步后预算用完 | 不再执行第 2 步 | 报告尚未完成，不能冒充已交付 |

参考 smolagents 的循环实现，我们保留两个独立条件：是否已经结束、是否还有执行步数。这里仅借鉴可检查的循环结构，所有代码和数据均为本教材重新编写。工具超时、网络失败、真实模型调用还没有实现，后续阶段再逐层补齐。

### 从文本到模型：提前分清四种数量
LLMs-from-scratch 的文本处理章节把文本、token ID、取样与嵌入逐步连接起来。当前阶段先练清楚数据的形状，后续再学习算法和 PyTorch。

| 名称 | 此阶段可以观察什么 | 不应混淆什么 |
| --- | --- | --- |
| 字符串长度 | len("hi AI") 为 5 | 不代表模型 token 数 |
| 空白分词数 | "hi AI".split() 得到 2 项 | 不是通用 tokenizer |
| 词表 ID | 自己给词分配整数编号 | 编号不是向量，也没有“越大越相似”的意义 |
| batch 大小 | 一次取几条样本 | 不是每条样本长度 |

本章原有的词表、填充和批次示例继续使用教学用小数据。完成后再阅读真实 tokenizer 的输出，比较同一个字符串为什么可能被分成不同数量的 token。

### 实操：把“流程跑通”与“任务做对”分别验收
为后面的检索流程写四张测试卡：已知关键词、未知关键词、零步预算、一步预算。每张卡先写你预期的 status、answer 和 steps，再运行。本章新增综合题会自动检查这些边界。

再手动试“列表和函数有什么不同？”：示例检索器只取第一个命中，不能完整回答比较题。把它记在错题本中，说明“程序按规则执行成功”与“满足用户需求”为什么不同。未来加入模型后，这种对照样例仍有用，不能只根据程序没有报错来判断回答质量。

## 示例 16_01 清洗并保序去重

路径：examples/ch16/ex16_01.py

```python
raw = ["  Hello   Python ", "", "hello python", "Learn AI"]
seen = set()
cleaned = []
for text in raw:
    clean = " ".join(text.lower().split())
    if clean and clean not in seen:
        seen.add(clean)
        cleaned.append(clean)
print(cleaned)
```

输出：
```text
['hello python', 'learn ai']
```

解析：先规范化，才能识别空白和大小写差异造成的重复。保留第一次出现顺序；原始输入仍保存着。

改写：解释对源代码文本直接使用这个规则会有什么问题。

## 示例 16_02 记录整体划分

路径：examples/ch16/ex16_02.py

```python
import random
records = [{"id": i, "label": i % 2} for i in range(6)]
shuffled = records.copy()
random.Random(42).shuffle(shuffled)
train = shuffled[:4]
test = shuffled[4:]
print(len(train), len(test))
print(set(row["id"] for row in train).isdisjoint(row["id"] for row in test))
print([row["id"] for row in records])
```

输出：
```text
4 2
True
[0, 1, 2, 3, 4, 5]
```

解析：copy 保护原顺序，记录中的 id 与 label 一起移动。这里只检查 ID 无重叠，完整数据泄漏检查还要考虑内容和来源。

改写：让不同 ID 的文本完全相同，解释为什么 ID 检查不够。

## 示例 16_03 训练词表与未知词

路径：examples/ch16/ex16_03.py

```python
train_texts = ["hello python", "hello world"]
vocab = {"<PAD>": 0, "<UNK>": 1}
for text in train_texts:
    for word in text.split():
        if word not in vocab:
            vocab[word] = len(vocab)
encoded = [vocab.get(word, vocab["<UNK>"]) for word in "hello ai".split()]
print(vocab)
print(encoded)
```

输出：
```text
{'<PAD>': 0, '<UNK>': 1, 'hello': 2, 'python': 3, 'world': 4}
[2, 1]
```

解析：ai 不在训练词表，因此映射到 1。词表只从 train_texts 建立。真实模型不能随意使用这个自建词表代替其配套 tokenizer。

改写：添加一个训练词后重新建表，观察 ID 是否受顺序影响。

## 示例 16_04 补齐与掩码

路径：examples/ch16/ex16_04.py

```python
sequences = [[2, 3, 4], [2]]
width = max(len(sequence) for sequence in sequences)
padded = []
masks = []
for sequence in sequences:
    padding = width - len(sequence)
    padded.append(sequence + [0] * padding)
    masks.append([1] * len(sequence) + [0] * padding)
print(padded)
print(masks)
```

输出：
```text
[[2, 3, 4], [2, 0, 0]]
[[1, 1, 1], [1, 0, 0]]
```

解析：每行长度变成批内最大长度。mask=1 表示本例真实位置，0 表示补齐位置。此例假定批次非空，练习中处理空输入。

改写：加入空序列，确认该行全部为 padding。

## 示例 16_05 准确率与对齐

路径：examples/ch16/ex16_05.py

```python
def accuracy(predictions, labels):
    if len(predictions) != len(labels):
        raise ValueError("长度不一致")
    if not labels:
        return None
    correct = sum(pred == label for pred, label in zip(predictions, labels))
    return correct / len(labels)

print(f"{accuracy([1, 0, 1, 1], [1, 1, 1, 0]):.1%}")
```

输出：
```text
50.0%
```

解析：布尔值参与 sum 时 True 当作 1，False 当作 0。先检查长度，避免 zip 的截短悄悄忽略样本。

改写：构造全部猜主要类别但准确率很高的例子，说明指标局限。

## 示例 16_06 二维形状检查

路径：examples/ch16/ex16_06.py

```python
rows = [[1, 2], [3, 4], [5, 6]]
width = len(rows[0]) if rows else 0
is_rectangular = all(len(row) == width for row in rows)
print(len(rows), width)
print(is_rectangular)
```

输出：
```text
3 2
True
```

解析：外层长度是行数，内层长度是列数；所有行等长才符合本例矩形结构。Python 列表本身不会强制这一规则。

改写：把最后一行改为 [5]，确认检查能发现不齐。

## 示例 16_07 AI / Agent：串起决策、工具、观察和结束

路径：examples/ch16/ex16_07.py

```python
def search_notes(query):
    notes = [
        {"keyword": "列表", "text": "列表保存有顺序的元素。", "source": "note-01"},
        {"keyword": "函数", "text": "return 把结果交回调用位置。", "source": "note-02"},
    ]
    for note in notes:
        if note["keyword"] in query:
            return {"text": note["text"], "source": note["source"]}
    return {"text": "没有找到相关笔记。", "source": None}

def decide(state):
    if state["observation"] is None:
        return {"action": "search", "query": state["query"]}
    return {"action": "finish"}

state = {"query": "怎么理解函数？", "observation": None}
finished = False
for step in range(1, 4):
    decision = decide(state)
    print(f"第 {step} 步：{decision['action']}")
    if decision["action"] == "search":
        state["observation"] = search_notes(decision["query"])
    elif decision["action"] == "finish":
        print(state["observation"]["text"])
        print(f"来源：{state['observation']['source']}")
        finished = True
        break
    else:
        raise ValueError("未知动作")
if not finished:
    print("达到步数上限，尚未完成")
```

输出：
```text
第 1 步：search
第 2 步：finish
return 把结果交回调用位置。
来源：note-02
```

解析：输入 query 进入 state；第一次 decide 看见 observation 为 None，提出 search 动作；执行器调用工具 search_notes，把结果写回 observation；第二次 decide 因已有结果而选择 finish；执行器输出结果和来源后结束。决策函数在这里完全由规则实现；没有模型推理、向量检索或生成式回答。检索只返回第一个命中的关键词笔记，能追踪来源不等于覆盖所有问题。固定上限可以限制此循环执行次数，但不能中断某一次已经卡住的工具函数。

改写：把问题改成“什么是张量？”，观察无命中时的结果。把 range(1, 4) 改为 range(1, 2)，确认工具虽执行了但流程还未 finish。最后解释：为什么只把一个函数命名为 agent 并不自动获得规划能力？

## 练习 16_01 文本清洗函数（场景）

实现 normalize(text)，转小写并将连续空白变成单空格，去两端空白。打印对 "  Hello   AI  " 的结果。

作答文件：practice/ch16/ex16_01.py

提示：lower、split、join。

参考答案：
```python
def normalize(text):
    return " ".join(text.lower().split())

print(normalize("  Hello   AI  "))
```

输出：
```text
hello ai
```

解析：规则应被记录并保持一致。对训练与推理文本使用同一预处理约定，避免流程不一致。

## 练习 16_02 训练词表（场景）

实现 build_vocab(texts)，初始 {"<PAD>":0,"<UNK>":1}，按首次出现为按空白分出的词分配 ID；文本已预处理。打印 ["a b","a c"] 的结果。

作答文件：practice/ch16/ex16_02.py

提示：未出现才用当前字典长度分配。

参考答案：
```python
def build_vocab(texts):
    vocab = {"<PAD>": 0, "<UNK>": 1}
    for text in texts:
        for word in text.split():
            if word not in vocab:
                vocab[word] = len(vocab)
    return vocab

print(build_vocab(["a b", "a c"]))
```

输出：
```text
{'<PAD>': 0, '<UNK>': 1, 'a': 2, 'b': 3, 'c': 4}
```

解析：保留两个特殊 ID；同词再次出现不分配新 ID。只对训练集调用这个建表函数。

## 练习 16_03 未知词编码（基础）

实现 encode(text,vocab)，按空白拆词，用 vocab["<UNK>"] 处理未知词。打印 "a x" 在 {"<UNK>":1,"a":2} 下的结果。

作答文件：practice/ch16/ex16_03.py

提示：get 默认未知词 ID。

参考答案：
```python
def encode(text, vocab):
    return [vocab.get(word, vocab["<UNK>"]) for word in text.split()]

print(encode("a x", {"<UNK>": 1, "a": 2}))
```

输出：
```text
[2, 1]
```

解析：本题约定词表含 <UNK>。编码不修改词表，尤其不能因为看见测试词就自动扩充训练词表。

## 练习 16_04 准确率函数（迁移）

实现 accuracy(predictions,labels)，等长空列表返回 None，长度不同抛 ValueError，其余返回准确率。打印 [1,0] 对 [1,1] 的结果。

作答文件：practice/ch16/ex16_04.py

提示：长度检查在 zip 前。

参考答案：
```python
def accuracy(predictions, labels):
    if len(predictions) != len(labels):
        raise ValueError("长度不一致")
    if not labels:
        return None
    correct = sum(pred == label for pred, label in zip(predictions, labels))
    return correct / len(labels)

print(accuracy([1, 0], [1, 1]))
```

输出：
```text
0.5
```

解析：先约定空输入行为，再计算分母。不能把空输入默认为 100% 正确。

## 练习 16_05 补齐批次（挑战）

实现 pad_batch(sequences,pad_id=0)，返回 (padded,masks)，补到本批最大长度，空批返回 ([],[])，不修改输入。打印 [[2,3],[4]] 的结果。

作答文件：practice/ch16/ex16_05.py

提示：空批先返回；每行构建新列表。

参考答案：
```python
def pad_batch(sequences, pad_id=0):
    if not sequences:
        return [], []
    width = max(len(sequence) for sequence in sequences)
    padded = []
    masks = []
    for sequence in sequences:
        extra = width - len(sequence)
        padded.append(sequence + [pad_id] * extra)
        masks.append([1] * len(sequence) + [0] * extra)
    return padded, masks

print(pad_batch([[2, 3], [4]]))
```

输出：
```text
([[2, 3], [4, 0]], [[1, 1], [1, 0]])
```

解析：返回两个对齐结构。空序列可以作为批中的一项；全空序列时宽度为 0。

## 练习 16_06 验证记录 ID 唯一（场景）

实现 unique_ids(records)，如果各字典的 id 唯一返回 True，重复则 False；空列表返回 True。打印 [{"id":1},{"id":1}] 的结果。

作答文件：practice/ch16/ex16_06.py

提示：取 id 列表再比较集合长度。

参考答案：
```python
def unique_ids(records):
    ids = [record["id"] for record in records]
    return len(ids) == len(set(ids))

print(unique_ids([{"id": 1}, {"id": 1}]))
```

输出：
```text
False
```

解析：本题假定 id 字段存在且可哈希。真实导入流程需先验证字段类型，再执行唯一性检查。

## 练习 16_07 AI / Agent：实现可测试的两步工具流程（综合）

实现 lookup_agent(query, max_steps=2)。query 必须是字符串；max_steps 必须是非负 int（不接受 bool），否则 raise ValueError。固定笔记字典为 {"列表":"有序容器", "函数":"可复用的处理步骤"}，按 query 完全匹配查找。第 1 步保存查找结果；第 2 步返回 {"status":"done", "answer":结果或"未找到", "steps":2}。若步数不够，返回 {"status":"limit", "answer":None, "steps":已执行步数}。只需这两种动作，最多执行两步。调用 "函数" 并依次输出 status、answer、steps。

作答文件：practice/ch16/ex16_07.py

提示：先验证输入，再用 for 从 1 到 max_steps 迭代。第 1 步查询，第 2 步立即 return。循环正常结束则说明还没完成。max_steps=0 时应执行零步。

参考答案：
```python
def lookup_agent(query, max_steps=2):
    if not isinstance(query, str):
        raise ValueError("query 必须是字符串")
    if type(max_steps) is not int or max_steps < 0:
        raise ValueError("max_steps 必须为非负整数")
    notes = {"列表": "有序容器", "函数": "可复用的处理步骤"}
    observation = None
    steps = 0
    for step in range(1, max_steps + 1):
        steps = step
        if step == 1:
            observation = notes.get(query, "未找到")
        else:
            return {"status": "done", "answer": observation, "steps": steps}
    return {"status": "limit", "answer": None, "steps": steps}

result = lookup_agent("函数")
print(result["status"])
print(result["answer"])
print(result["steps"])
```

输出：
```text
done
可复用的处理步骤
2
```

解析：第 1 步只查找，第 2 步才交付结果，因此 max_steps=1 必须报告 limit。max_steps 很大时也在第 2 步提前返回，不能为了用完预算继续循环。未知问题得到 done/未找到，表示流程已结束但没有证据；不能伪造知识补空缺。先画出 max_steps=0、1、2、5 四条执行轨迹，再运行额外测试。本题是离线规则流程，完成它意味着理解了 Python 控制流与状态传递。

## 记忆要点
- 清洗规则先写清，不是删得越多越好。
- 记录一起打乱，训练数据负责建立统计规则。
- 空白分词是教学简化，不等于模型 tokenizer。
- 批大小、序列长度、特征维度分别命名。

## 通关检查
- 我能把原始记录整理成没有空文本和重复 ID 的列表。
- 我能从训练文本建立词表并处理未知词。
- 我能检查批次末尾、标签对齐与空指标。


# 17 四个综合项目

从需求开始独立组织程序，再对照参考实现。

## 学习目标

- 把需求拆成数据、函数和验证规则
- 运行完整项目并核对输出文件
- 在已有程序上独立增加一个功能

### 项目练习的正确顺序
先读项目需求和输入数据，不要先打开 app.py。用中文写出 5—8 个步骤，在 `starter.py` 里实现最小版本。先用内存里的三条假数据验证，再加入文件读取，最后才加命令行界面。app.py 是完整参考实现，分步任务并不要求一开始就复刻它的全部结构。starter.py 中的 NotImplementedError 是提醒你填入代码的练习标记；它报错并不表示 Python 安装失败。每次只完成一个函数，再运行底部的小样例检查。

每个项目目录都有 README.md、TASKS.md、starter.py 和 app.py。需要输入数据的项目另有 data/。默认运行会把结果写进该项目 outputs/。这些都是专用练习输出；重跑项目 02—04 会覆盖同名报告，项目 01 的 add 会追加记录。不要将练习输出路径改成自己的重要文件。

### 四次递进练习
1. **学习打卡本**：日期、分钟、主题 → 验证 → JSON 保存 → 汇总。重点是函数边界、文件持久化、错误恢复。
2. **成绩表清洗**：含错误的 CSV → 按字段检查 → 有效记录与错误记录分流 → 排名和统计。重点是字符串转数字、边界和错误报告。
3. **文本语料清洗**：JSONL → 解析 → 规范化 → 去空去重 → 词频。重点是字典、集合和数据可追溯性。
4. **数据集管道**：记录验证 → 划分 → 训练词表 → 编码 → 补齐 → 分批。重点是数据对齐、未知词、形状和复现。

### 把一个任务拆成函数的完整示范
以成绩表为例，输入是路径，不是分数列表。读取后每条 CSV 记录仍是字符串字典。先约定 student_id、name、score 三列必须存在；再对每行去空白、转整数、检查范围。合法记录加入 valid，非法记录加入 errors。两个结果列表形成“验证层”的输出。

统计层只接收 valid，所以不必在每次求平均时重新猜哪些分数可用。最后输出层把统计对象序列化成 JSON，把 errors 写成 CSV。这样如果平均分错了，可以直接测试统计层；如果文件找不到，则看读取层；不必每次从头怀疑全部代码。

### 项目验收的五个问题
- 用最小样本手算，程序结果一致吗？
- 没有数据、只有一条、全是坏数据时会怎样？
- 用户看到的错误能找到对应记录吗？
- 换一个启动目录或另一台电脑，路径是否仍然正确？
- 同样输入再跑一次，哪些文件覆盖、哪些记录追加，行为是否符合说明？

### 新出现的 argparse 不用死记
项目里的 argparse 来自标准库。`ArgumentParser` 建立命令说明，`add_argument` 声明参数，`parse_args` 把终端输入读成对象。`args.input` 对应 `--input`；命令中的带空格主题要加引号。先运行 `python app.py --help`，按照给出的命令执行，再回看源代码中相应定义。

注意运行位置：本章给出的命令从**学习包根目录**执行。如果已进入某个项目目录，命令可以简化为 `python app.py`。Windows/Mac 的解释器名字按第 00 章替换。

### 不看答案的通关挑战
给成绩报告增加“分数区间人数”，区间为 0—59、60—79、80—89、90—100。先手算样例，应为 2、1、1、1。把逻辑写成独立函数，测试 59、60、79、80、89、90、100。再把结果加入输出 JSON。完成后用自己的话解释：为什么区间不能互相重叠，为什么不能把 100 漏掉。

另一个挑战：给学习打卡增加“按主题累计”，清洗主题两端空格但不随意更改中文内容。对 30 分钟 Python、20 分钟 Git、15 分钟 Python，结果应为 Python=45、Git=20。把这题与第 08 章的分类累计联系起来。

### 最后一次自我验收
以下命令核验参考项目，不会替你完成 starter.py：

```terminal
python tools/check_projects.py
```

自己的实现仍要用 TASKS.md 的验收案例检查。工具通过只能证明参考实现通过了已写检查；你能否不看答案重建其中一个项目，才是本阶段最重要的学习证据。


# 学习打卡与统计

前置：第 00—13 章

写一个命令行学习记录本：添加日期、学习分钟和主题；下次打开仍可读取；汇总次数、总分钟和平均分钟。分钟必须为非负整数，主题不能为空。文件损坏时报告问题，不把坏文件当空记录覆盖。

### 1 先写核心函数
先对内存中的记录列表写 summarize(records)，输入 30、60 分钟时总量为 90、平均 45.0。空列表返回平均 None。此时完全不需要文件或命令行。

### 2 加入文件持久化
用 JSON 保存记录列表。文件不存在视作首次使用，返回空列表；文件存在但内容错误则抛出清楚异常。分清这两种情况，避免误覆盖用户数据。

### 3 检查输入再修改
将日期用 date.fromisoformat 验证；分钟用整数且非负检查；主题 strip 后检查非空。所有检查通过后才 append。

### 4 加入命令行入口
argparse 是标准库命令行参数解析器。add 子命令收 minutes 与 topic；summary 子命令显示统计；--store 指定记录位置。看不懂时先运行 --help，再按已给命令练习。

### 5 独立扩展
增加按主题汇总，不直接改现有统计函数；给新函数写 3 个例子。进一步可做删除记录，但先为每条记录设计稳定 ID，并明确删除规则。

### 运行命令

从学习包根目录执行。Windows 用 py，Mac 用 python3，激活环境后可用 python。

```terminal
python projects/01_study_log/app.py add 30 "变量与类型" --date 2026-10-07
python projects/01_study_log/app.py add 60 "循环练习" --date 2026-10-08
python projects/01_study_log/app.py summary
```

### 预期结果

初始无记录时执行两次 add，分别显示已保存 1 条和 2 条；summary 显示记录数 2、总分钟 90、平均分钟 45.0。反复 add 会继续追加，所以重复练习前可使用 --store 指向另一个新的练习文件。

### 验收

运行学习包的 python tools/check_projects.py 核验参考实现；自己的实现按上面手算结果和边界逐项验证。


```python
"""学习记录本。先运行 python app.py --help 查看命令。"""
import argparse
import json
from datetime import date
from pathlib import Path

BASE = Path(__file__).resolve().parent

def validate_record(record):
    if not isinstance(record, dict):
        raise ValueError("记录必须是对象")
    if not isinstance(record.get("date"), str):
        raise ValueError("记录日期必须是字符串")
    date.fromisoformat(record["date"])
    minutes = record.get("minutes")
    if type(minutes) is not int or minutes < 0:
        raise ValueError("分钟必须是非负整数")
    topic = record.get("topic")
    if not isinstance(topic, str) or not topic.strip():
        raise ValueError("主题不能为空")

def load_records(path):
    if not path.exists():
        return []
    records = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(records, list):
        raise ValueError("记录文件最外层必须是列表")
    for record in records:
        validate_record(record)
    return records

def summarize(records):
    total = sum(record["minutes"] for record in records)
    count = len(records)
    return {"count": count, "total": total, "mean": total / count if count else None}

def add_record(path, minutes, topic, day):
    record = {"date": day, "minutes": minutes, "topic": topic.strip()}
    validate_record(record)
    records = load_records(path)
    records.append(record)
    path.parent.mkdir(parents=True, exist_ok=True)
    # 先写临时文件，再替换本项目自己的记录文件，降低中途写坏的机会。
    temporary = path.with_name(path.name + ".tmp")
    temporary.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
    temporary.replace(path)
    return len(records)

def main():
    parser = argparse.ArgumentParser(description="学习打卡记录本")
    parser.add_argument("--store", type=Path, default=BASE / "outputs" / "study_log.json")
    sub = parser.add_subparsers(dest="command", required=True)
    add = sub.add_parser("add", help="添加一条记录")
    add.add_argument("minutes", type=int)
    add.add_argument("topic")
    add.add_argument("--date", default=date.today().isoformat())
    sub.add_parser("summary", help="查看汇总")
    args = parser.parse_args()
    try:
        if args.command == "add":
            count = add_record(args.store, args.minutes, args.topic, args.date)
            print(f"已保存 {count} 条记录")
        else:
            stats = summarize(load_records(args.store))
            print(f"记录数：{stats['count']}")
            print(f"总分钟：{stats['total']}")
            print("平均分钟：暂无" if stats["mean"] is None else f"平均分钟：{stats['mean']:.1f}")
    except (ValueError, OSError) as error:
        parser.exit(1, f"无法完成：{error}\n")

if __name__ == "__main__":
    main()
```


# 成绩 CSV 清洗与报告

前置：第 00—13 章

读取含坏数据的成绩表。逐条检查 ID、姓名、分数和重复 ID，保留有效记录，把无效行及原因写入错误报告，输出人数、平均分、通过率与排名。CSV 的逻辑记录可能跨物理行，因此报告记录号不伪称绝对物理行号。

### 1 列出数据约定
必需列为 student_id、name、score；ID 和姓名非空；score 是 0—100 的整数字符串；相同 ID 首条有效记录保留，后续重复记错。

### 2 手算样本
先查看 data/scores.csv。有效分数为 80、60、100、59、0，总分 299，平均 59.8；达到 60 有 3 人，通过率 60%。有 4 条错误记录。

### 3 一条一条验证
先处理字段缺失，再转整数，再检查范围，最后检测已保留 ID 是否重复。错误放入列表并继续，不让一条坏数据阻止所有正常记录。

### 4 统计与排序
只对有效数据计算。空数据平均分与通过率为 None，排名为空。sorted 用负分数与 ID 组成 key，保证同分顺序稳定可说明。

### 5 保存并核验
写 report.json 和 errors.csv，读回来确认 valid_count+invalid_count 等于输入数据记录数。修改一条坏记录后重跑，确认计数相应变化。

### 运行命令

从学习包根目录执行。Windows 用 py，Mac 用 python3，激活环境后可用 python。

```terminal
python projects/02_score_report/app.py
python projects/02_score_report/app.py --help
```

### 预期结果

显示：有效 5 条，无效 4 条；平均分 59.8；通过率 60.0%。outputs/report.json 保存完整结果，outputs/errors.csv 保存记录号与错误原因。重复运行覆盖这两个专用输出文件。

### 验收

运行学习包的 python tools/check_projects.py 核验参考实现；自己的实现按上面手算结果和边界逐项验证。


```python
"""成绩清洗报告；只覆盖 --out 指定目录中的本项目输出文件。"""
import argparse
import csv
import json
from pathlib import Path

BASE = Path(__file__).resolve().parent

def analyze(path):
    valid = []
    errors = []
    seen = set()
    with path.open(encoding="utf-8-sig", newline="") as file:
        reader = csv.DictReader(file)
        required = {"student_id", "name", "score"}
        if not required.issubset(set(reader.fieldnames or [])):
            raise ValueError("缺少必需列 student_id、name、score")
        for record_number, row in enumerate(reader, start=1):
            try:
                if None in row:
                    raise ValueError("字段数超过表头列数")
                student_id = (row.get("student_id") or "").strip()
                name = (row.get("name") or "").strip()
                if not student_id or not name:
                    raise ValueError("ID 或姓名为空")
                try:
                    score = int(row.get("score") or "")
                except ValueError:
                    raise ValueError("分数不是整数") from None
                if not 0 <= score <= 100:
                    raise ValueError("分数超出 0 到 100")
                if student_id in seen:
                    raise ValueError("重复 ID")
            except ValueError as error:
                errors.append({"record": record_number, "reason": str(error)})
                continue
            seen.add(student_id)
            valid.append({"student_id": student_id, "name": name, "score": score})
    count = len(valid)
    total = sum(row["score"] for row in valid)
    passed = sum(row["score"] >= 60 for row in valid)
    return {
        "valid_count": count,
        "invalid_count": len(errors),
        "total": total,
        "mean": total / count if count else None,
        "pass_rate": passed / count if count else None,
        "ranking": sorted(valid, key=lambda row: (-row["score"], row["student_id"])),
        "errors": errors,
    }

def save_report(report, folder):
    folder.mkdir(parents=True, exist_ok=True)
    (folder / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
    with (folder / "errors.csv").open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=["record", "reason"])
        writer.writeheader()
        writer.writerows(report["errors"])

def main():
    parser = argparse.ArgumentParser(description="成绩表清洗与统计")
    parser.add_argument("--input", type=Path, default=BASE / "data" / "scores.csv")
    parser.add_argument("--out", type=Path, default=BASE / "outputs")
    args = parser.parse_args()
    try:
        report = analyze(args.input)
        save_report(report, args.out)
    except (OSError, ValueError, csv.Error) as error:
        parser.exit(1, f"处理失败：{error}\n")
    print(f"有效 {report['valid_count']} 条，无效 {report['invalid_count']} 条")
    if report["mean"] is None:
        print("没有有效分数，无法计算平均分与通过率")
    else:
        print(f"平均分 {report['mean']:.1f}")
        print(f"通过率 {report['pass_rate']:.1%}")

if __name__ == "__main__":
    main()
```


# 文本语料清洗与词频

前置：第 00—13 章和第 16 章

把原始 JSONL 清洗成可追踪的小语料：验证 id 与 text，统一英文大小写及空白，去除空文本和重复文本，保留原记录 ID，输出干净 JSONL、错误与重复记录报告和词频。

### 1 定义清洗规则
仅针对本项目英文教学文本：lower 后按空白 split/join。保留标点。去重依据规范化后的完整文本；第一条有效记录保留。输出词频按空白分词，不是模型 tokenizer。

### 2 分离解析与清洗
逐行 JSON 解析可能失败；对象结构和字段类型也可能不对。先确认 dict，再检查 id 与 text 的类型，最后做字符串方法。

### 3 分别记拒绝与重复
坏 JSON、缺字段、非字符串、空文本、重复已保留 ID 计为 rejected；同规范化文本计为 duplicate。两类都记物理行号和原因。不要悄悄消失记录。

### 4 用小样本核对
样本共 9 个非空行，保留 3，重复文本 1，拒绝 5。干净文本为 hello python、learn python、hello world；python 和 hello 各 2 次。

### 5 独立扩展
增加 --min-length 过滤太短文本，给报告增加过滤原因。再测试中文和代码文本，说明为什么当前规则不能直接代表所有语料任务。

### 运行命令

从学习包根目录执行。Windows 用 py，Mac 用 python3，激活环境后可用 python。

```terminal
python projects/03_text_cleaner/app.py
python projects/03_text_cleaner/app.py --help
```

### 预期结果

输出非空行 9、保留 3、重复 1、拒绝 5。outputs/clean.jsonl 保存干净记录；outputs/report.json 保存每个被排除行的原因和排序词频。

### 验收

运行学习包的 python tools/check_projects.py 核验参考实现；自己的实现按上面手算结果和边界逐项验证。


```python
"""英文教学语料清洗器，空白分词不是大模型 tokenizer。"""
import argparse
import json
from collections import Counter
from pathlib import Path

BASE = Path(__file__).resolve().parent

def normalize(text):
    return " ".join(text.lower().split())

def clean_file(path):
    kept = []
    rejected = []
    duplicates = []
    seen_text = set()
    seen_ids = set()
    input_count = 0
    with path.open(encoding="utf-8") as file:
        for line_number, line in enumerate(file, start=1):
            if not line.strip():
                continue
            input_count += 1
            try:
                record = json.loads(line)
                if not isinstance(record, dict):
                    raise ValueError("记录必须是 JSON 对象")
                if not isinstance(record.get("id"), str) or not record["id"].strip():
                    raise ValueError("id 必须为非空字符串")
                if not isinstance(record.get("text"), str):
                    raise ValueError("text 必须为字符串")
                record_id = record["id"].strip()
                text = normalize(record["text"])
                if not text:
                    raise ValueError("清洗后文本为空")
                if record_id in seen_ids:
                    raise ValueError("与已保留记录的 ID 重复")
            except (ValueError, json.JSONDecodeError) as error:
                rejected.append({"line": line_number, "reason": str(error)})
                continue
            if text in seen_text:
                duplicates.append({"line": line_number, "reason": "规范化文本重复"})
                continue
            seen_text.add(text)
            seen_ids.add(record_id)
            kept.append({"id": record_id, "text": text})
    counts = Counter(word for record in kept for word in record["text"].split())
    frequencies = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
    report = {"input_count": input_count, "kept_count": len(kept),
              "duplicate_count": len(duplicates), "rejected_count": len(rejected),
              "duplicates": duplicates, "rejected": rejected, "word_counts": frequencies}
    return kept, report

def save_outputs(records, report, folder):
    folder.mkdir(parents=True, exist_ok=True)
    with (folder / "clean.jsonl").open("w", encoding="utf-8") as file:
        for record in records:
            file.write(json.dumps(record, ensure_ascii=False) + "\n")
    (folder / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")

def main():
    parser = argparse.ArgumentParser(description="清洗英文教学 JSONL 语料")
    parser.add_argument("--input", type=Path, default=BASE / "data" / "raw.jsonl")
    parser.add_argument("--out", type=Path, default=BASE / "outputs")
    args = parser.parse_args()
    try:
        records, report = clean_file(args.input)
        save_outputs(records, report, args.out)
    except (OSError, UnicodeError) as error:
        parser.exit(1, f"处理失败：{error}\n")
    print(f"非空行 {report['input_count']}，保留 {report['kept_count']}，重复 {report['duplicate_count']}，拒绝 {report['rejected_count']}")

if __name__ == "__main__":
    main()
```


# 数据集划分 编码与分批

前置：第 00—16 章

读取小型带标签文本数据集，验证记录、ID 与规范化文本唯一性；可复现地划分训练测试；只用训练文本建词表；把各批编码、补齐并生成掩码；保存配置与输出。此项目不训练模型，专门练 Python 数据流。

### 1 先写四个纯函数
normalize、split_records、build_vocab、pad_batch。每个先用 2—3 条手造数据检查。把数据处理和文件输出分开，错误更容易定位。

### 2 约定保留比例
train_ratio 满足 0<ratio<1，至少 2 条数据；n_train=int(n*ratio)，再限制到 1 到 n-1，保证两边非空。小数据可能导致实际比例不同，报告实际条数。

### 3 一次打乱完整记录
复制外层记录列表，用 random.Random(seed).shuffle。ID、text、label 一起移动，避免标签错配。样本按规范化文本完全重复时直接拒绝；相近内容或同源分组需要更复杂检查。

### 4 训练侧建立词表
保留 <PAD>=0、<UNK>=1，只看 train。测试中的未知词映射为 1。保存 vocab.json，让下一次编码使用相同映射。

### 5 切批与补齐
每批独立算最大长度；保存 ids、labels、input_ids、attention_mask。最后不足 batch_size 的批保留。让你能检查每个维度与记录对齐。

### 6 验收与扩展
检查训练测试 ID 无交集、样本数守恒、每批 labels 数与 input_ids 行数相等、每行掩码长度与 ID 长度相等。自行加入分层划分或按来源分组，先写清规则再实现。

### 运行命令

从学习包根目录执行。Windows 用 py，Mac 用 python3，激活环境后可用 python。

```terminal
python projects/04_dataset_pipeline/app.py
python projects/04_dataset_pipeline/app.py --seed 7 --batch-size 3 --train-ratio 0.75
```

### 预期结果

默认 8 条数据，训练 6、测试 2；批大小 2 时训练 3 批、测试 1 批。outputs/ 中有 train.jsonl、test.jsonl、vocab.json、batches.json、manifest.json。默认重复运行应得到相同输出（同一运行环境）。

### 验收

运行学习包的 python tools/check_projects.py 核验参考实现；自己的实现按上面手算结果和边界逐项验证。


```python
"""仅用标准库的数据管道；toy 词表不能替代预训练模型 tokenizer。"""
import argparse
import json
import random
from pathlib import Path

BASE = Path(__file__).resolve().parent

def normalize(text):
    return " ".join(text.lower().split())

def load_records(path):
    records = []
    ids = set()
    texts = set()
    with path.open(encoding="utf-8") as file:
        for number, line in enumerate(file, start=1):
            if not line.strip():
                continue
            row = json.loads(line)
            if not isinstance(row, dict):
                raise ValueError(f"第 {number} 行不是对象")
            if not isinstance(row.get("id"), str) or not row["id"].strip():
                raise ValueError(f"第 {number} 行 ID 非法")
            if not isinstance(row.get("text"), str):
                raise ValueError(f"第 {number} 行 text 非字符串")
            if type(row.get("label")) is not int or row["label"] not in (0, 1):
                raise ValueError(f"第 {number} 行 label 必须是整数 0 或 1")
            record_id = row["id"].strip()
            text = normalize(row["text"])
            if not text or record_id in ids or text in texts:
                raise ValueError(f"第 {number} 行为空文本、重复 ID 或重复文本")
            ids.add(record_id)
            texts.add(text)
            records.append({"id": record_id, "text": text, "label": row["label"]})
    return records

def split_records(records, train_ratio=0.75, seed=42):
    if not 0 < train_ratio < 1:
        raise ValueError("train_ratio 必须在 0 与 1 之间")
    if len(records) < 2:
        raise ValueError("至少需要 2 条有效记录")
    shuffled = list(records)
    random.Random(seed).shuffle(shuffled)
    cut = max(1, min(len(records) - 1, int(len(records) * train_ratio)))
    return shuffled[:cut], shuffled[cut:]

def build_vocab(records):
    vocab = {"<PAD>": 0, "<UNK>": 1}
    for row in records:
        for word in row["text"].split():
            if word not in vocab:
                vocab[word] = len(vocab)
    return vocab

def pad_batch(sequences):
    width = max((len(sequence) for sequence in sequences), default=0)
    padded = [sequence + [0] * (width - len(sequence)) for sequence in sequences]
    masks = [[1] * len(sequence) + [0] * (width - len(sequence)) for sequence in sequences]
    return padded, masks

def make_batches(records, vocab, size):
    if size <= 0:
        raise ValueError("batch_size 必须为正整数")
    result = []
    for start in range(0, len(records), size):
        rows = records[start:start + size]
        sequences = [[vocab.get(word, 1) for word in row["text"].split()] for row in rows]
        padded, masks = pad_batch(sequences)
        result.append({"ids": [row["id"] for row in rows],
                       "labels": [row["label"] for row in rows],
                       "input_ids": padded, "attention_mask": masks})
    return result

def write_jsonl(path, rows):
    with path.open("w", encoding="utf-8") as file:
        for row in rows:
            file.write(json.dumps(row, ensure_ascii=False) + "\n")

def run(input_path, output_dir, train_ratio=0.75, seed=42, batch_size=2):
    records = load_records(input_path)
    train, test = split_records(records, train_ratio, seed)
    vocab = build_vocab(train)
    batches = {"train": make_batches(train, vocab, batch_size),
               "test": make_batches(test, vocab, batch_size)}
    manifest = {"seed": seed, "requested_train_ratio": train_ratio, "batch_size": batch_size,
                "total": len(records), "train_count": len(train), "test_count": len(test),
                "vocab_size": len(vocab), "normalization": "lower + whitespace split/join",
                "tokenizer": "teaching-only whitespace tokenizer"}
    output_dir.mkdir(parents=True, exist_ok=True)
    write_jsonl(output_dir / "train.jsonl", train)
    write_jsonl(output_dir / "test.jsonl", test)
    for name, value in [("vocab.json", vocab), ("batches.json", batches), ("manifest.json", manifest)]:
        (output_dir / name).write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
    return manifest

def main():
    parser = argparse.ArgumentParser(description="教学文本数据集管道")
    parser.add_argument("--input", type=Path, default=BASE / "data" / "samples.jsonl")
    parser.add_argument("--out", type=Path, default=BASE / "outputs")
    parser.add_argument("--seed", type=int, default=42)
    parser.add_argument("--batch-size", type=int, default=2)
    parser.add_argument("--train-ratio", type=float, default=0.75)
    args = parser.parse_args()
    try:
        result = run(args.input, args.out, args.train_ratio, args.seed, args.batch_size)
    except (ValueError, OSError) as error:
        parser.exit(1, f"处理失败：{error}\n")
    print(f"共 {result['total']} 条，训练 {result['train_count']}，测试 {result['test_count']}")
    print(f"词表 {result['vocab_size']} 项，批大小 {result['batch_size']}")

if __name__ == "__main__":
    main()
```

## 记忆要点
- 先内存逻辑，再文件，再命令行。
- 验证层把坏数据说明白，统计层只接收约定的数据。
- 完成一个独立扩展，比只运行参考代码更能证明掌握。

## 通关检查
- 我能独立完成至少一个项目的核心逻辑。
- 我能给项目增加一个小功能并验证边界。
- 我能解释原始输入如何一步步变成最终报告。


# 18 复习路线 自测与速查

按能力前进，不必为了赶进度跳过卡住的知识。

## 学习目标

- 安排可执行的复习节奏
- 通过阶段检查找到薄弱点
- 知道进入 NumPy 与 PyTorch 前应会什么

### 一个可调整的八周路线
这只是安排参考，不是保证八周精通。建议每周 5 天，每天 45—75 分钟；基础薄弱或工作繁忙时，每周内容可以拆成两周。

| 周次 | 重点 | 必须留下的实作成果 |
|---|---|---|
| 1 | 第 00—03 章 | 能独立运行文件，完成输入转换和文本清洗 |
| 2 | 第 04—06 章 | 写成绩分级、累计、菜单退出，手工追踪循环 |
| 3 | 第 07—08 章 | 会列表与字典，解释共享对象，写分组和词频 |
| 4 | 第 09—10 章 | 把旧程序改成函数，处理空数据并完成额外测试 |
| 5 | 第 11—13 章 | 读写 CSV/JSON，创建环境，定位并修复报错 |
| 6 | 第 14—15 章 | 看懂 self、数据集类、生成器与 ** 参数展开 |
| 7 | 第 16—17 章项目 01—03 | 独立写一个有持久化输出的完整程序 |
| 8 | 项目 04 与综合验收 | 画出数据处理路径，增加一个功能，重做错题 |

### 记忆靠提取，不靠反复划线
学完当天合上教材，用空文件写一个同类例子；次日重写错题；第 3 天换输入和场景；第 7 天混合不同章节；第 14 天在项目中复用。这些间隔只是可操作的复习安排，不是对所有人最佳的固定规律。

每张记忆卡只放一个问题，例如“sort 返回什么？”、“空列表的 all 是什么？”、“input 得到什么类型？”、“为什么 b=a 后改 b 会影响 a？”先口头回答，再看背面并运行小例子。不要把十个语法点塞在一张卡里。

### 一页关键记忆表
| 问题 | 记住的核心 | 最小例子 |
|---|---|---|
| = 与 == | 赋值与比较 | x=3；x==3 |
| 索引与切片 | 单点与区间，终点不含 | s[0]；s[:3] |
| input 类型 | 先是字符串 | int(input()) |
| append 与 extend | 加一个与逐个加 | a.append([1,2]) |
| sort 与 sorted | 原地改并返回 None 与新列表 | b=sorted(a) |
| b=a 与 copy | 共享对象与新外层 | b=a.copy() |
| print 与 return | 显示与把值交回 | return total |
| break 与 continue | 结束循环与跳本轮后半段 | if bad: continue |
| in 在 dict 中 | 默认查键 | "name" in row |
| None 与 0 | 缺失与有效数值可能不同 | value is None |
| get 的默认值 | 读取缺失键，不自动写回 | d.get(k,0) |
| CSV 与 JSON | 文本字段表格与嵌套对象 | int(row["score"]) |
| 路径 | 当前目录与脚本目录区别 | Path(__file__).parent |
| yield 与 return | 暂停给一个与结束 | yield batch |

### 常见报错恢复路线
“找不到 Python”：关闭并重开终端，检查安装，分别试 Windows 的 py、Mac 的 python3。

“can't open file”：先确认完整解压，检查当前目录与文件名，路径有空格时加引号。不要把教材里的尖括号占位文字当作真实路径。

“ModuleNotFoundError”：标准库先检查是否拼错和是否用同名文件遮蔽；第三方库检查当前解释器与 pip 对应，不能只看另一个终端是否曾安装。

“TypeError 或 ValueError”：打印 type 和 repr，确认数据内容。字符串 "80" 与整数 80 外观相似，操作规则不同。

“网页勾选丢失”：浏览器本地存储不是跨电脑云同步；导入之前导出的进度 JSON。你的代码保存在 .py 文件，与网页勾选独立。

### 阶段自测 A 语法解释
先口头回答，再在对应章节验证：
1. `print("3"*2)` 与 `print(3*2)` 分别是什么？答案：33 与 6；前者字符串重复。
2. `list(range(2,7,2))` 是什么？答案：[2,4,6]，不包含 7。
3. `a=[1]; b=a; b.append(2)` 后 a 是什么？答案：[1,2]，共享对象。
4. 函数只有 print 没有 return，返回什么？答案：None。
5. `bool("False")` 是什么？答案：True，非空字符串。
6. 空列表可以直接求平均吗？答案：不能直接除以 len；需定义空数据行为。
7. `d.get("x",0)` 会创建 x 键吗？答案：不会。
8. 第二次 list(g) 为什么可能为空？答案：同一个生成器已经消费完。
9. 标注 `x: int` 会自动转型吗？答案：不会。
10. 训练集和测试集标签为什么不能分别打乱？答案：会破坏样本与标签配对。

### 阶段自测 B 独立编程
不给参考代码，按下面案例自己验证：
- 写 summarize_minutes([0,30,45])，返回总分钟 75、学习天数 2、按全部 3 天计算的日均 25.0；空列表日均 None。要求说明“按全部天数”与“只按学习日”的区别。
- 写 top_words("a b a c b a",2)，返回 [("a",3),("b",2)]；同频按词的字母顺序。空文本返回空列表。
- 写 clean_scores(["80","x","101","0"])，返回有效分数 [80,0] 与无效项位置 [2,3]，位置从 1 开始。把它接到 CSV 读取程序。
- 写 chunks([1,2,3,4,5],2)，返回 [[1,2],[3,4],[5]]；size<=0 抛 ValueError；不改变输入列表。

完成标准不是记住这几个答案，而是你能为每个函数换 3 组输入，手算预期，然后解释结果不一致时是哪一步出错。

### 项目自评 100 分
需求与输入输出约定 15 分；核心逻辑正确 30 分；边界与错误处理 20 分；函数拆分与命名 15 分；可重复运行与路径 10 分；能解释并独立扩展 10 分。80 分可作为继续下一阶段的参考线，但若还分不清 return、列表共享或字典列表层次，应先回补，即使总分够也别跳过。

### 进入深度学习前的能力清单
能够独立读写 CSV/JSONL；能解释嵌套列表与字典的层级；能编写带参数、返回值和边界检查的函数；能定位类型、路径和导入错误；能读懂类、迭代器与批次；会建立环境并记录依赖；知道训练测试应保持独立。这些基础比提前背某个模型 API 更耐用。

下一阶段的顺序建议是：NumPy 数组与形状 → 数学中的向量、矩阵和导数直觉 → PyTorch 张量与自动求导 → 小型分类任务完整训练流程 → tokenizer、Transformer 与模型推理 → 根据目标学习微调、评估或检索增强。每一阶段仍然用小数据、可核对输出和独立实现来检验理解。

### 官方资料与核对日期
本教材是为零基础重新组织的原创讲解和练习，并非 Python 官方教程的翻译。安装与环境说明参考官方资料；语法细节有争议时以对应版本文档为准。核对日期：2026-10-07。

- [Python 官方教程](https://docs.python.org/3/tutorial/)：适合完成基础阶段后查阅。
- [Python 内置函数](https://docs.python.org/3/library/functions.html)：查询 print、range、sorted 等。
- [Python 标准类型](https://docs.python.org/3/library/stdtypes.html)：字符串、列表、字典的准确语义。
- [Windows 安装](https://docs.python.org/3/using/windows.html) 与 [macOS 安装](https://docs.python.org/3/using/mac.html)。
- [虚拟环境与 pip](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/)。
- [NumPy 入门](https://numpy.org/doc/stable/user/absolute_beginners.html)、[PyTorch 基础](https://docs.pytorch.org/tutorials/beginner/basics/intro.html)：下一阶段再学，安装前查看当时官方要求。

### 以后向助教提问的模板
“我正在做第 XX 章第 XX 题；我理解目标是……；我的代码是……；输入是……；我预计输出……；实际输出或完整报错是……；我已经试过……。请先提示定位思路，不直接给完整答案。”

能把问题这样说清楚，本身就是一种重要的编程能力。你不需要一次记住所有方法，但要能把数据、步骤和错误描述清楚。

### AI / Agent 穿插练习后的自测
请先不运行代码，用自己的话回答，再到对应章节验证。

1. f-string 拼出一段提示词，是否已经调用了大模型？没有，只生成了 Python 字符串。
2. 对中文文本直接 split()，能否算出模型 token 数？不能。默认 split 按空白切分；具体模型使用的 tokenizer 有自己的规则。
3. 列表里的 role 字段会自动触发某种模型权限吗？不会；它在本教材里只是普通数据。实际服务如何解释字段要看它自己的接口约定。
4. 工具返回 `{"count": 3}`，与打印这段文字有何不同？返回值可供调用方继续取字段；打印本身不把这个字典作为函数结果交回。
5. 模型回复能被 json.loads 解析，是否一定能执行？不能。还要检查结构、参数和允许的工具名；不能把回复直接交给 eval 或 exec。
6. 规则检索返回了一篇文档，是否说明回答可靠？不能。还要看检索是否相关、答案是否由证据支持；本章小程序只演示流程。

### 后续学习的连接点
掌握字符串、列表和字典后，再学习 tokenizer、张量和批次；掌握函数、异常和文件后，再学习真实 API 的请求、响应、失败处理与日志；掌握类和生成器后，再阅读 Agent 框架中的状态对象与流式事件。每次只替换一层，例如先保留现有工具函数，只把固定决策函数换成真实模型接口，便于定位问题。

官方延伸资料需要联网；阅读教材和运行本轮代码不需要联网。以下是概念核对入口，不要求你现在背诵接口字段：

- [Python 官方：函数与默认参数](https://docs.python.org/3/tutorial/controlflow.html)
- [Python 官方：类与实例变量](https://docs.python.org/3/tutorial/classes.html)
- [Python 官方：JSON 编码与解码](https://docs.python.org/3/library/json.html)
- [Python 官方：错误与异常](https://docs.python.org/3/tutorial/errors.html)
- [OpenAI 官方：工具调用流程](https://developers.openai.com/api/docs/guides/function-calling)

本教材的工具调用字典是为教学自定义的简化格式，不能直接当作任何厂商的 API 请求格式使用。

### 高星 GitHub 项目：v1.1 的参考记录
星数只反映关注量，不能代替对代码、解释和前置知识的检查。v1.1 制作时先读取项目原始内容与许可证，再选择适合第一阶段的部分。以下星数是 2026-10-20 查询 GitHub API 的快照，后续会变化。

| 项目与星数快照 | 参考范围 | 本教材中的具体落点 |
| --- | --- | --- |
| [Python-100-Days](https://github.com/jackfrued/Python-100-Days) · 187,325 | 基础语法到应用练习的课程组织 | 保留先语法、再场景、再独立作答的顺序；增加逐步分析和边界输入 |
| [AI Agents for Beginners](https://github.com/microsoft/ai-agents-for-beginners) · 77,523 | 工具、参数、执行结果和状态之间的关系 | 第 09 / 13 / 16 章：工具返回字典、数据校验、决策与执行分开 |
| [LLMs-from-scratch](https://github.com/rasbt/LLMs-from-scratch) · 106,931 | 第 2 章的文本数据与取样衔接 | 第 03 / 15 / 16 章：字符、词、token、流片段和批次之间的区别 |
| [smolagents](https://github.com/huggingface/smolagents) · 29,947 | Agent 运行循环的停止条件 | 第 06 / 16 章：完成状态、步数上限、未完成时的明确返回 |

v1.1 采用概念与教学组织参考，示例、题目、数据和解析重新编写，未搬入这些项目的框架源码、书籍或图片。Python-100-Days 在该次检查中未发现仓库级许可证，因此仅参考公开目录结构并链接原作；微软项目为 MIT，smolagents 为 Apache-2.0；LLMs-from-scratch 的 LICENSE.txt 含书籍和相关图片的排除条款，不能笼统当作所有材料都可复制。

学习包中的 `开源参考与改编说明.md` 记录作者、精确提交版本、对应文件及许可证链接，方便将来逐项核对。阅读原项目需要联网；运行当前教材不依赖它们。

## 记忆要点
- 先独立提取，再核对答案；会背不代表会用。
- 每个函数至少测正常、空、边界。
- 看懂数据流，再学习库的接口。

## 通关检查
- 我能在空文件里完成阶段自测 B 的至少三题。
- 我能不看答案解释一段综合项目代码。
- 我有自己的错题记录和下一周可执行的练习安排。


# Python 知识图谱

本图谱串联本课程的知识和用途。学习联系是建议顺序，不是语言的严格依赖；联想不表示依赖。场景中的顺序表示数据处理步骤。图谱不代替实际练习，也不会自动判断是否掌握。

## 五条目标路线

### 从零打基础

按顺序学会运行、表达、选择、重复，再组织数据和函数。

运行与执行顺序 → 变量、类型与转换 → 字符串清洗 → 条件与边界 → for 与累计 → while 与停止条件 → 列表与顺序 → 字典与记录 → 函数与 return

### 做成绩分析

主线是把文件中的字符串变成通过校验的记录，再统计并验收；细节见下方场景链路。

变量、类型与转换 → 字符串清洗 → 条件与边界 → for 与累计 → 字典与记录 → 函数与 return → 路径与文件 → CSV / JSON / JSONL → 异常与数据校验 → 断言与边界测试 → 综合项目与验收

### 整理 AI 数据

先练文本规则和去重，随后验证记录，保持文本与标签对齐；暂不训练模型。

字符串清洗 → 列表与顺序 → 集合与去重 → 字典与记录 → 路径与文件 → CSV / JSON / JSONL → 异常与数据校验 → AI 数据与批次

### 理解 Agent

先理解数据结构、工具参数和返回值，再把有限循环连接起来；这里使用规则决策。

条件与边界 → while 与停止条件 → 字典与记录 → 函数与 return → 作用域与状态传递 → 异常与数据校验 → Agent 工具流程

### 为大模型做准备

函数、对象、迭代和数据是后续代码的入口；数学、张量与模型训练要在第二阶段补学。

函数与 return → 作用域与状态传递 → 类与实例状态 → 迭代器与生成器 → AI 数据与批次 → 通向深度学习与大模型

## 运行与执行顺序

第 01 章；网页锚点：#c01-example-01_04

保存 .py 文件，交给 Python 从上到下执行；终端不是代码文件。

记忆：先保存，再运行，再看输出。

易混点：编辑器里的文字不会自动成为正在执行的程序。

- 后续用途 变量、类型与转换：先能运行并观察值，才能核对变量和类型。

自测：修改代码后输出没变，先检查什么？

解析：检查文件是否保存、终端运行的路径是否就是刚编辑的文件。

## 变量、类型与转换

第 02 章；网页锚点：#c02-example-02_04

变量名引用值；类型决定允许的操作。输入得到字符串，要算术计算时显式转换。

记忆：先问类型，再问数值。

易混点：0、空字符串、None 的含义不同，不能都当作缺失。

- 建议先会 运行与执行顺序：先能运行并观察值，才能核对变量和类型。

- 后续用途 字符串清洗：字符串也是一种类型；清洗方法的结果仍是字符串。

- 后续用途 条件与边界：比较前先区分数值、字符串和缺失值。

- 后续用途 列表与顺序：列表把多个值放在有顺序的容器中。

自测：字符串 "60" 加 "5"，与整数 60 加 5 有什么不同？

解析：前者拼成 "605"，后者算出 65；int("60") 才得到整数。

## 字符串清洗

第 03 章；网页锚点：#c03-example-03_05

用 strip、split、join 组织文本；先决定清洗规则，再观察返回的新字符串。

记忆：清洗有返回，接住才保留。

易混点：split() 的空白分词不等于模型 tokenizer；字符数量也不等于 token 数量。

- 建议先会 变量、类型与转换：字符串也是一种类型；清洗方法的结果仍是字符串。

- 联想对照：普通 split(",") 不处理带引号逗号；CSV 要交给 csv 解析器。

- 联想对照：字符数、空白分词数、token 数不可混用。

自测：name.strip() 后 name 一定改变了吗？

解析：没有；字符串不可变，通常需要 name = name.strip() 保存返回结果。

## 条件与边界

第 04 章；网页锚点：#c04-example-04_05

把业务要求写成 True/False 条件，用 if 决定走哪条分支。

记忆：先列边界，再写分支。

易混点：条件能判断规则，不能证明信息真实。

- 建议先会 变量、类型与转换：比较前先区分数值、字符串和缺失值。

- 后续用途 for 与累计：每轮可用条件决定是否累计当前项。

- 后续用途 while 与停止条件：while 要反复检查一个布尔条件。

- 后续用途 异常与数据校验：字段存在、值类型和范围，都需要明确的条件。

自测：为什么合法的 0 分不能用 if score 排除？

解析：0 的布尔值是假，但它仍是有效成绩；用 score is None 判断缺失，再检查范围。

## for 与累计

第 05 章；网页锚点：#c05-example-05_02

逐项拿到元素，并把当前值合入累计状态；空输入意味着循环一次也不执行。

记忆：每轮只看：旧状态、当前项、新状态。

易混点：统计人数是加 1，统计分数总和是加分数。

- 建议先会 条件与边界：每轮可用条件决定是否累计当前项。

- 后续用途 集合与去重：遍历输入并查 seen，可以保留首次出现顺序。

- 后续用途 排序与推导式：先能展开逐项处理，再读推导式与排序键。

- 后续用途 迭代器与生成器：理解逐项消费后，再理解按需生产一项的 yield。

- 联想对照：for 负责取值；生成器负责按需提供值，不要求先装满列表。

自测：total 从 0 开始，依次加 2、4、1，三轮后各是多少？

解析：2、6、7；必须在循环前初始化，不能每轮都清零。

## while 与停止条件

第 06 章；网页锚点：#c06-example-06_07

在条件仍成立时重复；每轮更新状态，并安排成功、失败或预算耗尽的出口。

记忆：条件、更新、出口，三个一起看。

易混点：break 只结束当前循环，结束循环不等于达到目标。

- 建议先会 条件与边界：while 要反复检查一个布尔条件。

- 后续用途 Agent 工具流程：限定最多执行多少步，并保留独立完成状态。

- 联想对照：状态若没有更新，循环可能停不下来。

自测：重试三次仍失败，能把结果标成成功吗？

解析：不能；次数只限制执行，必须独立记录是否完成任务。

## 列表与顺序

第 07 章；网页锚点：#c07-example-07_02

一个列表保存一批有顺序的数据，索引定位元素，append 增加一个元素。

记忆：一批用列表，一项看索引。

易混点：列表可以嵌套；外层长度不等于所有内层元素数之和。

- 建议先会 变量、类型与转换：列表把多个值放在有顺序的容器中。

- 后续用途 共享对象与浅拷贝：列表可变，才需要追踪多个名字是否共享它。

- 后续用途 字典与记录：先看一批元素，再用字典描述每条记录的字段。

自测：把 [2, 3] append 到 [1]，结果是什么？

解析：[1, [2, 3]]；若要逐个加入 2、3，使用 extend。

## 共享对象与浅拷贝

第 07 章；网页锚点：#c07-example-07_05

赋值改变名字指向；修改对象会影响指向它的其他名字。浅拷贝只新建外层容器。

记忆：改绑名字与修改对象分开想。

易混点：复制外层列表不代表嵌套数据已经完全独立。

- 建议先会 列表与顺序：列表可变，才需要追踪多个名字是否共享它。

- 后续用途 作用域与状态传递：函数可能修改共享对象，也可能只改绑局部名字。

- 联想对照：浅拷贝和会话串线都要追踪：谁引用了哪个可变对象？

自测：a=[[1]]，b=a.copy()，b[0].append(2) 后 a 是什么？

解析：[[1, 2]]；两个外层列表仍引用同一个内层列表。

## 字典与记录

第 08 章；网页锚点：#c08-example-08_03

字典按字段名保存一条记录；列表中装字典，就得到一批结构一致的记录。

记忆：一条用字段，一批保顺序。

易混点：键存在不代表值的类型正确，也不代表内容可信。

- 建议先会 列表与顺序：先看一批元素，再用字典描述每条记录的字段。

- 后续用途 函数与 return：记录作为参数传入，结果作为字典返回；函数也可处理简单数值。

- 后续用途 CSV / JSON / JSONL：JSON 对象和数组可对应 Python 字典和列表。

自测：一轮对话的 role/content，和多轮对话分别适合什么结构？

解析：一轮可用字典，多轮可用列表保存这些字典；顺序由外层列表表达。

## 集合与去重

第 08 章；网页锚点：#c08-example-08_06

集合便于检查某值是否已出现；若要保留首次出现顺序，同时维护结果列表。

记忆：seen 查重复，result 留顺序。

易混点：去重前是否先 strip，会改变哪些内容被视为同一条。

- 建议先会 for 与累计：遍历输入并查 seen，可以保留首次出现顺序。

- 后续用途 AI 数据与批次：清洗后检查重复样本，避免无意重复统计。

自测：为什么只用 set(texts) 不能保证原输入顺序？

解析：集合不提供输入顺序保证；按原列表遍历，首次出现时加入结果列表和 seen。

## 函数与 return

第 09 章；网页锚点：#c09-example-09_02

把一个职责封装成函数：输入参数，处理数据，return 将结果交给调用者。

记忆：参数进，返回出，调用处接。

易混点：能显示结果，不表示后面的函数已经拿到可用数据。

- 建议先会 字典与记录：记录作为参数传入，结果作为字典返回；函数也可处理简单数值。

- 后续用途 作用域与状态传递：调用之间怎样保留新状态，需要理解参数和返回值。

- 后续用途 模块与环境：把可复用函数放进模块，并把启动逻辑隔开。

- 后续用途 断言与边界测试：一个函数职责清楚，才能独立准备输入和预期返回值。

- 后续用途 迭代器与生成器：比较函数的 return 结束与 yield 暂停。

- 后续用途 Agent 工具流程：工具首先是有明确参数和返回值的可调用函数。

- 联想对照：print、list.sort 与显式 return 的返回值要分别核对。

自测：只在函数里 print(5)，外面 result=f() 能得到 5 吗？

解析：不能；没有显式 return 的函数返回 None。打印给人看，返回值供后续代码使用。

## 作用域与状态传递

第 09 章；网页锚点：#c09-example-09_10

先明确值属于哪次调用；需要外层状态变化时，用参数传入并接住新的返回值。

记忆：新状态返回后，还要赋值接住。

易混点：可变对象的原地修改，与整数参数重新绑定行为不同。

- 建议先会 函数与 return：调用之间怎样保留新状态，需要理解参数和返回值。

- 建议先会 共享对象与浅拷贝：函数可能修改共享对象，也可能只改绑局部名字。

- 后续用途 类与实例状态：把属于一个会话的状态保存在该实例上。

- 联想对照：状态若没有更新，循环可能停不下来。

自测：函数返回了新轮数，但外层轮数不变，可能缺了什么？

解析：缺少 rounds = next_round(rounds) 这样的赋值；局部参数重新绑定不会替外层名字赋值。

## 排序与推导式

第 10 章；网页锚点：#c10-example-10_03

先会写普通循环，再读压缩写法；排序键表达比较规则，例如分数降序后学号升序。

记忆：先写展开版，再读简写版。

易混点：list.sort() 修改原列表且返回 None；sorted() 返回新列表。

- 建议先会 for 与累计：先能展开逐项处理，再读推导式与排序键。

- 后续用途 综合项目与验收：把有效成绩整理成有稳定规则的排名。

- 联想对照：print、list.sort 与显式 return 的返回值要分别核对。

自测：成绩相同的时候怎样让排名可重复？

解析：明确第二排序键，如学号；sorted(rows, key=lambda r: (-r["score"], r["student_id"]))。

## 模块与环境

第 11 章；网页锚点：#c11-example-11_05

import 复用模块中的定义；先分清标准库与第三方包，再确认运行脚本所用的解释器。

记忆：解释器选对，导入才对路。

易混点：不要把自己的文件命名成 json.py 等常用标准库名，避免遮住真正模块。

- 建议先会 函数与 return：把可复用函数放进模块，并把启动逻辑隔开。

- 后续用途 路径与文件：用标准库 pathlib 管理路径，学习 with 管理文件。

自测：为什么导入模块时不应该自动运行整个命令行程序？

解析：模块顶层会执行；把命令行启动放进 if __name__ == "__main__":，才能安全复用函数。

## 路径与文件

第 12 章；网页锚点：#c12-example-12_06

路径用于定位文件，with 管理打开与关闭；用明确编码把字节转换成文本。

记忆：路径找文件，编码读文字。

易混点：文件存在不代表内容格式或业务字段正确。

- 建议先会 模块与环境：用标准库 pathlib 管理路径，学习 with 管理文件。

- 后续用途 CSV / JSON / JSONL：读到文本后，选择与文件约定相符的解析器。

- 后续用途 综合项目与验收：项目从输入文件读取，并把结果写成报告。

自测：相同相对路径为什么从另一个终端目录运行会找错文件？

解析：相对路径通常相对于当前工作目录；可用 Path(__file__).resolve().parent 定位脚本目录。

## CSV / JSON / JSONL

第 12 章；网页锚点：#c12-example-12_05

解析器把文本变成 Python 容器。CSV 字段默认仍是字符串；JSONL 每行分别解析。

记忆：先解析格式，再检查字段。

易混点：CSV 记录可以跨物理行；记录序号与文本行号不是同一个东西。

- 建议先会 路径与文件：读到文本后，选择与文件约定相符的解析器。

- 建议先会 字典与记录：JSON 对象和数组可对应 Python 字典和列表。

- 后续用途 异常与数据校验：可解析只是第一层，还要验证数据结构与含义。

- 联想对照：普通 split(",") 不处理带引号逗号；CSV 要交给 csv 解析器。

- 联想对照：工具参数格式合法不等于工具已执行，更不等于任务正确。

自测：JSON 解析成功，能直接认定 score 是 0—100 的整数吗？

解析：不能；还要检查结构、键、值的类型与范围。

## 异常与数据校验

第 13 章；网页锚点：#c13-example-13_09

针对转换错误处理具体异常；对可解析的数据再检查字段、范围，并记录拒绝原因。

记忆：能读、能解析、合约定，逐层检查。

易混点：宽泛捕获后静默跳过，会掩盖程序错误；不要把所有异常都当坏数据。

- 建议先会 CSV / JSON / JSONL：可解析只是第一层，还要验证数据结构与含义。

- 建议先会 条件与边界：字段存在、值类型和范围，都需要明确的条件。

- 后续用途 断言与边界测试：把每类拒绝原因转成能重复运行的边界案例。

- 后续用途 AI 数据与批次：无效记录先被识别，再进入划分与编码。

- 后续用途 Agent 工具流程：在执行工具前检查参数，执行后检查观察结果。

自测："abc"、"101"、"0" 作为成绩输入分别怎样处理？

解析：abc 转换失败；101 转换成功但超范围；0 转换且校验通过，必须保留。

## 断言与边界测试

第 13 章；网页锚点：#c13-example-13_04

用可手算的输入核对结果，再覆盖空输入、零值、错误类型和重复值。

记忆：先手算，再断言，再换边界。

易混点：少量测试通过只支持这些案例，不能证明所有输入都正确。

- 建议先会 函数与 return：一个函数职责清楚，才能独立准备输入和预期返回值。

- 建议先会 异常与数据校验：把每类拒绝原因转成能重复运行的边界案例。

- 后续用途 综合项目与验收：每层的预期结果组成最终验收条件。

自测：函数处理了 [60, 80]，还应该优先测试什么？

解析：空列表、0、刚好及格的 60、错误输入；空列表不应除以零。

## 类与实例状态

第 14 章；网页锚点：#c14-example-14_06

类把状态和操作放在一起；每个实例应该拥有属于自己的可变数据。

记忆：类是定义，实例各有状态。

易混点：学会函数后再学类；小任务可以先用函数，不必强行套类。

- 建议先会 作用域与状态传递：把属于一个会话的状态保存在该实例上。

- 后续用途 通向深度学习与大模型：后续模型和数据集 API 常把操作组织为类与实例。

- 联想对照：浅拷贝和会话串线都要追踪：谁引用了哪个可变对象？

自测：两个会话为什么不能共用同一个可变消息列表？

解析：对一个会话追加消息会污染另一个会话；应在每次初始化时创建新列表。

## 迭代器与生成器

第 15 章；网页锚点：#c15-example-15_06

用 next 逐项取值；yield 暂停执行并交出一个值，下次从暂停处继续。

记忆：return 结束，yield 暂停。

易混点：这里的流式文本是固定片段模拟，不会产生模型推理。

- 建议先会 for 与累计：理解逐项消费后，再理解按需生产一项的 yield。

- 建议先会 函数与 return：比较函数的 return 结束与 yield 暂停。

- 联想对照：for 负责取值；生成器负责按需提供值，不要求先装满列表。

自测：一个流式片段是否一定等于一个 token？

解析：不一定；片段可能是若干字符或多个 token 的显示结果，取决于接口。

## AI 数据与批次

第 16 章；网页锚点：#c16-example-16_02

清洗记录、保持字段对齐、划分数据、建立词表，再检查二维形状和评估分母。

记忆：先分清样本，再保持对齐。

易混点：只在训练集建立词表；本章简化编码不是生产模型的 tokenizer。

- 建议先会 集合与去重：清洗后检查重复样本，避免无意重复统计。

- 建议先会 异常与数据校验：无效记录先被识别，再进入划分与编码。

- 后续用途 通向深度学习与大模型：张量、训练与评估将接在数据准备之后。

- 联想对照：字符数、空白分词数、token 数不可混用。

自测：为什么不能分别打乱文本和标签？

解析：会破坏一一对应；应将一条记录整体移动和划分。

## Agent 工具流程

第 16 章；网页锚点：#c16-example-16_07

任务进入流程，规则选择工具，校验参数，调用函数，观察返回值，再判断是否结束。

记忆：选工具、查参数、看观察、判完成。

易混点：本课程用规则模拟决策。真实模型的输出还可能有格式错误或事实错误。

- 建议先会 函数与 return：工具首先是有明确参数和返回值的可调用函数。

- 建议先会 异常与数据校验：在执行工具前检查参数，执行后检查观察结果。

- 建议先会 while 与停止条件：限定最多执行多少步，并保留独立完成状态。

- 后续用途 通向深度学习与大模型：接入真实模型后，仍需工具契约、错误处理与任务评估。

- 联想对照：工具参数格式合法不等于工具已执行，更不等于任务正确。

自测：工具调用没有报错，能说明用户问题答对了吗？

解析：不能；还需核对返回内容是否满足原任务。执行成功与任务正确是两层判断。

## 综合项目与验收

第 17 章；网页锚点：#c17-02_score_report

把需求拆成读取、清洗、统计、输出四层；每层先用小样例验证，再连接完整流程。

记忆：一层一个职责，一层一个手算样例。

易混点：项目保留每个学号的第一条有效记录；这是一项业务约定，不是 Python 自带规则。

- 建议先会 断言与边界测试：每层的预期结果组成最终验收条件。

- 建议先会 排序与推导式：把有效成绩整理成有稳定规则的排名。

- 建议先会 路径与文件：项目从输入文件读取，并把结果写成报告。

自测：成绩项目应该先处理合法性还是先把学号记入 seen？

解析：先通过字段、转换和范围校验，再登记学号；否则第一条坏记录会错误占用该学号。

## 通向深度学习与大模型

第 18 章；网页锚点：#c18

第一阶段熟练后，再学数组与张量、线性代数、训练评估、tokenizer、模型调用和工具接口。

记忆：Python 基础是起点，模型知识要另学。

易混点：此处是后续学习路标；当前课程没有实现真实模型训练或调用。

- 建议先会 AI 数据与批次：张量、训练与评估将接在数据准备之后。

- 建议先会 Agent 工具流程：接入真实模型后，仍需工具契约、错误处理与任务评估。

- 建议先会 类与实例状态：后续模型和数据集 API 常把操作组织为类与实例。

自测：运行完规则 Agent，是否已经会训练大模型？

解析：还没有；还需数学、数据与张量、模型训练和评估。本课程提供理解后续代码的基础。

## 成绩表 → 清洗 → 报告

用原成绩项目的字段约定来拆解。先做能手算的小数据，再运行项目中的完整文件。下面的表是数据追踪，不是程序执行器。

### 读文件

输入：student_id,name,score 的 CSV 文本

处理：DictReader 按表头解析

输出：{"student_id": "S1", "name": " 小林 ", "score": "60"}

联系：路径与文件、CSV / JSON / JSONL、字典与记录

### 清洗一条

输入：name=" 小林 "，score="60"

处理：strip 清理；int 转换；检查 0 ≤ score ≤ 100

输出：{"student_id": "S1", "name": "小林", "score": 60}

联系：字符串清洗、变量、类型与转换、条件与边界、异常与数据校验

### 收集与去重

输入：一条无效 S2/abc，随后一条有效 S2/0

处理：无效条记错误；只有有效且未见过的学号才加入 seen

输出：S2 的 0 分保留；seen 不被前面的坏记录占用

联系：for 与累计、列表与顺序、集合与去重

### 统计小样例

输入：有效分数 [60, 0, 90]

处理：count=3，total=150，passed=2

输出：mean=50.0，pass_rate=2/3；空列表时均值和及格率为 None

联系：函数与 return、for 与累计、条件与边界

### 排名与验收

输入：有效记录和错误列表

处理：按分数降序、学号升序排名；写报告；对照手算

输出：JSON 报告与错误 CSV；先确认有效数、均值、比例再读排名

联系：排序与推导式、路径与文件、断言与边界测试

迁移自测：第一条 S2 分数是 abc，第二条 S2 分数是 0。为什么不能在字段校验前就 seen.add("S2")？

解析：第一条无效，不应占用 S2。提前登记会让第二条合法零分被误判重复。按项目约定，seen 只记录已保留的有效学号。扩展：两条都有效时保留第一条；若需求改成保留最新一条，就必须修改规则和测试。

## 任务 → 工具 → 观察 → 判断

沿用第 16 章的规则工具示例。每一步的中间值都应可以查看；真实模型只会让决策来源更复杂，不会替你自动完成校验。

### 描述任务

输入：用户想查询指定条目的资料

处理：保存任务文本和明确的目标

输出：任务与目标；尚未执行工具

联系：字符串清洗、字典与记录

### 决定动作

输入：任务与当前状态

处理：规则选出工具名和参数

输出：一个动作字典；它只是执行请求

联系：条件与边界、字典与记录

### 检查并执行

输入：工具名、参数字典

处理：检查工具是否存在、参数是否合约定，再调用函数

输出：返回一个值或明确的失败；工具返回值是观察数据

联系：异常与数据校验、函数与 return

### 保存观察

输入：本轮工具结果

处理：将结果放入轨迹；更新轮数和下一步状态

输出：新轨迹、新轮数；参数改绑需要外层接住返回值

联系：列表与顺序、作用域与状态传递

### 判断结束

输入：目标、观察和剩余预算

处理：核对结果是否回答原问题；失败时决定继续还是停止

输出：完成 / 未完成；达到次数上限不自动变成完成

联系：while 与停止条件、断言与边界测试、Agent 工具流程

迁移自测：工具返回 {"ok": True, "text": "未找到相关资料"}，仅凭 ok 能宣布任务正确吗？

解析：不能。ok 可能只说明工具顺利运行；还要检查内容是否满足任务。试着把失败原因拆成参数错误、工具执行失败、结果不满足任务，分别说明该在哪一步处理。这里没有调用真实模型。
