01 · DATA MINDSET
先理解数据任务,再选择工具
数据处理不是把文件交给某个库就结束了。你需要先说清楚:一行代表什么,一列代表什么,哪些字段允许为空,哪些字段必须唯一,输出要回答什么问题。NumPy 适合处理同一种类型、形状规整的数值数组;Pandas 适合用列名和索引管理表格;Matplotlib 负责把已经整理好的结果变成可观察的图形。工具是表达规则的方式,规则本身仍然由你负责。
2.1.1 一行、一列和一个字段到底代表什么
数据血缘:知道每个数字从哪里来
当一个汇总数字被质疑时,最有价值的问题不是“哪个函数算的”,而是“它来自哪些原始行”。数据血缘就是给输入、清洗、聚合和输出之间建立可回溯关系。最小做法是保留稳定的 row_id 或 source_id,清洗时不丢掉原始索引,聚合时记录来源范围,报告中写入输入文件和规则版本。这样一条柱子的平均分可以回到具体的学习记录,而不是停在一张图片上。
版本信息也要分层记录:原始文件版本、数据字典版本、处理脚本版本和输出生成时间。文件名变化不等于内容变化,最好使用来源链接、日期或校验和辅助识别。学习阶段可以先把这些信息写进 JSON;后面学习版本控制和实验追踪时,再将它们接入更完整的工具。
| 层次 | 示例 | 要留下的字段 |
|---|---|---|
| 原始记录 | CSV 第 17 行 | source、row_id、原始文本 |
| 规范化 | “ Python ”变成“Python” | 原值、新值、规则 |
| 清洗 | 负分钟进入拒绝表 | reject_reason、规则版本 |
| 聚合 | 主题 Python 的平均分 | group_key、valid_count |
| 交付 | 图表和 JSON | 输入、脚本、参数、时间 |
import json
payload = {
"source": {
"path": "data/raw/study.csv",
"encoding": "utf-8",
"version": "2026-08-27",
},
"rules": {
"topic": "strip whitespace",
"score": "0 <= score <= 100",
"group": "topic",
},
"output": {
"input_rows": 12,
"cleaned_rows": 9,
"rejected_rows": 3,
"files": ["clean.csv", "rejected.csv", "summary.json"],
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))先画出数据模型,再打开文件
初学数据处理时最容易跳过“建模”这一步:看到 CSV 就立刻调用 read_csv(),看到 DataFrame 就立刻求平均。这样做看似快速,实际上把最重要的判断藏进了默认行为。建议先拿纸或文本编辑器写下四件事:一行代表的事件、每一列的含义、记录之间的唯一标识、允许出现的空值。哪怕只有四列,也要让别人能够根据说明判断一条记录是否合理。
例如,学习记录中的 minutes 是一次连续学习的分钟数,还是当天累计分钟数?如果同一资料在一天内学习三次,是三行还是合并成一行?这两个答案会直接改变分组、去重和平均值。数据表没有脱离问题的“标准形状”,只有和问题匹配的粒度。建模时先写一句“每行是……”的完整句子,再为每一列写一句“它表示……,单位是……,允许……”。
字段名也应当表达稳定的语义。score 这种名称需要在契约中补上范围和量表;如果列名叫 time,读者不知道它是秒、分钟还是时间戳。推荐使用小写英文和下划线,把单位写入说明或列名,例如 duration_minutes。展示给人的中文标题可以在最后一步生成,不要一开始就用难以在代码中复用的长中文列名。
| 业务问题 | 建议字段 | 类型与单位 | 必须写出的边界 |
|---|---|---|---|
| 这次学习用了多久? | minutes | 非负数,分钟 | 是否允许 0,是否接受小数 |
| 这条记录属于什么主题? | topic | 非空字符串 | 空白、大小写、同义词如何统一 |
| 这次练习得到多少分? | score | 0 到 100 的数 | 缺失代表未评分还是 0 分 |
| 如何回到原始来源? | source_id | 稳定字符串 | 是否唯一,路径变化后是否仍可追踪 |
| 记录何时发生? | recorded_at | 带时区的日期时间 | 时区、格式和夏令时处理 |
record = {
"source_id": "python-basics-01",
"topic": "Python",
"minutes": 35,
"score": 82,
}
required = {"source_id", "topic", "minutes", "score"}
missing = required - record.keys()
if missing:
raise ValueError(f"缺少字段: {sorted(missing)}")
if not isinstance(record["minutes"], (int, float)):
raise TypeError("minutes 必须是数字")
if record["minutes"] < 0:
raise ValueError("minutes 不能为负数")
print("契约检查通过", record)这段代码不是完整的数据验证器,却示范了一个重要习惯:先把必需字段和基本范围写成可执行的规则。随着项目变大,可以把规则拆成函数,加入行号、字段名和原始值,让错误能够回到具体记录。验证越早发生,后面的聚合和绘图越容易解释;验证越晚,错误越可能已经被平均值和格式化输出掩盖。
把表格想成一组记录,每条记录都有相同的字段。比如学习资料实验表可以包含 source、topic、minutes、score 和 completed。一行是一次学习记录,列是记录的属性;如果把“多个主题”塞进同一个单元格,后续统计就会变得困难。数据处理的第一步不是调用 groupby(),而是写出这种最小语义。
| 概念 | 学习资料示例 | 需要提前约定的规则 | 不清楚会造成什么问题 |
|---|---|---|---|
| 记录 / 行 | 一次资料学习或一次实验运行 | 是否允许同一来源出现多次 | 重复行被误当成更多样本 |
| 字段 / 列 | minutes 学习分钟数 | 单位、类型、是否允许负数 | 数字和带单位的字符串无法比较 |
| 键 | source_id 或文件路径 | 是否唯一、是否可为空 | 合并时一对多关系被悄悄放大 |
| 缺失 | 没有填写分数 | 缺失代表未知、未发生还是不适用 | 把缺失直接填 0 改变结论 |
| 输出 | 按主题的平均分和样本量 | 排序、舍入、来源和生成时间 | 不同版本的报告无法比较 |
2.1.2 安装数据工具并保持环境可复现
环境问题要分成三层排查
数据工具的安装问题通常不是某个库“坏了”,而是解释器、环境和项目路径没有对齐。第一层是确认正在使用哪个 Python:在终端运行版本命令,并用 sys.executable 打印解释器的绝对路径。第二层是确认包安装到了同一个解释器:使用 python -m pip show pandas,不要只看系统里是否存在一个名为 pip 的命令。第三层是确认编辑器或 Notebook 的内核没有偷偷切换到另一个环境。
虚拟环境的价值不是让代码变快,而是把本项目依赖和系统 Python 隔开。项目目录中可以保留创建环境的命令,但不应把 .venv 本身提交到版本库。依赖记录也要有层次:学习阶段可以先保存主要包的版本,交付时再锁定完整依赖。升级包之前先记录当前版本并运行最小测试,升级之后比较输出,而不是只看安装命令是否成功。
不同系统的激活命令不一样,路径分隔符也不一样。把 Windows、macOS 和 Linux 的命令分别写清楚,比复制一条在自己电脑上有效的命令更友好。PowerShell 如果阻止脚本执行,应先阅读错误提示和当前执行策略;不要为了省事把系统安全设置改成完全开放。无法联网时,可以先用标准库完成数据模型练习,等环境恢复后再安装第三方包。
| 要确认的事实 | 命令或代码 | 正常证据 | 异常时先查什么 |
|---|---|---|---|
| 解释器版本 | python --version | 版本满足项目要求 | 命令是否指向预期安装 |
| 解释器路径 | sys.executable | 路径位于项目环境 | 编辑器内核是否不同 |
| 包安装位置 | python -m pip show pandas | Location 与当前环境一致 | 是否误用了全局 pip |
| 导入状态 | import pandas as pd | 能导入并打印版本 | 包名和导入名是否不同 |
| 工作目录 | Path.cwd() | 输入输出路径可预测 | 相对路径基准是否改变 |
from importlib import metadata
from pathlib import Path
import platform
import sys
print("python:", sys.version.split()[0])
print("executable:", sys.executable)
print("platform:", platform.platform())
print("workdir:", Path.cwd())
for package in ("numpy", "pandas", "matplotlib"):
try:
print(f"{package}: {metadata.version(package)}")
except metadata.PackageNotFoundError:
print(f"{package}: NOT INSTALLED")把这份报告保存到实验记录中,遇到“同一段代码在另一台电脑结果不同”时,就有一组可以比较的事实。还可以把 python -m pip freeze 的输出保存为依赖快照,但不要把快照当成对所有平台都适用的安装说明。真正重要的是知道哪些版本会影响结果,并在 README 中说明验证过的环境。
NumPy、Pandas 和 Matplotlib 都是第三方库,应该安装在项目自己的虚拟环境中。命令中的 python -m pip 会明确告诉系统“用当前这个 Python 调用 pip”,比直接输入一个可能来自其他环境的 pip 更容易排查。安装完成后,把 Python 版本、包版本和启动目录记录在 README;以后结果不一致时,先比较这些事实。
mkdir chapter-02-data
cd chapter-02-data
python -m venv .venv
# Windows PowerShell
.venv\Scripts\Activate.ps1
# macOS / Linux
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install numpy pandas matplotlib
python -c "import numpy, pandas, matplotlib; print('ready')"安装成功不等于实验可信。第三方库会有版本差异,自动生成的随机数据也会改变结果。先用小而固定的输入学习 API,再用 requirements.txt 或项目配置记录依赖版本。不要为了一个警告反复重装环境;先看警告是否改变了输出,再决定是否处理。遇到导入错误时,回到第一章的 sys.executable 检查方法。
2.1.3 第一个可复现实验:把输入、处理和输出写在一起
数据契约也要能面对变化
真实项目中的字段会增加、改名或改变允许值。面对新版本输入,先比较列集合和数据字典,再决定兼容、迁移还是停止。新增可选列通常可以警告并继续;删除必需列、改变单位或改变主键含义必须停止。把版本号和变更说明写入输出清单,未来才能区分“数据变了”与“代码算错了”。
小实验可以模拟这种变化:复制一份三行数据,增加一个备注列、改掉一个列名、把分钟单位从分钟改成秒,观察哪些检查能够发现。练习的目标不是设计完美的版本系统,而是形成一个习惯:输入契约发生变化时,输出结论不能悄悄沿用旧解释。
| 变化 | 默认动作 | 需要更新 |
|---|---|---|
| 增加可选列 | 警告并保留 | 数据字典、输出列选择 |
| 删除必需列 | 停止 | 输入准备或契约版本 |
| 单位改变 | 停止并确认 | 转换公式、图表标签、报告 |
| 主键含义改变 | 停止并迁移 | 去重、合并和血缘规则 |
从需求到数据字典的完整走法
可以把一次数据任务拆成五个连续动作。第一步写问题:希望比较什么、观察什么、交付给谁;第二步写粒度:一行代表一次事件、一个文档片段还是一个聚合主题;第三步写字段:每列的名称、类型、单位、允许值和空值含义;第四步写验收:哪些事实必须成立,哪些情况要停止;第五步才是选择 NumPy、Pandas 或绘图库。这个顺序能让工具服务于问题,而不是让 API 决定问题。
数据字典可以是一张 Markdown 表,也可以是一份 JSON。它不需要一开始就非常复杂,但要能够指导代码和测试。字段名改变时,数据字典、验证函数和输出说明应一起更新;如果只改了代码里的字符串,读者会看到“程序运行成功”,却不知道结果是否仍然和原问题一致。为每个派生字段写出公式,例如 score_ratio = score / 100,并明确是在清洗前还是清洗后计算。
验收指标最好包含结构指标和业务指标。结构指标包括行数、列数、必需列、唯一键和缺失率;业务指标包括有效分数范围、主题数量、总分钟数和输出排序。结构正确但业务不合理时要停下来,业务数字看起来合理但结构变化时也要重新确认。通过这种双重检查,才不会把一个“格式正确的错误答案”交给后续章节。
| 说明层 | 要写清楚 | 代码中的落点 | 验收证据 |
|---|---|---|---|
| 问题 | 要比较或解释什么 | 报告标题、函数文档 | 读者能复述目标 |
| 粒度 | 一行代表什么 | 主键、分组键 | 唯一性和行数对账 |
| 字段 | 类型、单位、缺失 | 转换、验证、列名 | dtypes、范围、缺失率 |
| 规则 | 过滤、修复、聚合方式 | 纯函数和配置 | 边界案例测试 |
| 交付 | 文件、排序、版本 | JSON、PNG、清单 | 回读和冷启动 |
import json
from pathlib import Path
data_dictionary = {
"topic": {
"type": "string",
"required": True,
"nullable": False,
"meaning": "一次记录所属的主题",
},
"minutes": {
"type": "number",
"unit": "minute",
"minimum": 0,
"nullable": False,
},
"score": {
"type": "number",
"unit": "0-100",
"minimum": 0,
"maximum": 100,
"nullable": True,
},
}
path = Path("output/data-dictionary.json")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(data_dictionary, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print("fields:", sorted(data_dictionary))
print("score range:", data_dictionary["score"]["minimum"], "to",
data_dictionary["score"]["maximum"])这份字典可以直接被文档、验证函数或后续的自动化工具读取。学习阶段不必马上引入复杂的 schema 框架,先理解“规则是数据的一部分”更重要。规则被保存后,未来把学习记录换成模型评测表时,只需重新写字段和约束,检查、报告和复现的方法仍然相同。
为自己的学习日志设计至少六个字段,包含一个主键、一个类别、两个数值、一个日期和一个状态。写出每列的类型、单位、空值含义、合法范围和示例值,再写三条必须停止的检查和三条可以警告的检查。最后用字典或 JSON 保存这份说明。
一个实验要留下可追踪的四类证据
“能运行”只是数据实验的起点。可复现要求别人拿到同样的输入和规则,能够得到结构相同、含义一致的输出。第一类证据是输入快照:记录文件名、来源、编码、行数和生成时间;第二类证据是处理规则:写明过滤条件、转换方式、舍入方式和随机种子;第三类证据是输出契约:说明每个结果字段、排序规则和文件格式;第四类证据是检查结果:列出断言、警告和被拒绝记录的数量。
观察顺序也很重要。先看 shape,再看列名和类型,再看前几行、唯一值和缺失比例,最后才进行均值或排名。这样做不是形式主义,而是避免在“空表”“错列”“日期被当字符串”等情况下得到看似合理的结果。每一步都可以留下一个小输出,不必把整张表打印到屏幕上。
| 阶段 | 要回答的问题 | 建议证据 |
|---|---|---|
| 输入 | 数据来自哪里,有多少条? | 路径、来源、编码、行数、列名 |
| 规则 | 哪些值会被转换或拒绝? | 函数、参数、阈值、随机种子 |
| 处理 | 每一步改变了什么? | 前后 shape、缺失数、唯一键数 |
| 输出 | 别人如何读取并验证结果? | 文件路径、格式、字段说明、校验和 |
import pandas as pd
frame = pd.DataFrame([
{"topic": "Python", "minutes": 35, "score": 82},
{"topic": "数据", "minutes": 50, "score": 91},
{"topic": "Python", "minutes": 20, "score": 76},
])
print("shape:", frame.shape)
print("columns:", frame.columns.tolist())
print("dtypes:")
print(frame.dtypes)
print("missing:")
print(frame.isna().sum().to_dict())
print("unique topics:", frame["topic"].nunique())
print(frame.head(2).to_string(index=False))把“检查”写成代码后,它就能在输入变化时自动提醒你。比如输入文件突然多了一列,并不一定是错误;但你至少应该看到列集合发生了变化,再决定是否更新契约。对关键假设使用 assert 时,错误信息要说明假设是什么,避免只留下一个难以定位的 AssertionError。
import json
from pathlib import Path
summary = {
"input_rows": 3,
"topics": ["Python", "数据"],
"rule": "score_ratio = score / 100",
"sort": "score_ratio descending",
}
output = Path("output/experiment.json")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(summary, ensure_ascii=False, indent=2),
encoding="utf-8",
)
loaded = json.loads(output.read_text(encoding="utf-8"))
assert loaded["input_rows"] == 3, "输入行数记录错误"
print("可回读:", loaded)用三到五条人工记录完成一次小实验。不要只提交最终平均值,而是同时输出:输入行数、列名、每列类型、缺失数量、分组键数量、处理规则和 JSON 回读结果。故意删掉一列或把一个数字改成空字符串,观察哪一个检查最先发现问题,并把错误消息改写得让同学可以采取行动。
一个最小的数据实验应当能回答四个问题:输入从哪里来,清洗规则是什么,输出有哪些字段,别人怎样重复运行。下面使用手写的三行记录,不连接网络,也不依赖隐藏文件。它的重点不是统计结论,而是把“数据准备”写成可观察的流水线。
import pandas as pd
rows = [
{"topic": "Python", "minutes": 35, "score": 82},
{"topic": "数据", "minutes": 50, "score": 91},
{"topic": "Python", "minutes": 20, "score": 76},
]
frame = pd.DataFrame(rows)
frame["score_ratio"] = frame["score"] / 100
summary = frame.groupby("topic", as_index=False)["score_ratio"].mean()
print(frame.shape)
print(summary.sort_values("score_ratio", ascending=False))frame.shape 应该是三行四列;分组结果只有两个主题。先看形状和列名,再解释平均分,不要只复制最后一行输出。准备一个包含“资料名、主题、阅读分钟、是否完成”的小表。先用自然语言写明每列类型、允许的空值和一行的含义,再用 Pandas 创建 DataFrame。故意把一个分钟数写成带空格的字符串,观察 dtype 和平均值计算如何变化。
pd.to_numeric(errors="coerce") 标记无法转换的值,再决定修复、删除还是保留为缺失。02 · NUMPY ARRAYS
NumPy:用数组形状表达数值数据
Python 列表可以保存不同类型的对象,适合通用编程;NumPy 的 ndarray 更强调同质数据、固定形状和批量运算。图像像素、模型特征、批量指标和向量通常都能表示成数组。学习 NumPy 的关键不是记住几十个函数,而是每次操作前都确认数组的 ndim、shape、size 和 dtype。
2.2.1 ndarray、dtype 和 shape
精度、溢出和可比较性
dtype 不只决定内存大小,也决定运算的范围和精度。小整数类型在加法中可能溢出,浮点数不能精确表示所有十进制小数,字符串数组则会改变运算语义。学习时可以用小数组观察这些差异:先打印类型、最小值和最大值,再做转换。不要因为结果“看起来差不多”就忽略精度;模型输入、金额和计量数据需要在契约中写明容许误差。
比较浮点结果时使用绝对误差或相对误差,而不是期待二进制浮点完全相等。保存数组时还要记录 dtype 和 shape,重新读取后做断言。对从 DataFrame 转来的数组,确认列顺序没有改变;对从文本转来的数组,确认缺失值没有被当成字符串。数组本身只知道数值,不知道单位和来源,这些信息要由外部契约携带。
| 检查 | 代码线索 | 目的 |
|---|---|---|
| 类型范围 | dtype、min、max | 发现溢出和异常量级 |
| 浮点比较 | np.isclose() | 允许合理误差 |
| 缺失 | np.isnan() | 区分 NaN 和普通字符串 |
| 形状 | shape、size | 确认列顺序和样本数量 |
| 来源 | 旁边的字典或清单 | 补上单位和版本语义 |
import numpy as np
small = np.array([120, 120], dtype=np.int8)
wide = small.astype(np.int16)
float_values = np.array([0.1, 0.2, 0.3])
print("small:", small, "dtype:", small.dtype)
print("wide sum:", int(wide.sum()), "dtype:", wide.dtype)
print("float sum:", float_values.sum())
print("close to 0.6:", np.isclose(float_values.sum(), 0.6))
assert wide.dtype == np.int16
assert np.isclose(float_values.sum(), 0.6)把数组的四个观察指标读成一句话
面对一个 NumPy 数组,先不要急着做运算,可以把它的结构读成一句话:“这是一个有几条轴、每条轴有多少元素、元素采用什么类型、总共有多少个元素的容器。”ndim 回答有几条轴,shape 按顺序给出每条轴的长度,size 是所有长度的乘积,dtype 说明每个位置存储的数值类型。四个属性相互印证,任何一个与预期不符,都应该先停下来。
例如 (100, 768) 可以表示 100 条样本、每条 768 个特征,也可以表示 100 行 768 列的普通表格。NumPy 不知道“100”代表样本还是时间步,这个语义必须由程序员写在变量名、注释和数据契约中。把 features、labels、batch 这样的名称和形状一起写出来,比只写一个含糊的 x 更容易排查模型输入错误。
数组和 Python 列表的差异还体现在元素类型上。列表允许同一个容器里放整数、字符串和对象;NumPy 通常会尝试把元素转换成统一的 dtype。整数与浮点混合时可能提升为浮点,字符串混入后可能变成字符串数组,后续加法就不再是数值加法。创建数组后立即检查 dtype,能在错误扩散前发现问题。
| 属性 | 回答的问题 | 示例 | 常见误解 |
|---|---|---|---|
ndim | 有多少条轴? | 二维表为 2 | 不是元素总数 |
shape | 每条轴有多长? | (2, 3) | 顺序不能随意交换 |
size | 总共有多少个位置? | 2 × 3 = 6 | 不是内存字节数 |
dtype | 每个元素怎样存储? | int64 | 不等于业务单位 |
itemsize | 单个元素占多少字节? | 8 字节 | 小 dtype 可能溢出 |
import numpy as np
values = np.array([[1, 2.5, 3], [4, 5, 6]])
print("ndim:", values.ndim)
print("shape:", values.shape)
print("size:", values.size)
print("dtype:", values.dtype)
print("itemsize:", values.itemsize)
print("nbytes:", values.nbytes)
assert values.size == values.shape[0] * values.shape[1]
assert values.ndim == 2这里整数和小数混合后通常会得到浮点 dtype,这是为了保证小数不被截断。若你希望分数始终是整数,就应该在输入契约中禁止小数,或者在明确规则后再转换,而不是依赖 NumPy 的自动推断。数组的属性是观察工具,不是装饰信息;把它们打印在每个实验的开头,能让后续输出有上下文。
shape 和 dtype。如果你无法用一句话解释每个轴,暂时不要进行 reshape 或矩阵乘法。一维数组可以理解为一行数值,二维数组可以理解为矩形表格,更高维数组则像一叠表格。shape 描述每个轴的长度,ndim 是轴的数量,size 是所有元素的总数,dtype 说明元素采用什么数值类型。形状不是装饰信息,它决定索引方式、矩阵运算和广播能否成立。
import numpy as np
scores = np.array([[82, 91, 76], [88, 79, 95]])
print("ndim:", scores.ndim)
print("shape:", scores.shape)
print("size:", scores.size)
print("dtype:", scores.dtype)
print("first row:", scores[0])
print("one value:", scores[1, 2])这个数组有两个轴:第一个轴可以理解为学习者,第二个轴可以理解为三次测验。访问 scores[1, 2] 时,先取第二行,再取第三列。不要把“第二个元素”这种自然语言直接翻译成两个下标;先写清楚每个轴的语义,才能避免把行列顺序弄反。
2.2.2 创建数组、控制类型和记录精度
创建数据时,先区分“规则输入”和“真实输入”
np.zeros()、np.ones() 和 np.full() 适合初始化计算缓冲区;np.arange() 适合整数索引和离散测试;np.linspace() 适合需要指定采样数量的连续区间。它们都能生成数组,却表达不同的意图。看到一段生成数据的代码时,要问它是在模拟真实记录、创建坐标轴,还是为后续累加准备空间,避免把测试数据误当成业务数据。
arange(stop) 的终点通常不包含,和 Python 的 range() 类似;linspace(start, stop, num) 默认包含终点,适合“从起点到终点取几个点”。当步长是浮点数时,arange() 可能产生累计误差,边界值不一定正好出现。绘图横轴通常优先考虑 linspace(),索引和计数则优先考虑整数序列。
dtype 是存储和计算的共同约束。int8 可以节省空间,但可表示范围很小;float32 常用于大规模数值计算,float64 更适合需要较高精度的统计。把浮点转为整数会截断小数,不是四舍五入;需要舍入时先调用 round() 或 np.rint(),再转型。转换前后应比较最小值、最大值和缺失情况。
| 方式 | 意图 | 终点规则 | 先验证什么 |
|---|---|---|---|
zeros / ones / full | 初始化固定形状的缓冲区 | 由 shape 决定 | 默认 dtype 和填充值 |
arange | 整数索引或近似步长 | 通常不含 stop | 步长符号和元素数量 |
linspace | 指定数量的连续采样 | 默认包含 stop | num 是否包含起终点 |
array.astype | 显式改变元素类型 | 不改变 shape | 截断、溢出、精度损失 |
asarray | 尽量复用已有数组 | 不负责清洗值 | 输入是否已经满足 dtype |
import numpy as np
indexes = np.arange(0, 5)
axis = np.linspace(0.0, 1.0, num=5)
buffer = np.full((2, 3), fill_value=-1, dtype=np.int16)
raw = np.array([1.9, 2.1, 3.8])
rounded = np.rint(raw).astype(np.int64)
truncated = raw.astype(np.int64)
print("indexes:", indexes)
print("axis:", axis)
print("buffer:", buffer)
print("rounded:", rounded)
print("truncated:", truncated)这个例子同时展示了“舍入”和“截断”的差异。数据进入模型或报告之前,应把转换规则写在函数名、注释或配置中,并保留原始列。只保存转换后的值,会让以后的人无法判断一个整数到底来自精确测量还是被截断的浮点数。
np.array([1, "2", 3]) 可能得到字符串 dtype,表面上仍然有三个元素,但 mean() 不再代表数值平均。不要用“数组创建成功”代替类型验证。np.array() 可以从列表创建数组,zeros()、ones()、arange() 和 linspace() 适合生成规则输入。类型转换要有意识:整数转浮点通常是安全的,浮点转整数会截断小数,过小的整数类型还可能溢出。做模型特征时,保存原始值和转换规则,避免只剩一个无法解释的数组。
常用创建函数的选择
| 函数 | 返回什么 | 适合场景 | 边界提醒 |
|---|---|---|---|
np.array(data) | 由已有序列构成的数组 | 把列表或嵌套列表交给数值工具 | 嵌套长度不一致会形成意外对象或报错 |
np.zeros(shape) | 全为 0 的数组 | 初始化累计量或掩码 | 先确认默认浮点类型是否合适 |
np.arange(start, stop, step) | 等差序列 | 索引、整数测试数据 | 浮点步长可能产生边界误差 |
np.linspace(start, stop, num) | 指定数量的等距值 | 绘图横轴和实验采样 | 默认包含终点,和 range() 不同 |
array.astype(dtype) | 转换类型后的数组 | 明确控制存储和计算类型 | 转换可能截断、溢出或产生缺失 |
import numpy as np
counts = np.arange(0, 10, 2)
axis = np.linspace(0, 1, num=5)
empty_scores = np.zeros((2, 3), dtype=np.float32)
rounded = np.array([1.9, 2.1]).astype(np.int64)
print(counts)
print(axis)
print(empty_scores.shape, empty_scores.dtype)
print(rounded)2.2.3 向量化运算和聚合轴
从数组结果回到原始语义
NumPy 计算完成后,最好把结果重新包装成带名称和单位的对象。一个没有列名的三元素数组无法告诉读者三个值分别对应哪次测验;一个没有样本量的平均数组无法说明可靠程度。可以使用字典、DataFrame 或轻量的结果类携带轴名称、指标名称和规则,避免在函数之间传递“裸数组”。
当数组进入模型或绘图时,检查顺序尤其重要。列顺序一旦改变,形状可能仍然相同,模型却会把分钟当成分数,把分数当成完成状态。把特征名称列表和数组列一一对应,并在保存或加载后断言名称和顺序一致,是低成本但很有效的保护。
import numpy as np
feature_names = ["minutes", "score", "completed"]
matrix = np.array([
[35.0, 82.0, 1.0],
[50.0, 91.0, 1.0],
])
assert matrix.ndim == 2
assert matrix.shape[1] == len(feature_names)
feature_mean = dict(zip(feature_names, matrix.mean(axis=0)))
print("feature order:", feature_names)
print("feature mean:", feature_mean)
reloaded_names = ["minutes", "score", "completed"]
assert reloaded_names == feature_names向量化不等于“永远不要写循环”
向量化适合对整个数组执行相同的数值规则,例如加减、比较、裁剪和按轴聚合;它的优势来自清楚的批量表达和底层实现。循环仍然适合处理每条记录需要不同错误消息、不同外部资源或复杂分支的情况。不要为了追求一行代码,把难以检查的广播和嵌套表达式堆在一起。优先选择能说出轴语义、能打印中间结果的写法。
性能判断也要有证据。小数组上循环和向量化的差异可能不重要,优化前应先用简单计时确认瓶颈,并确保两种实现产生同样的结果。内存占用、dtype 和临时数组数量会影响大数据处理;把一个大数组复制三次可能比清晰的循环更昂贵。学习阶段先保证规则正确,再用规模化样本测量。
聚合时应把结果和元数据放在一起。除平均分外,保存有效计数、最小值、最大值或分位数;除归一化后的数组外,保存均值、标准差和缺失策略。这样模型或图表出现异常时,能够回到变换参数,而不是重新猜测数据被怎样缩放。
| 任务 | 优先方式 | 原因 | 需要额外验证 |
|---|---|---|---|
| 统一加减、比较、裁剪 | 数组运算 | 表达短、规则一致 | shape、dtype、广播方向 |
| 按轴统计 | sum/mean/count | 直接表达聚合语义 | axis、缺失、样本量 |
| 每行复杂分支 | 先写清楚的循环或函数 | 错误更容易定位 | 输出顺序和副作用 |
| 大规模热点 | 先测量再优化 | 避免凭感觉优化 | 结果一致和内存峰值 |
import numpy as np
scores = np.array([60.0, 75.0, 90.0, np.nan])
valid = ~np.isnan(scores)
vectorized = np.where(valid, scores * 1.1, np.nan)
loop_result = []
for score in scores:
if np.isnan(score):
loop_result.append(np.nan)
else:
loop_result.append(score * 1.1)
loop_result = np.array(loop_result)
same = np.allclose(
vectorized,
loop_result,
equal_nan=True,
)
print("same result:", same)
print("valid count:", int(valid.sum()))
print("scaled:", vectorized)这个例子没有把循环当成错误答案,而是用它验证向量化表达。两种实现都保留缺失值,并用 allclose() 比较浮点结果;浮点数不应简单用 == 判断完全相等。验证通过后,再根据数据量和维护成本决定保留哪一种实现。
import numpy as np
matrix = np.array([
[60.0, 70.0, np.nan],
[90.0, 80.0, 75.0],
])
valid_count = np.sum(~np.isnan(matrix), axis=0)
mean = np.nanmean(matrix, axis=0)
minimum = np.nanmin(matrix, axis=0)
maximum = np.nanmax(matrix, axis=0)
for index, values in enumerate(zip(mean, valid_count, minimum, maximum)):
average, count, low, high = values
print({
"test": index,
"average": float(average),
"valid_count": int(count),
"min": float(low),
"max": float(high),
})axis 不是参数记忆题,而是“沿哪条方向压缩”
对二维数组执行聚合时,可以把被聚合的那条轴想成被“折叠”掉。形状为 (2, 3) 的数组,使用 axis=0 会把两行按列合并,留下三个列结果;使用 axis=1 会把三列按行合并,留下两个行结果。先在纸上标注“行=学习者、列=测验”,再决定轴,远比死记 axis=0 的含义可靠。
很多聚合还需要同时保留维度。keepdims=True 会让结果保留长度为 1 的轴,便于后续广播;不保留维度的结果更便于展示。两种结果都可能正确,区别在于接下来要进行什么操作。遇到广播失败时,先比较结果的 shape,再决定是否添加一个新轴,而不是随意调用 flatten() 把信息抹平。
缺失值是聚合的另一处陷阱。普通的 mean() 遇到 NaN 可能返回 NaN,nanmean() 会忽略缺失值,但样本量也随之改变。报告平均数时应该同时报告有效样本数,并说明缺失值被忽略、填补还是拒绝。否则两个看似相同的平均值可能来自完全不同的数据量。
| 表达式 | 输入 shape | 典型输出 shape | 语义 |
|---|---|---|---|
a.mean() | (2, 3) | () | 全部元素的一个平均值 |
a.mean(axis=0) | (2, 3) | (3,) | 每列一个结果 |
a.mean(axis=1) | (2, 3) | (2,) | 每行一个结果 |
a.mean(axis=1, keepdims=True) | (2, 3) | (2, 1) | 每行结果仍可沿列广播 |
import numpy as np
scores = np.array([
[60.0, 70.0, 80.0],
[90.0, np.nan, 75.0],
])
row_mean = np.nanmean(scores, axis=1)
column_mean = np.nanmean(scores, axis=0)
row_count = np.sum(~np.isnan(scores), axis=1)
centered = scores - np.nanmean(scores, axis=1, keepdims=True)
print("row mean:", row_mean)
print("column mean:", column_mean)
print("valid count:", row_count)
print("centered shape:", centered.shape)这里的 row_count 是报告平均值时不可缺少的伴随信息。若一个主题只有一条有效记录,平均值的解释力度和有一百条记录时不同。向量化运算让计算表达清楚,却不会替你判断统计是否可靠;样本量、单位和缺失策略仍要由你写入输出。
import numpy as np
minutes = np.array([20, 40, 60])
completed = np.array([True, False, True])
effective = np.where(completed, minutes, 0)
long_sessions = minutes >= 45
weighted = minutes * np.where(completed, 1.0, 0.5)
print("effective:", effective)
print("long sessions:", long_sessions)
print("weighted:", weighted)创建一个 (4, 3) 的成绩数组,其中至少包含一个缺失值。完成每个学习者的有效平均分、每次练习的有效平均分和有效样本数;再用 keepdims=True 计算每行中心化结果。提交时写出每个变量的 shape、缺失策略和一个不应使用普通 mean() 的理由。
(4,),列平均通常是 (3,),保留维度的行平均是 (4, 1)。如果缺失值会使普通平均值变成 NaN,应使用明确的忽略或填补规则,并把有效样本数一起输出;不能只用一个漂亮的平均值掩盖数据量变化。数组运算通常可以一次作用于所有元素,不必手写逐项循环。两个形状相同的数组可以逐元素相加、相减或相乘;sum()、mean()、min() 和 max() 可以按整体或指定轴聚合。这里的“轴”不是抽象词:对形状为 (2, 3) 的数组,axis=0 通常得到每一列的结果,axis=1 通常得到每一行的结果。
import numpy as np
before = np.array([60, 70, 80])
after = np.array([66, 77, 88])
improvement = after - before
matrix = np.array([[60, 70, 80], [66, 77, 88]])
print("improvement:", improvement)
print("each learner:", matrix.mean(axis=1))
print("each test:", matrix.mean(axis=0))
print("overall:", matrix.mean())向量化写法通常更短,也能利用底层实现提高速度,但它不会自动替你检查业务含义。两个数组形状相同并不代表它们的元素一一对应;要确认行顺序、单位和样本来源。遇到广播或聚合结果不对时,先打印形状和一两个小样本,再检查公式。不要用“大数组跑完没有报错”证明结果正确。
创建一个形状为 (3, 4) 的分数数组,行表示三个学习者,列表示四次练习。分别计算每个学习者的平均分、每次练习的平均分和全体平均分;再用布尔掩码找出低于 60 的分数。写下每个结果的形状,并解释为什么它们不同。
axis=1,结果形状为 (3,);列平均使用 axis=0,结果形状为 (4,);整体平均是一个标量。掩码形状与原数组相同,应用后得到一维筛选结果。评分重点是轴的语义和形状解释,不是把所有结果打印得很长。03 · SHAPE & INDEX
数组的形状、索引、切片与广播
很多数据错误并不是数值算错,而是“把正确的数放到了错误的位置”。NumPy 用下标、切片和布尔数组选择数据,用广播把兼容形状的数组组合起来。理解这些操作时,始终把数组画成一个小矩形:每个轴表示什么,每个位置代表什么,切片返回的是副本还是视图。
2.3.1 索引、切片与视图风险
切分数据时防止重叠和泄漏
训练、验证和测试的切分本质上也是索引问题。随机切分前要固定种子并保存索引;按时间切分要保证未来记录没有跑进训练集;按来源切分要避免同一文档的相邻片段出现在不同集合。NumPy 只执行你给的切片,不会检查样本是否重复,因此切分契约需要你自己写断言。
切分后检查三件事:各集合的形状和比例、稳定 ID 是否互斥、类别分布是否出现意外偏移。对小数据,打印交集和几个边界样本;对大数据,保存索引清单和计数摘要。不要在切分后再随机打乱而不记录顺序,因为复现和错误定位都会变得困难。
| 切分方式 | 适合 | 风险 | 应保存 |
|---|---|---|---|
| 按位置 | 已经排序的时间记录 | 输入顺序不稳定 | 起止位置和排序规则 |
| 按随机索引 | 独立同分布的小样本 | 种子变化、重复样本 | 随机种子和索引 |
| 按来源 | 文档、用户或设备分组 | 来源键缺失 | 分组键和互斥检查 |
| 按时间 | 预测未来数据 | 时区和边界日期 | 截止时间和时区 |
import numpy as np
ids = np.arange(12)
rng = np.random.default_rng(7)
shuffled = rng.permutation(ids)
train = shuffled[:8]
valid = shuffled[8:10]
test = shuffled[10:]
print("train:", train)
print("valid:", valid)
print("test:", test)
assert set(train).isdisjoint(valid)
assert set(train).isdisjoint(test)
assert set(valid).isdisjoint(test)
assert len(set(train) | set(valid) | set(test)) == len(ids)索引表达的是位置,标签表达的是含义
一维数组的下标是位置,二维数组的两个下标通常写成“第几行、第几列”。下标从 0 开始,所以 a[0] 是第一项,a[-1] 是最后一项。切片使用“起点包含、终点不包含”的半开区间,这让相邻切片可以首尾相接而不重复。写切片之前先说清楚边界,例如“取第 2 到第 5 个样本”要翻译为 array[2:5],不要凭感觉加减 1。
基础切片通常返回视图,视图与原数组共享底层数据;整数下标和布尔筛选常常返回新的数组。不要把这种实现细节当成可以永远背下来的口诀,关键操作之后直接修改一个小样本并比较原数组,或者显式使用 copy()。当数据要传给另一个函数,而你不希望函数原地改变输入时,复制是一种清楚的接口承诺。
高级索引还包括整数数组索引和布尔索引。整数索引可以按任意顺序抽取位置,结果通常是副本;布尔索引要求掩码形状能够对应被选择的轴。高级索引很灵活,也更容易把行列顺序写错。初学阶段先用小数组打印下标和结果,再把同样的表达式放进真实数据。
| 写法 | 含义 | 结果特点 | 检查重点 |
|---|---|---|---|
a[2] | 取位置 2 | 可能降一维 | 是否真的要丢掉轴 |
a[1:4] | 取位置 1、2、3 | 常为视图 | 修改是否会回写 |
a[:, 0] | 取二维数组第一列 | 结果通常一维 | 列语义是否明确 |
a[[2, 0]] | 按指定顺序取两行 | 常为副本 | 顺序是否改变 |
a[a > 0] | 取满足条件的元素 | 可能变成一维 | 是否还需要原二维形状 |
import numpy as np
matrix = np.arange(1, 13).reshape(3, 4)
print("matrix:\\n", matrix)
print("second row:", matrix[1])
print("first column:", matrix[:, 0])
print("middle block:\\n", matrix[0:2, 1:3])
print("rows in custom order:\\n", matrix[[2, 0]])
window = matrix[0:2, 0:2]
window[0, 0] = 99
print("changed by view:\\n", matrix)
independent = matrix[0:2, 0:2].copy()
independent[0, 0] = -1
print("original after copy change:\\n", matrix)
print("independent block:\\n", independent)如果切片用于训练集和验证集,推荐在变量名中保留它的语义,例如 train_rows、valid_rows,并断言两者没有重叠。若要把结果交给会原地修改的函数,先复制再传入。复制不是“浪费内存”的同义词,而是让所有权边界可见;数据量很大时,再根据性能测量决定是否使用只读视图。
matrix[:, 0] 和 matrix[:, 0:1] 的形状不同。需要保留二维列形状时,不要把它们混用。NumPy 的索引从 0 开始,与 Python 列表相似;二维数组可以在同一组方括号中写出行列下标。切片可能返回原数组的视图,修改切片就可能影响原数组。需要独立副本时,显式调用 copy()。这和第一章讲过的列表别名问题很像,但在数值实验中更容易因为“只改了一个小窗口”而改变整个输入。
import numpy as np
scores = np.array([10, 20, 30, 40])
view = scores[1:3]
view[0] = 99
print("原数组被视图影响:", scores)
copy = scores[1:3].copy()
copy[0] = 7
print("副本修改不影响原数组:", scores)
print("副本:", copy)切片语法的起点包含、终点不包含,scores[1:3] 只选择位置 1 和 2。做训练集、验证集切分时,边界必须写成可检查的规则,并记录样本数量。若切片用于只读计算,视图可以节省内存;若后续代码会原地赋值,优先使用副本,避免隐藏副作用。
2.3.2 布尔筛选、组合条件与广播
先生成掩码,再检查掩码的形状
布尔筛选可以分成两步:第一步根据规则生成 True 或 False 的掩码,第二步把掩码应用到数组上。把两步分开写,能够打印出规则到底命中了哪些位置。组合条件必须使用逐元素运算符 &、| 和 ~,每个比较表达式都加括号;and、or 面向单个布尔值,不能直接判断一整组数组。
筛选后的结果默认会压缩成一维,适合拿到“所有满足条件的值”,但不一定适合继续保持样本行结构。如果你要保留每一行,只标记某一列是否合格,可以保留二维掩码或对行轴做 any()、all()。业务规则中的“至少一个特征超标”和“所有特征都合格”是不同的聚合逻辑,不能只换一个函数名。
广播按从最后一条轴开始比较:两个维度相同,或其中一个为 1,才可能兼容。形状为 (2, 3) 的数组可以和 (3,) 相加,因为后者相当于每行复用;不能直接和 (2,) 相加,因为最后一条轴长度 3 和 2 不匹配。广播成功后也要回到业务语义,确认复用方向正是你想要的。
| 问题 | 正确做法 | 错误做法 | 为什么 |
|---|---|---|---|
| 两个条件同时成立 | (a > 0) & (a < 10) | a > 0 and a < 10 | 数组需要逐元素运算 |
| 取反 | ~mask | not mask | not 只接受单个布尔值 |
| 保留原二维形状 | 使用列掩码或 where | 直接 a[mask] 后忘记维度 | 筛选结果可能被压平 |
| 按列加偏置 | 偏置形状为 (列数,) | 把学习者数组误当列偏置 | 广播方向改变含义 |
import numpy as np
scores = np.array([[60, 72, 81], [55, 90, 68], [88, 64, 92]])
valid = (scores >= 60) & (scores <= 100)
row_ok = valid.all(axis=1)
row_has_low = (scores < 60).any(axis=1)
bonus_by_test = np.array([5, 0, 2])
adjusted = scores + bonus_by_test
clipped = np.clip(adjusted, 0, 100)
print("valid mask:\\n", valid)
print("all tests valid:", row_ok)
print("has low score:", row_has_low)
print("adjusted:\\n", adjusted)
print("clipped:\\n", clipped)np.where(condition, yes, no) 适合把条件转换为两个结果,np.clip() 适合表达上下限裁剪;它们都不会自动告诉你有多少值被改变。做数据清洗时要同时统计命中掩码的数量,并保留原始值或拒绝表,避免把“修正”伪装成原始事实。
import numpy as np
features = np.array([[1.0, 10.0, 100.0], [2.0, 20.0, 200.0]])
mean = features.mean(axis=0)
scale = features.std(axis=0)
scale = np.where(scale == 0, 1.0, scale)
normalized = (features - mean) / scale
assert normalized.shape == features.shape
print("mean:", mean)
print("normalized:\\n", normalized)布尔筛选先得到一个与数组形状对应的真假数组,再用它选出满足条件的元素。组合条件时使用 & 和 |,每个条件都要加括号;Python 的 and 和 or 不能直接替代数组条件。广播允许一个形状较小的数组沿兼容轴重复使用,但“能广播”不代表“业务上应该广播”。
import numpy as np
scores = np.array([[60, 72, 81], [55, 90, 68]])
passed = scores[scores >= 60]
good = scores[(scores >= 60) & (scores < 90)]
bonus = np.array([5, 0, 2])
adjusted = scores + bonus
print("通过分数:", passed)
print("60 到 89:", good)
print("按列加分:\n", adjusted)这里的 bonus 形状为 (3,),会沿着两行复用,所以它表达的是“每一列统一加分”。如果你误把每个学习者的加分写成一维数组,广播可能仍然成功,却产生完全不同的含义。对关键变换,最好在代码旁写一句“沿哪个轴广播”,并用一个两行小数组手工验证。
2.3.3 reshape、转置和数据形状图
把形状说明写进函数接口
如果函数要求“二维、每行一个样本、四个特征”,就把这个要求放在入口处检查,而不是让错误一路传到矩阵乘法。断言可以检查 ndim 和特征数,错误消息可以带出实际 shape。对于空数组、单个样本和只有一列的特殊情况,也要提前决定允许还是拒绝;不同选择会影响后续广播和统计。
形状说明最好同时出现在变量名、注释和测试中。sample_features 比 x 更能提醒读者第一维是什么;测试使用编号数组比随机数更容易发现排列错误。等数据进入模型后,还要保存特征名称、标准化参数和形状,确保推理阶段使用同样的列顺序。
| 问题 | 示例答案 |
|---|---|
| 一行代表什么? | 一个学习记录或一个样本 |
| 每一列代表什么? | 一个有名称和单位的特征 |
| 允许哪些特殊形状? | 空输入、单样本是否单独处理 |
| 变换后怎样证明没乱? | shape、编号值、特征名称断言 |
import numpy as np
def require_samples_features(array, feature_count):
if array.ndim != 2:
raise ValueError(f"需要二维数组,实际是 {array.shape}")
if array.shape[1] != feature_count:
raise ValueError(
f"特征数不符,期望 {feature_count},实际 {array.shape[1]}"
)
return array
features = np.arange(20).reshape(5, 4)
checked = require_samples_features(features, feature_count=4)
print("accepted:", checked.shape)形状错误的诊断路径
当模型或矩阵运算报告维度不匹配时,先不要马上修改最后一个数字。第一步打印输入的 shape、ndim 和一小段样本;第二步确认哪一维代表样本、特征、时间或通道;第三步回看上游是否有筛选、降维或转置;第四步才选择 reshape、expand_dims 或 transpose。每一步只做一种变换,并在输出中记录新形状。
降维是最容易悄悄发生的变化。用整数索引取一列可能从 (n, 1) 变成 (n,),用 mean(axis=1) 会删除一条轴,布尔筛选可能把二维结果压成一维。需要保留样本边界时,用切片 0:1、keepdims=True 或显式增加新轴;不要把所有结果都 flatten 后再希望下游自动恢复。
形状变换还应考虑数据排列顺序。默认 reshape 按行组织元素,转置只是改变视图的轴顺序;如果原始数组代表图像、时间序列或批量特征,排列顺序错误不会总是立即报错,却会让模型学到错误关系。用一个编号数组做小实验,观察每个元素从哪里移动,再处理真实数据。
| 症状 | 先打印 | 常见原因 | 修复前必须确认 |
|---|---|---|---|
| 矩阵乘法维度不符 | 左右两边 shape | 样本轴和特征轴交换 | 乘法公式和轴语义 |
| 结果少了一维 | 索引表达式、ndim | 整数索引或聚合降维 | 是否需要保留长度 1 的轴 |
| 广播结果形状正确但数值错 | 双方 shape、示例值 | 沿错误方向复用 | 每个偏置对应哪一列或哪一行 |
| reshape 不报错但模型变差 | 编号数组、样本顺序 | 排列语义被打乱 | 元素顺序和来源边界 |
import numpy as np
def describe(name, array):
print({
"name": name,
"shape": array.shape,
"ndim": array.ndim,
"dtype": str(array.dtype),
"sample": array.reshape(-1)[:6].tolist(),
})
raw = np.arange(12).reshape(3, 4)
column_vector = raw[:, 0:1]
column_flat = raw[:, 0]
row_mean = raw.mean(axis=1, keepdims=True)
transposed = raw.T
for name, array in [
("raw", raw),
("column_vector", column_vector),
("column_flat", column_flat),
("row_mean", row_mean),
("transposed", transposed),
]:
describe(name, array)
assert column_vector.shape == (3, 1)
assert column_flat.shape == (3,)
assert row_mean.shape == (3, 1)调试函数把数组压成一维只用于展示前六个元素,原数组本身没有被改变。把这种小工具放在实验文件的开头,可以明显减少“打印整个大数组”的噪声。真正提交项目时,保留关键 shape 断言,删除无关的全部输出。
import numpy as np
numbered = np.arange(1, 13).reshape(3, 4)
as_two_rows = numbered.reshape(2, 6)
as_columns = numbered.reshape(12, 1)
print("numbered:\\n", numbered)
print("two rows:\\n", as_two_rows)
print("columns:\\n", as_columns)
print("transpose:\\n", numbered.T)
assert numbered.ravel()[0] == 1
assert as_two_rows.ravel()[5] == 6
assert as_columns.shape == (12, 1)用 1 到 24 的编号数组模拟一个批量数据,尝试构造“4 个样本、6 个特征”“2 个批次、4 个样本、3 个特征”两种形状。分别取一列、保留二维列、交换样本和特征轴,并为每一步写出一个你能检查的编号值。最后记录一次故意的错误 reshape 和它真正应该修复的原因。
reshape 之前先写样本轴和特征轴
reshape() 只重新组织元素,不会增加或删除元素,因此目标形状的各维乘积必须等于原来的 size。但“元素数量正确”只是数学条件,不是业务条件。把 12 个数字从 (3, 4) 改成 (2, 6) 在语法上可行,若原来每行是一个学习者,新的每行可能把两个学习者拼在一起,结果就失去含义。每次 reshape 都要在注释中写出旧形状、新形状和轴的解释。
转置 .T 对二维数组交换行列,对高维数组会反转轴顺序;需要交换指定轴时使用 transpose()。flatten() 总是生成副本,ravel() 尽量返回视图,是否共享内存应视具体情况而定。为了把一列特征变成二维输入,可以使用 [:, np.newaxis] 或 reshape(-1, 1);-1 只允许在一个维度中出现,NumPy 会根据总元素数推断它。
在机器学习前处理里,最常见的形状是“样本数 × 特征数”。单个样本可能写成 (特征数,),一批样本写成 (批大小, 特征数)。这两个形状在矩阵乘法和模型接口中并不等价。建议在函数入口处断言二维、断言特征数,并在输出中记录样本数量;这样模型报错时可以回到数据变形这一步。
| 操作 | 改变什么 | 是否改变元素 | 适合先检查的事实 |
|---|---|---|---|
reshape | 轴的组织方式 | 不改变 | size 与目标乘积、轴语义 |
.T | 二维行列或高维轴顺序 | 不改变 | 后续矩阵乘法的方向 |
transpose | 按指定顺序重排轴 | 不改变 | 每个新轴对应哪个旧轴 |
flatten | 压成一维副本 | 不改变 | 是否允许丢失样本边界 |
expand_dims | 增加长度为 1 的轴 | 不改变 | 新增轴用于哪种广播 |
import numpy as np
raw = np.arange(12)
samples = raw.reshape(3, 4)
features = samples.T
one_feature = raw.reshape(-1, 1)
batch = one_feature.reshape(3, 4)
print("raw:", raw.shape)
print("samples:", samples.shape)
print("features after transpose:", features.shape)
print("one feature:", one_feature.shape)
print("batch:", batch.shape)
assert samples.size == raw.size
assert samples.shape == (3, 4)
assert one_feature.shape[1] == 1这个流程中 samples 的每一行仍然代表一个样本,转置后每一列才代表一个样本;如果下游函数期待“样本在行”,就不应直接把 features 传进去。形状图的作用是让这种差异可见。遇到结果维度突然多一条或少一条时,优先查看是否调用了一个会自动降维的索引。
创建一个 (5, 4) 的数组,表示 5 个样本和 4 个特征。分别得到第一列、第一列的二维版本、每个样本的均值和每个特征的均值;再用 reshape(-1, 1) 构造一个可广播的偏置。提交每一步的 shape,并说明哪一步如果误用 .T 会把样本轴和特征轴交换。
(5,),二维列是 (5, 1);样本均值为 (5,),特征均值为 (4,)。关键不是背出答案,而是能解释每个轴的含义,并在函数入口用断言保护样本数和特征数。reshape() 只改变组织方式,元素总数必须保持不变;转置会交换轴,二维数组常见写法是 .T。在进入机器学习模型前,经常需要把“样本数 × 特征数”的表格变成模型需要的形状。不要只靠报错猜形状,先打印每一步的 shape,并把“哪一维代表样本”写进注释。
形状变化时,先写出轴的语义,再选择 reshape、转置或聚合;图中每个方框都对应一个可检查的数组维度。
创建一个包含 12 个数的数组,分别变成 (3, 4) 和 (2, 2, 3)。访问第二个样本的第三个特征,计算每行平均值,再尝试把 10 个数 reshape 成 (3, 4),记录错误信息。最后说明为什么总元素数是形状约束。
(3, 4) 的第二行第三列是两个下标,(2, 2, 3) 则需要三个下标。reshape 10 个数到 (3, 4) 会报无法改变大小的错误,修复方法不是随意补数,而是重新确认输入样本数或目标形状。04 · PANDAS TABLES
Pandas:用 Series 和 DataFrame 管理表格
Pandas 在 NumPy 数组上增加了标签、列名和缺失值处理能力。Series 可以看作带索引的一列,DataFrame 可以看作由多列 Series 对齐而成的表。理解 Pandas 时不要把它当作“更方便的列表”;它有自己的索引语义、对齐规则和数据类型,写代码前仍然要看表的形状、列名和样本。
2.4.1 Series:一列数据和它的索引
Series 的值、索引和对齐是三个不同概念
Series 不只是“装一列值的列表”。它同时携带一组值和一组索引,许多运算会先按索引对齐,再进行计算。两个 Series 即使长度相同,如果索引标签不同,直接相加也可能产生更多行和缺失值;这不是 Pandas 随机出错,而是它在保护标签语义。初学时如果只关心位置,可以使用默认整数索引;一旦设置业务索引,就要把对齐规则写进说明。
索引标签可以重复,但重复索引会让“一个标签对应一个值”的直觉失效。s["python"] 可能返回一个值,也可能返回一个 Series。把文件名、日期或来源 ID 设为索引前,先检查唯一性;如果它不唯一,保留普通列通常更清楚。索引也不是永久不变的主键,重排、过滤和连接都可能改变它。
Series 的 dtype 会影响缺失值和运算。整数列一旦出现普通 NaN,可能被提升为浮点;字符串列应使用 Pandas 的字符串 dtype 以便统一处理;布尔列若包含缺失,需要明确是“未知”还是“否”。调用 astype() 前先看原始值,转换失败时使用 errors="coerce" 只是一种策略,不是默认答案。
| 对象 | 要看什么 | 示例 | 风险 |
|---|---|---|---|
| 值 | 顺序、范围、缺失 | s.head() | 把标签顺序当成业务顺序 |
| 索引 | 类型、唯一性、是否有序 | s.index | 重复标签导致返回形状不稳定 |
| 类型 | 数值、字符串、日期 | s.dtype | 字符串数字无法直接聚合 |
| 对齐 | 两个 Series 是否共享标签 | a.align(b) | 按位置相加却得到按标签结果 |
import pandas as pd
first = pd.Series([35, 50, 20], index=["python", "data", "model"])
second = pd.Series([2, 3, 4], index=["model", "python", "data"])
print("first:")
print(first)
print("dtype:", first.dtype)
print("index unique:", first.index.is_unique)
by_label = first + second
by_position = pd.Series(
first.to_numpy() + second.to_numpy(),
index=first.index,
)
print("aligned by label:")
print(by_label)
print("explicitly by position:")
print(by_position)
duplicate = pd.Series([1, 2], index=["x", "x"])
print("duplicate lookup:")
print(duplicate["x"])上例故意让第二个 Series 的标签顺序不同。按标签相加仍然能得到正确的主题对应关系;如果业务上真的要按位置相加,应先明确地取出数组并重新设置索引,而不是依赖两个对象当前碰巧的顺序。把索引信息丢掉是一种有意的选择,应该在代码中可见。
Series 的值和索引同时存在。默认索引从 0 开始,也可以使用日期、文件名或业务 ID 作为索引。索引适合表达“这一行是谁”,但不应把索引当成永远唯一的主键;重复索引会让选择和合并变得复杂。初学时先使用简单整数索引,明确主键后再设置业务索引。
import pandas as pd
minutes = pd.Series(
[35, 50, 20],
index=["python", "data", "python-review"],
name="minutes",
)
print(minutes)
print("data:", minutes.loc["data"])
print("first two:", minutes.iloc[:2])
print("mean:", minutes.mean())loc 按标签选取,iloc 按整数位置选取。这个区别和 NumPy 的位置索引不同,正是 Pandas 需要反复确认的地方。若索引标签是数字,loc[0] 和 iloc[0] 可能代表不同含义;写代码时不要省略对索引语义的说明。
2.4.2 DataFrame:列名、行记录与 dtype
索引重置、列顺序和可交付表
探索过程中索引可以帮助定位原始行,交付给其他程序时却常常需要稳定、连续的默认索引。过滤和排序后是否重置索引,要看下游是否把索引当成 ID。若索引只是旧位置,使用 reset_index(drop=True);若索引包含有意义的业务键,就把它恢复成明确的列并写进输出契约。不要把自动生成的整数索引误当成稳定主键。
列顺序也是接口的一部分。保存 CSV 前按契约排列列,避免字典顺序或中间新增列让输出发生无意变化。对数值列保持原始精度,展示格式在报告层处理;对字符串列统一空白和大小写,但保留原始列或审计表。DataFrame 适合表达表格,不能替你管理版本、许可和来源,这些元数据应放在伴随清单中。
| 动作 | 什么时候做 | 需检查 |
|---|---|---|
copy() | 建立独立清洗表 | 原表是否保持不变 |
reset_index(drop=True) | 旧索引不再有意义 | 业务 ID 是否已保留为列 |
| 显式列排序 | 保存或交付前 | 下游字段顺序和缺列 |
rename() | 统一字段语义 | 旧名映射和 README |
convert_dtypes() | 希望使用更合适的 Pandas 类型 | 缺失和下游兼容性 |
import pandas as pd
raw = pd.DataFrame({
"score": [82, 91, 76],
"topic": ["Python", "数据", "Python"],
"row_id": ["a", "b", "c"],
})
raw.index.name = "source_row"
delivery = (
raw.rename(columns={"row_id": "source_id"})
.loc[:, ["source_id", "topic", "score"]]
.sort_values(["topic", "source_id"])
.reset_index(drop=True)
.convert_dtypes()
)
print("raw index:", raw.index)
print("delivery index:", delivery.index)
print("columns:", delivery.columns.tolist())
print(delivery)
assert delivery.columns.tolist() == ["source_id", "topic", "score"]
assert delivery["source_id"].is_uniqueDataFrame 是一组按索引对齐的列
可以把 DataFrame 想成多列 Series 拼成的矩形,但每一列的 dtype 可以不同。创建 DataFrame 时,字典的键通常成为列名,列表长度应当一致;从记录列表创建时,缺少字段的记录会产生缺失值。不要把“每行长度一样”当成完整契约,还要检查列名、单位、唯一键和类别取值。
列名是程序接口的一部分。读取文件后可以先去除首尾空格、统一大小写,再和必需列集合比较。重命名或新增列时,尽量使用一次性、可追踪的表达式;如果同一个列名被反复覆盖,读者很难判断它是原始值、清洗值还是派生值。需要保留原始数据时,先复制 DataFrame,或把新表命名为 cleaned、summary。
查看 dtype 时不要只看打印结果。日期、类别和字符串的处理方法不同,数字列混入一个“未知”字符串后,比较和平均都会失败。对类型转换记录成功数量和失败数量;对类别列先列出唯一值,检查拼写、大小写和同义词。info()、describe() 和 value_counts() 是互补工具,不能用一张统计表替代三者。
| 检查 | 方法 | 要回答的问题 | 发现异常后的动作 |
|---|---|---|---|
| 形状 | df.shape | 行数和列数是否符合预期 | 回到输入和筛选步骤 |
| 列名 | df.columns.tolist() | 必需列是否齐全,有无空格 | 清理或更新契约 |
| 类型 | df.dtypes | 数值、日期、文本是否正确 | 转换并保存失败记录 |
| 缺失 | df.isna().sum() | 哪些列空得最多 | 按字段语义制定策略 |
| 类别 | df[col].value_counts() | 是否有拼写和大小写分裂 | 先规范化再统计 |
import pandas as pd
frame = pd.DataFrame([
{"topic": " Python ", "minutes": "35", "score": 82},
{"topic": "数据", "minutes": "50", "score": 91},
{"topic": "Python", "minutes": "unknown", "score": None},
])
raw = frame.copy()
frame["topic"] = frame["topic"].astype("string").str.strip()
frame["minutes"] = pd.to_numeric(frame["minutes"], errors="coerce")
frame["score"] = pd.to_numeric(frame["score"], errors="coerce")
print("shape:", frame.shape)
print("dtypes:\\n", frame.dtypes)
print("missing:\\n", frame.isna().sum())
print("topics:", frame["topic"].value_counts(dropna=False).to_dict())
print("conversion failures:", frame["minutes"].isna().sum())
assert raw.shape == frame.shape
assert {"topic", "minutes", "score"}.issubset(frame.columns)这里保留 raw 是为了在转换失败时能够回看原始文本。清洗表中的 NaN 不是“没有问题”,而是一个需要处理的状态。下一节会介绍如何用 loc 选择和赋值;现在先形成习惯:每次修改列之前,知道原始表是否需要保留。
frame.copy(),并在函数名或文档中说明返回的是新表。DataFrame 的每列可以有不同类型,但同一列最好保持语义一致。df.info() 适合快速查看非空数量和 dtype,df.describe() 适合观察数值列的范围,df.head() 和 df.sample() 适合抽查内容。不要只看前五行就相信数据质量;首行正常不代表中间没有空值、异常字符串或重复记录。
import pandas as pd
df = pd.DataFrame({
"topic": ["Python", "数据", "Python"],
"minutes": [35, 50, 20],
"completed": [True, True, False],
})
print("shape:", df.shape)
print("columns:", list(df.columns))
print("dtypes:\n", df.dtypes)
print("missing:\n", df.isna().sum())
print("sample:\n", df.head(2))检查表格时,建议把“结构检查”和“内容检查”分开。结构检查关注列是否存在、类型是否合理、行数是否大于零;内容检查关注范围、唯一性、类别集合和业务规则。df.isna().sum() 只能告诉你缺失数量,不能告诉你缺失意味着什么,后面清洗时要回到领域规则。
2.4.3 选择、过滤和赋值的明确写法
把选择、变换、排序分成可读的步骤
DataFrame 操作可以写成一条很长的链,但学习和排错时更适合拆成几步:先生成布尔掩码,再选择列,再复制独立子表,最后赋值或排序。每一步都可以查看 shape、索引和一两个样本。链式写法并不天然更专业;当列名、缺失策略和排序规则需要解释时,多写一个中间变量反而更清楚。
条件选择的优先级容易造成错误。比较表达式要加括号,字符串包含要明确是否区分大小写,日期筛选要先把列转换成日期类型。isin() 比一串 or 更容易维护,between() 可以表达闭区间,但必须确认端点是否包含。对空值使用 isna(),不要把空值当成普通字符串。
赋值之前要考虑数据所有权。直接修改原表适合记录“这就是清洗后的对象”,复制后修改适合探索和对照。新增列时尽量使用有意义的名称,不要反复覆盖原始指标;若必须覆盖,要在报告中说明旧值不再保留。排序和重置索引会改变位置语义,之后如果要与另一张表按位置拼接,先确认这真的是你想要的关系。
| 步骤 | 示例 | 输出证据 |
|---|---|---|
| 生成条件 | mask = df["score"].ge(80) | 命中行数、缺失处理 |
| 选择列 | df.loc[mask, columns] | 列名和顺序 |
| 明确所有权 | .copy() | 原表是否保持不变 |
| 派生字段 | subset["level"] = ... | 公式和单位 |
| 排序交付 | sort_values(...).reset_index() | 排序键和新索引 |
import pandas as pd
df = pd.DataFrame({
"topic": ["Python", "数据", "模型", "Python", "数据"],
"minutes": [35, 50, None, 20, 65],
"score": [82, 91, 88, 76, None],
"completed": [True, True, True, False, True],
})
score_ok = df["score"].ge(80) & df["score"].notna()
topic_ok = df["topic"].isin(["Python", "数据"])
mask = score_ok & topic_ok
selected = df.loc[
mask,
["topic", "minutes", "score", "completed"],
].copy()
selected["score_per_minute"] = (
selected["score"] / selected["minutes"]
)
selected = selected.loc[
selected["minutes"].gt(0)
].sort_values(
["score", "topic"],
ascending=[False, True],
).reset_index(drop=True)
print("selected shape:", selected.shape)
print(selected)
assert df["score"].isna().sum() == 1
assert selected["score_per_minute"].notna().all()这里先排除缺失分数和非正分钟,再计算派生指标,避免产生无意义的无穷或缺失。筛选逻辑和计算逻辑分开后,审查者可以分别检查“哪些行被选中”和“新列怎样得到”。这比在一行表达式中同时完成所有动作更适合学习和维护。
loc 或 copy()。隐藏警告并不能解决原表和副本的所有权问题。选择和赋值要把“标签”与“位置”说清楚
loc 面向标签和布尔条件,适合写“分数不低于 80 的记录”“把主题为 Python 的行标记为完成”;iloc 面向整数位置,适合写“前五行”“第 2 到第 4 列”。两者的切片边界和错误提示不同,混用时要先确认索引。若 DataFrame 的索引不是默认整数,loc[0] 不一定是第一行,而 iloc[0] 才是位置上的第一行。
条件表达式可以组合列、比较缺失和检查集合成员。字符串列常用 str.contains()、isin(),日期列可以比较时间范围;对缺失值要用 isna() 或 notna(),不能写成 value == None。赋值时优先使用一条完整的 loc[mask, "column"] = value,避免先筛选出一个可能是副本的临时对象。
链式索引的问题不仅是警告是否出现,更是你无法确定修改是否真正落在原表上。把筛选结果另存为 subset = df.loc[mask].copy(),表示你有意创建一个独立表;如果要修改原表,就直接在原表上用 loc。排序、重置索引和选择列之后,都要重新查看 shape 和索引,确保后面的代码没有依赖旧位置。
| 需求 | 推荐写法 | 返回的语义 | 注意 |
|---|---|---|---|
| 按条件选行 | df.loc[df["score"] >= 80] | 满足条件的记录 | 条件括号和缺失值 |
| 按标签选列 | df.loc[:, ["topic", "score"]] | 指定列的新视图或副本 | 列名拼写和顺序 |
| 按位置选行列 | df.iloc[0:3, 1:3] | 位置范围 | 索引标签不参与判断 |
| 条件赋值 | df.loc[mask, "level"] = "达标" | 有意修改原表 | mask 与行索引对齐 |
| 安全独立筛选 | df.loc[mask].copy() | 可独立修改的表 | 复制的成本和所有权 |
import pandas as pd
df = pd.DataFrame({
"topic": ["Python", "数据", "模型", "Python"],
"minutes": [35, 50, 15, 20],
"score": [82, 91, 58, 76],
})
high = df.loc[df["score"] >= 80, ["topic", "score"]]
first_two = df.iloc[0:2, :]
mask = (df["minutes"] > 20) & df["topic"].isin(["Python", "数据"])
df.loc[mask, "level"] = "重点"
df.loc[~mask, "level"] = "普通"
subset = df.loc[df["score"] < 80].copy()
subset["score"] = subset["score"] + 5
print("high:\\n", high)
print("first two:\\n", first_two)
print("updated:\\n", df)
print("independent subset:\\n", subset)注意最后的 subset:它先明确复制,再在副本上加分,所以不会悄悄改变原表。这个例子中的加分只是演示赋值,不代表真实评分规则;在实际项目中,所有改变原始指标的操作都要写进数据契约和报告。
创建一个至少八行的 DataFrame,包含主题、日期、分钟数、分数和完成状态。用 loc 筛选已完成且分数不低于 80 的记录,用 iloc 取前两行,再新增一个等级列。故意把索引改成字符串后重复运行,写下 loc 和 iloc 的区别,并验证原表与独立子表是否互相影响。
iloc;需要修改原表时用 df.loc[mask, column] = value,需要独立实验时用 .copy()。提交时同时展示索引、shape 和一条修改前后的记录。选列可以使用 df["topic"] 或 df[["topic", "minutes"]];按条件过滤时,先构造布尔掩码,再把它放入 DataFrame。修改筛选结果时,使用 .loc[条件, 列名] = 值 能减少链式赋值歧义。计算新列时,先写出单位和公式,再执行赋值,并立刻抽查几行。
loc、iloc 和布尔掩码的任务分工
| 写法 | 依据 | 适合场景 | 易错点 |
|---|---|---|---|
df["score"] | 单列名称 | 取一列继续计算 | 结果是 Series,不是二维表 |
df[["a", "b"]] | 列名列表 | 保留多列并维持表形状 | 少一层方括号会改变返回类型 |
df.loc[rows, cols] | 标签 | 按列名和条件更新 | 条件组合要加括号 |
df.iloc[rows, cols] | 整数位置 | 按位置切片或快速抽查 | 位置从 0 开始,终点通常不含 |
df[mask] | 布尔数组 | 筛选满足条件的行 | 掩码长度和索引必须对齐 |
import pandas as pd
df = pd.DataFrame({
"topic": ["Python", "数据", "Python"],
"score": [82, 91, 76],
})
high = df.loc[df["score"] >= 80, ["topic", "score"]]
df.loc[df["score"] < 80, "level"] = "需要复习"
df.loc[df["score"] >= 80, "level"] = "达标"
print(high)
print(df)创建一个至少五行的 DataFrame,包含主题、分钟数、分数和完成状态。筛选分数不低于 80 且已完成的记录,新增一个表示“每分钟得分”的列,并检查是否出现除以 0。不要直接修改筛选结果的副本,使用 loc 在原表上完成有意的赋值。
& 连接;新增列前先判断分钟数是否为 0 或缺失。评分重点是列名、单位和赋值位置清楚,避免链式索引导致“看似修改、原表未变”的情况。05 · INGEST & CONTRACT
读取数据:先验证来源,再交给分析
读取 CSV 或 JSON 只是一行函数调用,但可靠的数据导入需要更多信息:文件编码、分隔符、列名、日期格式、缺失标记、来源路径和版本。导入阶段如果把问题隐藏起来,后面的统计图再漂亮也可能建立在错误数据上。把读取、验证和转换拆开,才能知道问题发生在哪一层。
2.5.1 CSV、JSON 和路径
读取文件时,格式只是第一层问题
CSV 看起来简单,却没有统一的类型、编码和缺失标准。同一列可能出现空字符串、NA、null、-,分隔符也可能是逗号、制表符或分号。读取函数的参数不是越多越专业,而是要和文件契约相匹配。先用文本编辑器查看文件头部和几行原始内容,再决定 sep、encoding、na_values 等参数,不要看到解析失败就盲目替换分隔符。
JSON 的形状更灵活,可以是一个对象、一组对象、嵌套列表或分页响应。读入后先判断顶层结构,再把记录列表交给 DataFrame。不要把嵌套 JSON 直接当成平面表格;需要展开时,先写清楚每个嵌套对象如何对应一行。路径也属于输入契约的一部分:使用 Path 拼接路径,避免手写反斜杠和依赖当前工作目录的“碰巧可用”相对路径。
读取阶段应尽量少做业务清洗。可以指定列名、解析日期或选择列以节省内存,但不要在读取函数里悄悄删除重复行、填补缺失或裁剪异常值。原始输入和清洗结果分开保存,失败时才有机会回到原文件复核。对大型 CSV 可以分块读取,先学习完整读取的语义,再根据内存和耗时选择 chunksize。
| 事实 | 检查方式 | 影响 | 不要直接假设 |
|---|---|---|---|
| 路径 | Path.exists()、Path.resolve() | 能否找到同一份文件 | 当前目录永远不变 |
| 编码 | 文件说明或小样本检测 | 中文和特殊字符能否解码 | 所有 CSV 都是 UTF-8 |
| 分隔符 | 查看首行、测试解析 | 列是否被正确拆开 | 逗号一定是分隔符 |
| 缺失标记 | 列出空字符串和特殊词 | 是否读成 NaN | 字符 “0” 代表缺失 |
| JSON 顶层 | type(json.load(...)) | 记录列表如何定位 | 每个 JSON 都是一组行 |
import json
from pathlib import Path
import pandas as pd
root = Path("data")
csv_path = root / "raw" / "study.csv"
json_path = root / "raw" / "study.json"
if not csv_path.exists():
raise FileNotFoundError(f"找不到输入文件: {csv_path.resolve()}")
frame = pd.read_csv(
csv_path,
encoding="utf-8",
na_values=["", "NA", "null"],
)
print("csv shape:", frame.shape)
print("csv columns:", frame.columns.tolist())
if json_path.exists():
payload = json.loads(json_path.read_text(encoding="utf-8"))
records = payload if isinstance(payload, list) else payload.get("records", [])
json_frame = pd.DataFrame.from_records(records)
print("json shape:", json_frame.shape)代码把“文件不存在”作为明确错误,而不是返回一个空表继续运行。空表和缺文件的意义不同:空表可能是一次合法的零记录输入,缺文件通常表示路径或准备步骤错误。把这两种情况区分开,后续报告才不会把“没有读到数据”误报为“数据量为零”。
CSV 适合矩形表格,JSON 适合嵌套记录;选择格式时要看下游是否需要保留层级。路径使用 Path,读取时明确编码;CSV 的分隔符、引号和缺失标记也应记录。导入成功后先打印文件名、行数、列名和前几行,不要立即计算复杂指标。
from pathlib import Path
import json
import pandas as pd
root = Path("data")
csv_path = root / "study-log.csv"
json_path = root / "study-log.json"
table = pd.read_csv(csv_path, encoding="utf-8")
records = json.loads(json_path.read_text(encoding="utf-8"))
print("CSV:", csv_path, table.shape, list(table.columns))
print("JSON records:", len(records))这里假定 JSON 顶层是列表;如果顶层是包含 items 的字典,应先查看结构再决定 pd.DataFrame(records["items"])。不要用 try: everything 把格式错误变成一张空表;空表和读入失败是两个不同状态,报告中应分别说明。
2.5.2 日期、数字和缺失标记的显式转换
转换不是擦掉异常,而是把不确定性标记出来
从文本读入的日期和数字都只是字符串,直到你明确调用转换函数。数字可能带逗号、百分号、货币符号或前后空格;日期可能有多种格式、时区和无效日期。转换前先保留原始列,转换后统计成功和失败数量,再决定是否拒绝、修复或保留为缺失。直接使用强制转换并丢弃错误,会让数据质量问题失去证据。
pd.to_numeric(errors="coerce") 会把无法转换的值变成缺失,适合先发现问题;如果项目要求所有值都合法,可以在转换后检查缺失并抛出错误。日期使用 pd.to_datetime() 时要考虑时区和解析格式,不能把“2026-01-02”与“01/02/2026”在没有约定的情况下混在一起。布尔列也要明确 yes、Y、1 是否等价。
缺失值的含义取决于字段。没有填写分数可能表示尚未评分;没有填写完成状态可能表示不知道;分钟为 0 可能表示打开资料但没有学习。把所有缺失都填 0 会改变统计分布,把所有缺失都删除又可能丢掉重要的未完成记录。应该按列写策略,并在输出中报告处理了多少行。
| 原始值 | 转换目标 | 先做的操作 | 失败时的证据 |
|---|---|---|---|
" 35 " | 数值 35 | 去空白再转换 | 保留原文本和行号 |
"1,200" | 数值 1200 | 约定千分位规则 | 记录被替换的字符 |
"82%" | 比例或 82 分 | 明确量纲和除以 100 规则 | 不要让单位悄悄改变 |
"2026-08-27" | 日期时间 | 指定格式和时区 | 记录无法解析的文本 |
"" / "NA" | 缺失 | 统一缺失词 | 按字段语义选择填补或拒绝 |
import pandas as pd
raw = pd.DataFrame({
"minutes_text": [" 35 ", "1,200", "unknown", None],
"score_text": ["82", "91", "82%", "—"],
"recorded_text": ["2026-08-01", "2026-08-02", "bad-date", None],
"completed_text": ["yes", "no", "Y", "unknown"],
})
data = raw.copy()
data["minutes"] = pd.to_numeric(
data["minutes_text"].str.replace(",", "", regex=False),
errors="coerce",
)
data["score"] = pd.to_numeric(
data["score_text"].str.rstrip("%"),
errors="coerce",
)
data["recorded_at"] = pd.to_datetime(
data["recorded_text"],
format="%Y-%m-%d",
errors="coerce",
)
completed_map = {"yes": True, "y": True, "no": False, "n": False}
data["completed"] = data["completed_text"].str.lower().map(completed_map)
for column in ["minutes", "score", "recorded_at", "completed"]:
print(column, "failed:", int(data[column].isna().sum()))
print(data)注意这里把 "82%" 当成 82,只是演示一种规则;如果原字段表示比例,就应该转换成 0.82 并改名为 score_ratio。名称、单位和转换代码必须一致,否则后面的图表会给出一个数值正确但含义错误的结果。
外部文件中的所有字段本质上都是文本或某种序列化形式。日期可能写成 2026/08/27、2026-08-27 或带时区的时间;数字可能包含千位分隔符、百分号或空字符串。使用 pd.to_datetime()、pd.to_numeric() 等转换函数时,先选择错误策略,再检查转换失败的数量。
import pandas as pd
df = pd.DataFrame({
"date": ["2026-08-01", "2026-08-02", "未知"],
"minutes": ["35", " 50 ", ""],
"score": ["82%", "91%", "n/a"],
})
df["date"] = pd.to_datetime(df["date"], errors="coerce")
df["minutes"] = pd.to_numeric(df["minutes"], errors="coerce")
df["score"] = (
df["score"].str.rstrip("%")
.pipe(pd.to_numeric, errors="coerce")
)
print(df)
print("conversion failures:\n", df.isna().sum())errors="coerce" 会把无法转换的值变成缺失,方便继续统计但也可能掩盖原始问题。因此转换前可以保留原列,或在报告中保存失败行号和原文本。百分比去掉符号后,数值的单位是“百分点”还是“比例”要写清;展示为 82% 和计算使用 0.82 是两种不同的数据契约。
2.5.3 用数据契约保护后续步骤
大文件也要保持同一条证据链
当文件从几十行增长到几百万行,不能简单地把所有内容读进内存再观察。先读取少量行检查编码、列名和类型,再决定是否使用 usecols、dtype 或 chunksize。分块处理时,每一块都要执行相同的转换和契约检查,最后再合并计数和聚合结果;不要只验证第一块就假设后面的数据一样。
JSON Lines(每行一个 JSON 对象)适合追加和流式读取,但每行仍可能缺字段或类型不一致。读取时保留行号,解析失败的行写入拒绝文件,不要因为一行坏数据而让整份报告默默缺少样本。无论 CSV、JSON 还是 JSONL,都应在结果中记录格式、编码、输入文件和被拒绝行数。
契约的作用不是把所有输入都挡在门外,而是让例外有去处。对可选字段可以警告并继续,对关键字段必须停止;对一批数据可以输出通过表和拒绝表,对在线单条数据则可以立即返回错误。选择策略时考虑下游是否允许部分成功,并把这个决定写进 README。
| 规模或格式 | 读取方式 | 需要保留 | 常见风险 |
|---|---|---|---|
| 小型 CSV | 完整读取后检查 | 路径、编码、列名、行数 | 把空文件当缺文件 |
| 大型 CSV | 分块读取并逐块验证 | 块编号、累计行数、错误数 | 只检查第一块 |
| 嵌套 JSON | 先判断顶层结构再展开 | 记录路径和展开规则 | 一对象被误当一行 |
| JSONL | 逐行解析、保留行号 | 坏行和解析错误 | 失败行被静默跳过 |
from pathlib import Path
import pandas as pd
path = Path("data/raw/study.csv")
required = {"topic", "minutes", "score"}
total_rows = 0
rejected_rows = 0
partial_summaries = []
for chunk_no, chunk in enumerate(
pd.read_csv(path, chunksize=1000, encoding="utf-8"),
start=1,
):
total_rows += len(chunk)
missing = required - set(chunk.columns)
if missing:
raise ValueError(f"第 {chunk_no} 块缺少列: {sorted(missing)}")
chunk["minutes"] = pd.to_numeric(
chunk["minutes"], errors="coerce"
)
chunk["score"] = pd.to_numeric(
chunk["score"], errors="coerce"
)
invalid = (
chunk["minutes"].isna()
| chunk["minutes"].lt(0)
| chunk["score"].isna()
| chunk["score"].lt(0)
| chunk["score"].gt(100)
)
rejected_rows += int(invalid.sum())
valid = chunk.loc[~invalid]
partial_summaries.append(
valid.groupby("topic")
.agg(records=("score", "size"), score_sum=("score", "sum"))
)
print("input rows:", total_rows)
print("rejected rows:", rejected_rows)
print("chunks:", len(partial_summaries))分块聚合的最终合并需要再次按主题相加,并用总和除以总有效数,不能简单平均每块的平均值,因为每块样本量可能不同。这个例子先展示契约检查的框架,真正交付时还要测试空文件、最后一块不足整块和某块全部被拒绝的情况。
契约要同时约束列、行和关系
一个有用的数据契约不止是一张列名清单。列层面要说明名称、类型、单位、允许范围和是否可空;行层面要说明一行代表什么、哪些字段组合必须唯一、是否允许零行;关系层面要说明与其他表如何连接、连接后行数应怎样变化。契约越具体,错误越容易在源头被发现,而不是等到图表出现异常才猜原因。
把检查分为“必须停止”和“可以警告”两类。缺少必需列、文件无法解码、主键为空、分数超出量表通常应停止;可选备注缺失、类别数量变多、少量重复可能先警告并继续。警告也要有阈值、计数和行动,不能只打印一句“数据有问题”。
验证函数最好返回结构化结果,例如通过的表、拒绝的行和检查清单。这样主程序可以把结果写入 JSON,测试也能逐项断言。错误消息应包含列名、行号、原始值和规则;“格式错误”无法帮助使用者修复,“第 7 行 score='82%',期望 0 到 100 的数”就具有行动性。
| 层次 | 例子 | 检查方法 | 失败后的处理 |
|---|---|---|---|
| 列 | 必需列、dtype、单位 | issubset、类型检查 | 停止并指出缺失或冲突 |
| 行 | 非负分钟、分数范围 | 布尔掩码和行号 | 拒绝、修复或人工确认 |
| 键 | source_id 唯一 | is_unique、重复计数 | 阻止错误合并 |
| 关系 | 主题表一对多事件表 | 连接验证和行数对账 | 使用 validate 或停止 |
| 输出 | JSON 字段和图表路径 | 回读、文件存在性 | 不报告伪成功 |
import pandas as pd
def validate_table(frame, required, unique_key=None):
problems = []
missing_columns = sorted(set(required) - set(frame.columns))
if missing_columns:
problems.append(f"缺少列: {missing_columns}")
return frame.iloc[0:0].copy(), frame.copy(), problems
data = frame.copy()
data["minutes"] = pd.to_numeric(data["minutes"], errors="coerce")
data["score"] = pd.to_numeric(data["score"], errors="coerce")
invalid = (
data["topic"].isna()
| data["topic"].astype("string").str.strip().eq("")
| data["minutes"].isna()
| data["minutes"].lt(0)
| data["score"].isna()
| data["score"].lt(0)
| data["score"].gt(100)
)
if unique_key is not None:
duplicated = data[unique_key].duplicated(keep=False)
invalid = invalid | duplicated
clean = data.loc[~invalid].copy()
rejected = data.loc[invalid].copy()
return clean, rejected, problems
sample = pd.DataFrame({
"topic": ["Python", "数据", ""],
"minutes": [35, -2, 20],
"score": [82, 91, 105],
})
cleaned, rejected, problems = validate_table(
sample,
required=["topic", "minutes", "score"],
)
print("cleaned:", len(cleaned))
print("rejected:", len(rejected))
print("problems:", problems)示例中的函数把缺列作为整体问题,把行级违规放入拒绝表;两者不要混为一谈。真实项目还可以为每条拒绝记录增加 reject_reason,或返回一张检查报告,记录每个规则命中的数量。无论采用哪种表示,必须能回答“输入有多少行、保留多少行、拒绝多少行、为什么拒绝”。
为一份包含 source_id、topic、minutes、score 的表写出契约。准备一个正常样本和至少四个违规样本,分别覆盖缺列、空主题、负分钟、超范围分数和重复键。函数返回通过表、拒绝表和问题列表;提交时展示每条违规样本被哪一条规则捕获。
数据契约不是复杂的数据库规范,而是一份可以被程序检查的承诺:必需列存在、行数在合理范围、主键不为空、数值位于可解释区间、类别值没有意外拼写。把这些检查写成函数,导入阶段失败就停止,不要等到绘图时才发现列名拼错。
一个可复用的导入检查函数
def validate_table(frame, required_columns):
missing = [
column for column in required_columns
if column not in frame.columns
]
if missing:
raise ValueError(f"缺少字段:{missing}")
if frame.empty:
raise ValueError("表格为空,不能继续分析")
if frame["minutes"].isna().any():
raise ValueError("minutes 含有无法转换的值")
if (frame["minutes"] < 0).any():
raise ValueError("minutes 不能为负数")
return frame
required = ["topic", "minutes", "score"]
checked = validate_table(df, required)
print("validated rows:", len(checked))验证函数只负责判断并返回表格,不负责读取文件、打印长报告或修改输入。这样在测试时可以直接构造一个小 DataFrame,分别检查缺列、空表、负数和正常输入。错误消息包含字段名和下一步,调用者才知道应回到原始文件还是修改规则。
为一份学习记录表写出至少六项检查:必需列、编码、行数、日期范围、分钟数范围、主键重复和缺失比例。把检查分为“必须停止”和“可以警告”两类,准备一个正常表和三个故意违规的小表。
06 · CLEANING
数据清洗:每一次修复都要留下理由
清洗不是把所有“不好看”的值删除,而是在明确目标和损失后,把原始数据转换成可分析的数据。缺失、重复、类型错误和异常值的处理方式都依赖任务:用于展示的表可能需要保留原文,用于模型训练的特征可能需要统一尺度。保留原始文件、清洗规则和变化数量,是让结果可追溯的基本要求。
2.6.1 缺失值:未知、不适用和零不是一回事
先给缺失值命名,再决定怎样处理
缺失不是一种单一状态。一个人没有填写分数,可能代表还未评分;某个字段对这类记录不适用;导入时解析失败;或者原始系统根本没有收集这项信息。它们在业务上不同,却常常都被显示成空白。处理缺失前,先为字段写出状态含义,至少区分“未知”“不适用”“没有发生”和“转换失败”。
删除缺失行很方便,但会改变样本构成。填充 0 会把未知变成一个具体的数;用均值填充会压低方差;向前填充适合某些按时间持续的状态,却不适合独立学习记录。没有一种方法对所有列都正确。更稳妥的做法是保留原始列、增加缺失指示列,并在报告中统计每种处理影响了多少行。
缺失还会影响分组和图表。Pandas 的聚合通常会忽略缺失数值,导致不同主题的有效样本数不同;分类列的缺失可能在分组时被排除,除非明确设置保留策略。看到平均值时同时看 count;看到趋势断点时确认是否因为某几天没有记录。数据少时,逐行查看比自动填补更有价值。
| 缺失含义 | 例子 | 可选动作 | 不能直接做什么 |
|---|---|---|---|
| 未知 | 尚未填写分数 | 保留缺失、单独统计 | 直接当 0 分 |
| 不适用 | 某类资料没有截止日期 | 使用明确类别或标记 | 与真正日期混合比较 |
| 没有发生 | 未完成任务的耗时 | 按业务规则设为 0 或单独状态 | 悄悄用均值替代 |
| 转换失败 | "unknown" 无法转数字 | 保留原文和拒绝原因 | 吞掉错误继续聚合 |
| 系统漏采 | 某一周没有上传记录 | 区分零记录与零值 | 画出不存在的连续趋势 |
import pandas as pd
frame = pd.DataFrame({
"topic": ["Python", "数据", "模型", "Python", "数据"],
"minutes": [35, None, 50, 20, 0],
"score": [82, 91, None, None, 76],
"completed": [True, True, False, None, True],
})
missing = frame.isna()
report = pd.DataFrame({
"missing_count": missing.sum(),
"missing_rate": missing.mean(),
})
print(report)
frame["score_missing"] = frame["score"].isna()
frame["minutes_missing"] = frame["minutes"].isna()
summary = (
frame.groupby("topic", dropna=False)
.agg(
records=("topic", "size"),
valid_scores=("score", "count"),
average_score=("score", "mean"),
)
.reset_index()
)
print(summary)示例中的 valid_scores 和 records 同时出现,是为了让读者看到平均分背后的有效样本量。真正项目中还可以按主题输出缺失率,设置“超过 30% 时警告”的阈值,但阈值应根据问题和样本量确定,不能把任意数字当成普遍真理。
空字符串、NaN、None 和特殊文本都可能表示缺失,但它们的业务含义不同。学习分钟为空,可能是没有记录;分数为空,可能是尚未测验;次数为 0,则表示确实发生了零次。用 fillna(0) 前,先问这个字段的零是否有实际含义。填补均值也不是中立操作,它会改变分布和方差。
import pandas as pd
df = pd.DataFrame({
"topic": ["Python", "数据", "模型", "Python"],
"minutes": [35, None, 0, 20],
"score": [82, 91, None, 76],
})
missing_rate = df.isna().mean().sort_values(ascending=False)
print("缺失率:\n", missing_rate)
known_score = df.dropna(subset=["score"])
df["minutes"] = df["minutes"].fillna(0)
print("保留有分数的记录:\n", known_score)上例只是演示几种操作,不代表所有项目都应该如此填补。报告中应同时记录原始缺失数量、处理规则和处理后数量。若用训练集的均值填补训练集、验证集和测试集,均值必须只从训练集计算,否则会发生数据泄漏,让评估结果过于乐观。
2.6.2 重复记录:先定义“重复什么”
重复行、重复标题和重复事件不是同一个问题
完全相同的两行通常可以称为重复行,但业务上更常见的是关键字段相同、其他字段不同的重复。例如同一个 source_id 和日期出现两次,可能是重复导入,也可能是同一资料被学习了两次。去重前先定义粒度和主键:是整行去重、按资料去重,还是按“来源 + 时间 + 事件类型”去重?不同定义会留下不同数量的记录。
duplicated() 可以根据全部列或指定列生成布尔掩码,drop_duplicates() 可以选择保留第一条、最后一条或全部重复记录。保留哪一条也需要规则:较新的更新时间、状态更完整的一条,还是让重复记录进入人工审查?默认保留第一条只是 API 参数,不是业务判断。
去重会改变计数和平均值,所以应该在清洗报告里记录去重前后行数、重复键数量和保留策略。对事件流或学习记录,简单删除可能把真实的多次行为误删;对维度表,重复键会让合并后的行数放大。先用分组查看重复样本,再决定动作,避免一行代码把问题隐藏起来。
| 层级 | 判断依据 | 可能含义 | 建议动作 |
|---|---|---|---|
| 完全重复 | 全部列都相同 | 重复导出或重复提交 | 保留一条并记录数量 |
| 键重复 | 主键列相同 | 维度表违反唯一性 | 停止合并或人工确认 |
| 标题重复 | 标题相同、来源不同 | 同名资料或不同版本 | 保留来源字段,不按标题删除 |
| 事件重复 | 来源、时间、事件相同 | 重复上报 | 按事件契约判断 |
| 近似重复 | 文本相似但不完全相同 | 版本更新或格式差异 | 本章暂不自动删除 |
import pandas as pd
events = pd.DataFrame([
{"source_id": "a", "day": "2026-08-01", "minutes": 30, "updated_at": 1},
{"source_id": "a", "day": "2026-08-01", "minutes": 30, "updated_at": 1},
{"source_id": "b", "day": "2026-08-01", "minutes": 20, "updated_at": 1},
{"source_id": "b", "day": "2026-08-01", "minutes": 35, "updated_at": 2},
])
all_columns = events.duplicated(keep=False)
key_columns = events.duplicated(
subset=["source_id", "day"],
keep=False,
)
print("exact duplicate rows:", int(all_columns.sum()))
print("duplicate keys:", int(key_columns.sum()))
print(events.loc[key_columns].sort_values(["source_id", "updated_at"]))
ordered = events.sort_values("updated_at")
latest = ordered.drop_duplicates(
subset=["source_id", "day"],
keep="last",
)
print("latest per key:")
print(latest)这个例子把“完全重复”和“键重复”分开统计。b 的两条记录不是完全相同,却共享业务键;保留更新时间较新的版本只是一个示例策略,实际项目可能需要合并分钟数或保留两次事件。写清楚策略比追求一个唯一的“正确去重函数”更重要。
整行完全相同是最简单的重复;同一来源、同一天、同一主题可能是业务上的重复键;同一个标题在不同文件中出现,则可能是重复资料,也可能是有意的不同版本。drop_duplicates() 需要明确 subset、保留哪一行以及是否先排序。不要看到行数减少就认为清洗成功。
重复检查要同时保留证据
| 层次 | 示例规则 | 可执行检查 | 需要记录的决定 |
|---|---|---|---|
| 完全重复 | 所有字段都相同 | df.duplicated() | 保留第一条还是最后一条 |
| 业务键重复 | source_id + date | duplicated(subset=[...]) | 重复是错误还是多次事件 |
| 规范化重复 | 标题去空格并忽略大小写后相同 | 先生成规范化键再检查 | 是否会合并不同版本内容 |
| 近似重复 | 拼写或标点只有小差异 | 需要相似度工具和人工复核 | 阈值、误合并风险和审核记录 |
import pandas as pd
df = pd.DataFrame({
"source_id": ["a", "a", "b", "c"],
"title": [" Python ", "Python", "模型", "模型"],
"score": [80, 80, 92, 92],
})
df["title_key"] = df["title"].str.strip().str.casefold()
duplicates = df[df.duplicated(
subset=["source_id", "title_key"],
keep=False,
)]
print(duplicates.sort_values(["source_id", "title_key"]))2.6.3 类型转换、范围检查与异常值
清洗顺序会影响最终结论
一条稳妥的清洗顺序通常是:先保存原始副本,统一列名和空白;再转换类型并记录失败;然后处理缺失和重复;接着执行范围和关系检查;最后生成派生列和汇总。顺序不是固定的教条,但每一步都要说明依赖关系。例如,只有把分数转成数值后才能检查范围,只有统一主题文本后才能可靠地去重和分组。
清洗过程最好是可审计的。为每条拒绝记录保留原始索引、稳定 ID、规则名称和原始值;为修复操作保留前后值,例如把带千分位的文本转换成数值时记录转换规则。对于自动修复,输出“修复数量”和“无法修复数量”,不要只输出清洗后的行数。下游若需要原始证据,可以用 ID 回到 raw 文件,而不是依赖清洗表中的猜测。
清洗也应尽可能可重复和可回滚。不要在原始文件上直接覆盖,不要把临时手工修改混入脚本之外;每次运行使用同样的规则得到同样的结果,并把规则版本写入报告。若规则改变,保留旧输出或标明版本,避免把不同口径的结果放在同一目录里却无法区分。
| 步骤 | 依赖 | 产物 | 审计字段 |
|---|---|---|---|
| 列名规范化 | 读取成功 | 统一列名的表 | 旧列名到新列名映射 |
| 类型转换 | 列名稳定 | 数值、日期、字符串列 | 失败原值、转换数量 |
| 缺失处理 | 类型可判断 | 缺失标记或策略结果 | 字段、原因、行数 |
| 重复检查 | 主键已定义 | 保留表和审查表 | 键、保留规则 |
| 范围关系 | 类型和单位稳定 | 通过表和拒绝表 | 规则、原值、行号 |
import pandas as pd
raw = pd.DataFrame({
"row_id": ["a", "b", "c", "d"],
"topic": [" Python ", "数据", "", "Python"],
"minutes_text": ["35", "-2", "20", "unknown"],
"score_text": ["82", "91", "105", "76"],
})
data = raw.copy()
audit = pd.DataFrame({
"row_id": data["row_id"],
"original_topic": data["topic"],
"original_minutes": data["minutes_text"],
"original_score": data["score_text"],
})
data["topic"] = data["topic"].astype("string").str.strip()
data["minutes"] = pd.to_numeric(data["minutes_text"], errors="coerce")
data["score"] = pd.to_numeric(data["score_text"], errors="coerce")
reason = pd.Series("", index=data.index, dtype="string")
checks = [
(data["topic"].eq(""), "empty_topic"),
(data["minutes"].isna(), "minutes_not_numeric"),
(data["minutes"].lt(0), "minutes_negative"),
(data["score"].isna(), "score_not_numeric"),
(data["score"].gt(100), "score_out_of_range"),
]
for mask, label in checks:
reason = reason.mask(mask & reason.eq(""), label)
data["reject_reason"] = reason
audit["reject_reason"] = reason
clean = data.loc[reason.eq("")].copy()
rejected = audit.loc[reason.ne("")].copy()
print("clean:", len(clean))
print("rejected:")
print(rejected)
assert len(clean) + len(rejected) == len(raw)审计表中的原始字段让拒绝原因可以被复核,清洗表中的派生字段则服务于后续计算。真实项目中还可以把规则列表和脚本版本写入 JSON;先掌握这种“原始—处理—审计”的分离,再考虑更复杂的数据质量框架。
用箭头画出从 raw 到 clean、rejected、audit 和 summary 的关系,并为每一步写一个输入、一个输出和一个不变量。实现后故意修改一条规则,比较两次输出的行数和拒绝原因,说明为什么需要版本或运行清单。
异常值要有规则、证据和去向
类型正确不代表值正确。分钟列可以是数值,却出现负数;分数可以是浮点,却出现 120;日期可以成功解析,却落在项目开始之前。范围检查要从业务契约来,而不是从样本的最小值和最大值倒推。样本本身可能已经被错误值污染,使用它自动生成边界会把异常合法化。
异常值的处理至少有四种去向:拒绝并保留原因、修复并记录原值、截断到允许范围、保留但在统计中单独标记。选择取决于错误来源和问题目标。测试数据里可以故意制造异常,观察每条规则是否命中;真实数据里不要因为“看起来离群”就自动删除,先检查单位、重复导入和测量过程。
统计上的离群检测,例如四分位距规则,只能描述分布中的极端位置,不能证明它是错误。一个高分可能是真实进步,一个很长的学习时长可能是跨夜计时。将统计提示和业务拒绝分开:前者提醒人工查看,后者依据契约阻止下游计算。报告中同时写出阈值和被影响的行。
| 发现 | 可能原因 | 动作 | 必须保留的证据 |
|---|---|---|---|
| 负分钟 | 符号错误、列错位 | 拒绝并回看原始行 | 原值、行号、规则 |
| 分数超过 100 | 百分比未转换或量表不同 | 确认单位后修复或拒绝 | 量表、转换公式 |
| 极端长时长 | 计时未停止或真实长任务 | 标记并人工复核 | 上下文和确认结果 |
| 日期在范围外 | 时区、格式或导入错误 | 解析后检查项目边界 | 原文本和时区 |
| 类别拼写分裂 | 空格、大小写、同义词 | 规范化并记录映射 | 原值到标准值映射表 |
import pandas as pd
data = pd.DataFrame({
"topic": ["Python", "数据", "模型", "Python"],
"minutes": [35, -4, 900, 20],
"score": [82, 105, 88, "unknown"],
})
data["minutes_num"] = pd.to_numeric(data["minutes"], errors="coerce")
data["score_num"] = pd.to_numeric(data["score"], errors="coerce")
reasons = pd.Series("", index=data.index, dtype="string")
rules = [
(data["minutes_num"].isna(), "minutes_not_numeric"),
(data["minutes_num"].lt(0), "minutes_negative"),
(data["minutes_num"].gt(24 * 60), "minutes_too_large"),
(data["score_num"].isna(), "score_not_numeric"),
(data["score_num"].lt(0) | data["score_num"].gt(100), "score_out_of_range"),
]
for mask, reason in rules:
reasons = reasons.mask(mask & reasons.eq(""), reason)
data["reject_reason"] = reasons
rejected = data.loc[data["reject_reason"].ne("")]
clean = data.loc[data["reject_reason"].eq("")]
print("clean rows:", len(clean))
print("rejected rows:")
print(rejected[["topic", "reject_reason"]])循环在这里不是为了把所有逻辑塞在一起,而是让规则表逐条写出。若一行同时违反多条规则,示例保留第一条原因;你也可以把原因列表连接起来,但要在报告中说明计数方式。无论怎样,最终都应满足输入行数等于保留行数与拒绝行数之和。
准备至少 10 行数据,故意加入负数、超范围值、不可转换字符串、空主题、完全重复行和键重复行。先输出质量报告,再分别生成清洗表、拒绝表和重复审查表。对每条规则写明是停止、警告、修复还是保留;不要只输出“删除了几行”。
转换成功并不代表值合理。字符串 "9999" 可以被转换成整数,但如果分数范围是 0 到 100,它仍然是异常值。清洗函数最好按顺序执行:复制原始数据、规范空白、转换类型、检查范围、记录失败、输出新表。不要在同一个循环里同时覆盖原列、打印错误和删除行,这会让问题难以复盘。
import pandas as pd
def clean_records(source):
frame = source.copy()
frame["topic"] = frame["topic"].astype("string").str.strip()
frame["minutes"] = pd.to_numeric(
frame["minutes"], errors="coerce"
)
frame["score"] = pd.to_numeric(
frame["score"], errors="coerce"
)
invalid = (
frame["topic"].isna()
| frame["topic"].eq("")
| frame["minutes"].lt(0)
| frame["score"].lt(0)
| frame["score"].gt(100)
)
rejected = frame[invalid].copy()
cleaned = frame[~invalid].copy()
return cleaned, rejected
# df is defined below so the function can be run as a standalone example.
sample = pd.DataFrame({
"topic": ["Python", "数据", "模型"],
"minutes": [35, -2, 40],
"score": [82, 91, 105],
})
cleaned, rejected = clean_records(sample)
print("cleaned:", len(cleaned), "rejected:", len(rejected))这里的“拒绝”只是示例策略;另一个项目可能需要把异常值截断、修复或交给人工。重要的是返回 cleaned 和 rejected 两份结果,并让调用者知道清洗损失了多少记录。大模型语料清洗时还要考虑版权、个人信息、提示注入和原文回溯,不应只用一个正则表达式宣称数据已经安全。
准备 8 行包含空白、重复、空值、负数和超范围分数的记录。完成清洗后输出:原始行数、保留行数、拒绝行数、每种拒绝原因的数量,以及一份拒绝记录表。要求原始 DataFrame 不被原地修改。
07 · TRANSFORM
分组、合并与形状变换:让表回答问题
清洗后的表还不是结论。你需要根据问题选择分组、聚合、排序、合并或透视。每种变换都会改变行数、列数或粒度,最危险的错误是结果仍然“像一张表”,但含义已经变了。操作前先写一句粒度说明:一行代表一个人、一个主题、一天,还是一次实验。
2.7.1 groupby 与聚合:同时报告数量和数值
聚合的第一步是定义结果的一行代表什么
groupby() 会按照一个或多个键把记录分组,但它不会替你决定每个结果字段的意义。按主题分组后,一行可能代表一个主题,也可能代表“主题 + 周次”的组合;mean 可能是有效分数的平均,size 是所有记录的数量,count 只统计非缺失值。写聚合前先完成一句话:“输出表的每一行代表……,每一列用于回答……”。
建议使用命名聚合,把输出列名、输入列和函数写在同一个位置。这样比先分组再得到多层列名更容易阅读,也方便后续保存 JSON。聚合结果应同时报告样本量、有效数值数量和指标;只保留平均值会让小样本和大样本看起来一样可靠。
分组键的清洗也会改变结果。"Python"、" Python " 和 "python" 如果没有规范化,就会产生三个组。日期的时区和周起始日也会改变分组边界。聚合前打印唯一值和分组数量,聚合后检查总记录数是否仍能对账。空分组、缺失键和排序规则都要在报告中说明。
| 函数 | 统计什么 | 缺失值影响 | 适合回答 |
|---|---|---|---|
size | 组内所有行数 | 包含缺失行 | 收到多少条记录 |
count | 某列非空值数 | 忽略该列缺失 | 有多少个有效分数 |
sum | 数值总和 | 需明确空组处理 | 总学习分钟 |
mean | 有效值平均 | 通常忽略缺失 | 平均分或平均时长 |
nunique | 不同值数量 | 可选择是否计缺失 | 不同来源或不同资料数 |
import pandas as pd
events = pd.DataFrame({
"topic": ["Python", "Python", "数据", "数据", "数据"],
"minutes": [35, 20, 50, None, 40],
"score": [82, 76, 91, 88, None],
"source_id": ["a", "a", "b", "c", "c"],
})
summary = (
events.groupby("topic", dropna=False)
.agg(
records=("topic", "size"),
valid_scores=("score", "count"),
average_score=("score", "mean"),
total_minutes=("minutes", "sum"),
sources=("source_id", "nunique"),
)
.reset_index()
.sort_values(["average_score", "records"], ascending=[False, False])
)
print(summary)
print("input rows:", len(events))
print("group records:", int(summary["records"].sum()))这个输出把“有多少行”和“有多少有效分数”分开,读者可以判断平均分的样本基础。排序使用两个键:先按平均分降序,再按记录数降序;并列时的规则也属于输出契约。若要显示百分比或保留小数,应在展示层格式化,不要过早舍入原始聚合值。
records 总和不等于输入行数,检查是否过滤了缺失键、是否使用了错误的粒度或是否在聚合前重复连接。groupby() 先按键分组,再使用 agg() 计算计数、均值、总和或最值。只报告平均分会隐藏样本量:一个主题只有一条 100 分记录,和有 100 条平均 90 分记录,不应被当作同等证据。输出字段名要避免多层索引难以阅读,必要时使用命名聚合。
import pandas as pd
df = pd.DataFrame({
"topic": ["Python", "Python", "数据", "数据", "模型"],
"minutes": [35, 20, 50, 40, 70],
"score": [82, 76, 91, 88, 95],
})
summary = (
df.groupby("topic", as_index=False)
.agg(
records=("score", "size"),
average_score=("score", "mean"),
total_minutes=("minutes", "sum"),
)
.sort_values(["average_score", "records"], ascending=[False, False])
)
print(summary)聚合后要检查行数是否等于唯一分组数,数值是否落在合理范围,计数总和是否等于输入行数。若分组列含有空值,默认行为可能不包含缺失组;是否保留缺失组要写在规则里。排序只是展示层动作,不要把排序后的行号误当成原始主键。
2.7.2 merge:先确认键的关系再连接表
merge 的核心不是语法,而是关系基数
连接两张表之前,要先说清左表和右表的关系:一对一、一对多、多对一还是多对多。学习记录表可能有多行属于同一个主题,主题字典表则应当每个主题只有一行,这通常是多对一。若右表出现重复主题,左表的一行会被复制成多行,分钟和分数的总和就会被悄悄放大。
用 validate="one_to_one"、"many_to_one" 等参数把预期关系写进代码;用 indicator=True 检查哪些左表键没有匹配。连接后马上比较行数、键数量和数值总和,不能只看结果前几行。inner 会丢掉未匹配行,left 会保留左表,选择哪一种取决于问题,而不是习惯。
键的类型和格式必须一致。字符串中的空格、大小写、前导零和日期时区都会造成“看起来相同、实际上匹配不上”。连接前规范化键,连接后保留来源列或使用后缀区分同名字段。若两张表都有 score,不要接受 Pandas 自动生成的 score_x、score_y 而不做解释。
| 关系 | 典型场景 | validate | 最重要的检查 |
|---|---|---|---|
| 一对一 | 资料 ID 与资料元数据 | one_to_one | 两侧键都唯一 |
| 多对一 | 多条事件连接主题字典 | many_to_one | 右表键唯一、左表行数不应无故增加 |
| 一对多 | 主题连接其子资料 | one_to_many | 左表键唯一、展开倍数可解释 |
| 多对多 | 标签与资料的中间关系 | 谨慎使用 | 预估笛卡尔积规模并单独建关系表 |
import pandas as pd
events = pd.DataFrame({
"source_id": ["a", "b", "c", "x"],
"minutes": [35, 50, 20, 15],
})
topics = pd.DataFrame({
"source_id": ["a", "b", "c"],
"topic": ["Python", "数据", "模型"],
})
before_rows = len(events)
before_total = events["minutes"].sum()
joined = events.merge(
topics,
on="source_id",
how="left",
validate="many_to_one",
indicator=True,
)
print(joined)
print("match status:", joined["_merge"].value_counts().to_dict())
print("unmatched:", int((joined["_merge"] == "left_only").sum()))
assert len(joined) == before_rows
assert joined["minutes"].sum() == before_total示例中的 x 没有主题,因此通过左连接保留下来并被标记为 left_only。如果业务要求每条事件都必须有主题,就应该在检查后停止或进入拒绝表;如果主题可选,则在报告中记录未匹配数量。连接验证把“关系假设”变成了运行时保护。
合并两张表前,先检查键是否唯一以及连接关系是一对一、一对多还是多对多。多对多合并会产生笛卡尔式放大,行数可能突然增加;如果没有意识到,后面的平均值会被重复记录扭曲。使用 validate 参数可以把预期关系写进代码,让关系不符合时立即失败。
import pandas as pd
events = pd.DataFrame({
"source_id": ["a", "b", "a"],
"minutes": [20, 35, 15],
})
sources = pd.DataFrame({
"source_id": ["a", "b"],
"owner": ["小林", "小周"],
})
result = events.merge(
sources,
on="source_id",
how="left",
validate="many_to_one",
indicator=True,
)
print(result)
print(result["_merge"].value_counts())左连接保留左表事件,右表找不到的所有者会变成缺失;内连接只保留两边都能匹配的记录。选择 how 不是风格偏好,而是对“哪些记录应该留下”的业务承诺。合并后检查行数、未匹配键和关键字段缺失,不能只看几行样例。
2.7.3 宽表、长表和透视
连接和变换要有可逆的检查点
并不是每个变换都能完全恢复原表,但应该知道哪些信息被压缩、聚合或丢弃。宽转长通常保留变量和值,透视并聚合可能把多条记录压成一个数,排序可能改变显示顺序,重置索引可能丢弃旧位置。把输入表的行数、列名、联合键和数值总和记录下来,输出后比较能够保留的部分。
对于多对多关系,先建立中间关系表比直接 merge 更安全。例如资料和标签之间可以用 source_id、tag 的两列表示关联,标签本身的说明放在字典表;直接把标签列表塞进一个单元格会让计数、去重和连接都变得困难。关系表的联合键也要检查重复,重复可能代表同一关联重复上报。
变换后的表要服务于下一步:绘图需要长表或已聚合的结果,模型通常需要固定列顺序的数值矩阵,报告可能需要宽表。不要为了“看起来像 Excel”把所有数据都透视成宽表,也不要为了调用一个函数把明确的关系变成难以阅读的嵌套对象。
| 操作 | 可能丢失的信息 | 如何保留线索 |
|---|---|---|
| 过滤 | 被排除的行 | 拒绝表、过滤规则、行数 |
| 聚合 | 组内明细 | 样本量、来源键、原始表 |
| 透视 | 重复组合的明细 | 聚合函数、重复审计表 |
| 重置索引 | 旧位置标签 | 稳定 ID 或显式来源列 |
| 多对多连接 | 关系倍增 | 中间关系表、基数和行数 |
import pandas as pd
documents = pd.DataFrame({
"source_id": ["a", "b", "c"],
"title": ["Python", "数据", "模型"],
})
relations = pd.DataFrame({
"source_id": ["a", "a", "b", "c", "c"],
"tag": ["code", "beginner", "table", "model", "beginner"],
})
tags = pd.DataFrame({
"tag": ["code", "beginner", "table", "model"],
"description": ["代码", "入门", "表格", "模型"],
})
assert not relations.duplicated(["source_id", "tag"]).any()
joined = (
relations
.merge(documents, on="source_id", how="left",
validate="many_to_one")
.merge(tags, on="tag", how="left",
validate="many_to_one")
)
print(joined)
print("documents:", joined["source_id"].nunique())
print("relations:", len(joined))
assert joined["title"].notna().all()
assert joined["description"].notna().all()关系表的设计让一个资料拥有多个标签,同时保留每条关联的独立行。以后需要按标签统计时,可以直接分组;需要展示资料时,再连接标题。每一步关系都可检查,避免在一个单元格里存列表后无法查询。
选择一张包含重复键的事件表,先保存原始快照,再完成过滤、聚合、透视和连接。每一步写出哪些信息被保留、哪些信息被压缩,保存一份行数与键数量清单。最后尝试从汇总表回到明细,说明哪些信息已经不能恢复,以及为什么必须保留原始表。
变换之后要重新定义主键和粒度
每次 melt()、pivot()、concat() 或连接操作之后,都应该重新写出“一行代表什么”。宽表转成长表通常增加行数,长表透视回宽表通常减少行数但增加列;这两个变化本身都不是好坏判断。关键是变换后的联合键、排序和缺失含义是否仍然符合下一步任务。
拼接表格时要区分纵向追加和横向对齐。concat(axis=0) 适合多批相同字段的记录,要求列名和单位一致;concat(axis=1) 按索引对齐,索引不一致会产生缺失,不能当成“把两张表并排贴上”。横向拼接前确认索引代表同一粒度,必要时先用稳定键连接。
透视和排序常常需要辅助字段。显示名称可以保留中文,排序键可以是整数或日期;聚合值保留原始精度,展示时再格式化。输出给模型或其他程序时尽量使用平坦、稳定的列名,避免多层列索引在序列化和后续读取时变成难以处理的字符串。
| 复核项 | 问题 | 示例证据 |
|---|---|---|
| 粒度 | 一行代表什么? | 主题 + 周次 + 指标 |
| 键 | 哪组列应该唯一? | duplicated(subset=...) |
| 行数 | 行数改变是否符合预期? | 输入输出 shape、展开倍数 |
| 缺失 | 空白代表无记录还是零? | 填充值和规则说明 |
| 顺序 | 展示和计算使用什么排序? | 整数周次、日期、显式 sort |
import pandas as pd
events = pd.DataFrame({
"topic": ["Python", "Python", "数据", "数据"],
"week": [1, 2, 1, 2],
"minutes": [35, 20, 50, 40],
})
print("raw shape:", events.shape)
print("raw key duplicates:",
int(events.duplicated(["topic", "week"]).sum()))
long = events.copy()
long["metric"] = "minutes"
long = long.rename(columns={"minutes": "value"})
print("long shape:", long.shape)
wide = long.pivot(
index="topic",
columns="week",
values="value",
)
wide.columns = [f"week_{number}" for number in wide.columns]
wide = wide.reset_index()
print("wide shape:", wide.shape)
print(wide)
assert set(wide["topic"]) == set(events["topic"])
assert wide.shape[0] == events["topic"].nunique()示例中透视前的联合键是唯一的,因此可以使用 pivot()。如果同一主题同一周有多条事件,应该先聚合或决定保留策略,再透视;不要把 pivot_table() 当成“自动修复重复”的按钮。每次变换后都保留一行审计输出,复杂流程才不会变成不可解释的黑箱。
表格形状应该服从下一步问题
宽表把多个变量放在列中,例如 week_1、week_2;长表把变量名称和值拆成行,通常包含一个标识列、一个变量列和一个数值列。两种形状没有绝对的优劣:宽表便于人阅读和某些矩阵运算,长表便于按类别筛选、分组和绘图。先写下一步需要什么粒度,再选择 melt()、pivot() 或 pivot_table()。
pivot() 要求索引和列的组合唯一,重复组合会直接报错;pivot_table() 可以通过聚合函数处理重复值,但它不会告诉你该聚合是否符合业务。使用透视前先统计重复的索引—列组合,决定是去重、求和、取最后一次还是保留多条记录。填充缺失值也要说明“没有记录”和“值为零”的区别。
形状转换常常是为图表或模型准备数据。转换完成后要重新写出一行的含义、列的单位和排序规则。不要因为表格看起来规整,就跳过唯一键和缺失检查;长表中的重复变量组合会在绘图时生成重复线段,宽表中的隐藏空列会让模型输入列数变化。
| 形状 | 一行代表 | 适合 | 先检查 |
|---|---|---|---|
| 宽表 | 一个主题及其多个测量列 | 人类阅读、矩阵输入 | 列名是否包含变量和单位 |
| 长表 | 一个主题的一次变量测量 | 分组、过滤、绘图 | 标识 + 变量组合是否唯一 |
| 透视表 | 一个索引与多个列组合 | 报告展示、交叉表 | 重复组合的聚合规则 |
| 中间关系表 | 两个实体的一次关联 | 多对多关系 | 联合键和重复关系 |
import pandas as pd
wide = pd.DataFrame({
"topic": ["Python", "数据"],
"week_1": [35, 50],
"week_2": [20, 40],
})
long = wide.melt(
id_vars=["topic"],
var_name="week",
value_name="minutes",
)
long["week_no"] = long["week"].str.extract(r"(\\d+)").astype(int)
report = long.pivot_table(
index="topic",
columns="week",
values="minutes",
aggfunc="sum",
fill_value=0,
).reset_index()
print("long:")
print(long.sort_values(["topic", "week_no"]))
print("report:")
print(report)长表中的 week_no 是从显示列名中提取的排序字段,避免字符串排序把 week_10 放到 week_2 前面。展示名称和排序键可以分离,这种小小的额外列常常能避免图表顺序错误。透视后的报告如果要交给其他程序,最好把列名和填充值写入输出契约。
准备一张包含主题、周次、分钟数和分数的长表,再把它透视成宽表。故意加入同一主题同一周的两条记录,分别尝试 pivot() 和 pivot_table()。记录报错或聚合结果,说明你选择的汇总规则如何改变一行的含义,并画出转换前后的列关系。
宽表把不同类别放在多个列中,长表把类别和值拆成两列。绘图和分组通常更喜欢长表,因为一个变量可以通过类别列筛选;展示给人时,宽表可能更直观。melt()、pivot_table() 和 sort_values() 只改变表示方式,不会自动修复重复键或缺失值。
先写目标粒度,再选择变换
import pandas as pd
wide = pd.DataFrame({
"topic": ["Python", "数据"],
"week_1": [35, 50],
"week_2": [20, 40],
})
long = wide.melt(
id_vars=["topic"],
var_name="week",
value_name="minutes",
)
report = long.pivot_table(
index="topic",
columns="week",
values="minutes",
aggfunc="sum",
fill_value=0,
)
print(long)
print(report)如果目标是“每个主题每周一行”,长表就是更接近问题的形状;如果目标是“每个主题一行、每周一列”的展示表,再透视回宽表。变换后重新写出行的含义,并检查每个主题是否有重复的周键。形状正确但粒度错误,是数据分析中最难靠肉眼发现的 bug 之一。
准备一张事件表、一张主题表和一张周次表。分别完成分组、左连接和长宽转换,每一步记录输入行数、输出行数、唯一键数量和未匹配数量。故意在右表加入重复键,观察 validate="many_to_one" 怎样保护你。
aggfunc 不是为了消除报错,而是明确重复值如何汇总。08 · VISUALIZE
Matplotlib:先选问题,再画图
图表不是数据的装饰,而是把比较、趋势、分布或关系变得可见。画图前先问问题:要比较类别,观察时间变化,查看数值分布,还是寻找两个变量的关系?图表必须标出单位、样本量和数据来源;否则读者看到的只是形状,不知道形状代表什么。
2.8.1 图表类型与问题类型
先写句子,再选择图形
图表选择可以从一个句子开始:“我想比较不同主题的记录数量”“我想观察每周平均分的变化”“我想了解学习时长的分布”或“我想看看分钟数和分数是否一起变化”。句子的动词通常暗示图表:比较对应柱状图,变化对应折线图,分布对应直方图,关系对应散点图。先写问题能避免为了展示某个漂亮模板而改变问题。
图表中的视觉编码也有优先级。位置和长度通常比面积、角度和颜色更容易比较,所以类别比较优先使用排序柱状图;类别很多时可以使用横向条形图并缩短标签。折线图连接的是有顺序的观测,不能把没有时间顺序的类别随意连起来。散点图中的一个点要有清楚的记录粒度,重叠点很多时应考虑透明度或计数,而不是直接得出“没有关系”。
图表准备数据时要避免把聚合和绘图混在一起。先生成一张列名清楚、单位明确的 summary 表,再把它交给绘图函数。绘图函数不应该偷偷删除缺失值或改变排序,否则同一张 summary 无法复用于 JSON 报告。图表标题、坐标轴、图例和注释分别回答“这是什么、单位是什么、颜色代表什么、需要注意什么”。
| 问题动词 | 图表 | 位置编码 | 常见误读 |
|---|---|---|---|
| 比较多少 | 排序柱状图 | 柱顶高度 | 纵轴截断夸大差异 |
| 观察变化 | 折线图 | 连续点的位置和连线 | 把缺失期间连成真实趋势 |
| 了解分布 | 直方图或箱线图 | 区间计数或四分位数 | 箱宽改变后结论改变 |
| 发现关系 | 散点图 | 两个轴上的点 | 相关性被说成因果 |
| 展示部分构成 | 堆叠条形图 | 同一基线上的长度 | 类别过多难以比较 |
import pandas as pd
summary = pd.DataFrame({
"topic": ["Python", "数据", "模型"],
"records": [12, 8, 5],
"average_score": [82.5, 91.0, 88.0],
})
question = "比较每个主题有多少条有效学习记录"
if "多少" in question or "比较" in question:
chart = "bar"
elif "变化" in question:
chart = "line"
elif "分布" in question:
chart = "hist"
else:
chart = "scatter"
print("question:", question)
print("chart:", chart)
print("columns available:", summary.columns.tolist())代码中的判断只是教学用的提醒,不是自动替你选择图表的智能系统。真正的分析仍然需要人确认数据粒度、样本量和读者。把问题写进实验记录,能让几周后的自己知道为什么画这张图,也能避免因为换了字段就继续沿用旧的可视化。
柱状图适合比较少量类别,折线图适合有顺序的时间或实验轮次,散点图适合观察两个数值变量的关系,直方图适合观察分布。饼图和三维效果并不会自动增加信息,类别很多或差异很小时尤其容易误导。选择图表时,先写一句“横轴表示什么,纵轴表示什么,每个点或柱子代表什么”。
| 问题 | 优先图表 | 需要标注 | 容易误读的地方 |
|---|---|---|---|
| 哪个主题记录更多? | 排序柱状图 | 类别、计数、排序规则 | 类别太多导致标签重叠 |
| 分数随周次怎样变化? | 折线图 | 周次、单位、缺失周 | 用线连接不存在的中间数据 |
| 分钟数分布如何? | 直方图 | 箱宽、样本量、单位 | 箱宽改变后视觉结论改变 |
| 时间和分数有关吗? | 散点图 | 每点代表的记录、相关性边界 | 相关不等于因果,重叠点可能被忽略 |
2.8.2 Figure、Axes 和保存文件
Figure 管画布,Axes 管坐标系
Matplotlib 的对象接口把职责拆开:Figure 是整张画布,可以包含一个或多个 Axes;Axes 负责数据、坐标轴、标题、图例和网格。显式写出 fig, ax = plt.subplots() 后,读者能看到样式和数据都作用于哪一个坐标系。使用全局状态接口并非永远错误,但在生成多张图或写成函数时容易把上一张图的设置带进来。
图形尺寸用英寸指定,分辨率用 dpi 指定;屏幕预览和报告打印需要的参数可能不同。tight_layout() 可以减少标签被裁切的机会,但它不能替你检查中文字体、长标题和极端刻度。保存前先创建输出目录,保存后检查文件存在和大小;如果报告需要再次生成,保留代码、输入 summary 和参数,不要只保存 PNG。
颜色应当有目的:同一类别在多张图中保持同色,连续数值使用有顺序的色带,强调色只给真正需要读者注意的对象。考虑黑白打印、色觉差异和低分辨率屏幕,不能只依赖颜色传递信息。标签中的单位、日期格式和小数位要统一,避免一张图显示“分钟”,另一张图显示“小时”却没有说明。
| 对象 | 回答的问题 | 代码位置 | 验收 |
|---|---|---|---|
| Figure | 画布多大,输出到哪里 | plt.subplots(figsize=...) | 不裁切、不覆盖旧结果 |
| Axes | 数据如何编码 | ax.bar/plot/scatter | 每个元素粒度明确 |
| 标题 | 图表在回答什么 | ax.set_title() | 包含主题和范围 |
| 标签 | 轴的单位是什么 | set_xlabel/set_ylabel | 单位和刻度一致 |
| 保存 | 别人怎样复现 | fig.savefig() | 代码、数据、参数可追溯 |
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd
summary = pd.DataFrame({
"topic": ["模型", "数据", "Python"],
"average_score": [95.0, 89.5, 79.0],
"records": [3, 8, 12],
})
output_dir = Path("output")
output_dir.mkdir(parents=True, exist_ok=True)
fig, ax = plt.subplots(figsize=(7, 4))
bars = ax.bar(summary["topic"], summary["average_score"], color="#176b66")
ax.set_title("各主题平均分(附样本量)")
ax.set_xlabel("主题")
ax.set_ylabel("平均分(0–100)")
ax.set_ylim(0, 100)
ax.grid(axis="y", alpha=0.25)
for bar, records in zip(bars, summary["records"]):
ax.text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + 2,
f"n={records}",
ha="center",
va="bottom",
fontsize=9,
)
fig.tight_layout()
path = output_dir / "average-score.png"
fig.savefig(path, dpi=160, facecolor="white")
plt.close(fig)
print("saved:", path, "bytes:", path.stat().st_size)把 plt.close(fig) 放在保存之后,可以避免批量生成图表时旧画布占用内存。条形顶部的 n 是样本量提示,不应替代数据表。若数据中有缺失或拒绝记录,图题或注释还应说明统计口径。
Matplotlib 推荐显式创建 fig, ax,让图形对象和坐标轴对象的职责清楚。标题、标签、图例和网格应服务于阅读,不要把所有样式都堆在一行调用中。保存图像时指定尺寸、分辨率和输出路径,先创建输出目录;同时保存生成图表的代码和数据快照,图片本身不能说明所有规则。
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd
summary = pd.DataFrame({
"topic": ["模型", "数据", "Python"],
"average_score": [95, 89.5, 79],
})
fig, ax = plt.subplots(figsize=(7, 4))
ax.bar(summary["topic"], summary["average_score"], color="#176b66")
ax.set_title("各主题平均分")
ax.set_xlabel("主题")
ax.set_ylabel("平均分(0–100)")
ax.set_ylim(0, 100)
ax.grid(axis="y", alpha=0.25)
fig.tight_layout()
output = Path("output/average-score.png")
output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output, dpi=160)
print("saved:", output)设置纵轴从 0 开始,是为了让柱子的长度与数值差异保持直观关系;如果有理由截断坐标轴,必须在标题、注释或刻度中明确。颜色也应有语义:不要用相近颜色区分大量类别,也不要把装饰性颜色解释成重要程度。图表生成后打开图片检查中文字体、标签、单位和空白区域。
2.8.3 读图、质疑和复现
把图表和表格并列交付
图表适合快速比较,表格适合精确核对。重要报告可以把同一份 summary 同时导出为 CSV、JSON 和 PNG:读者先看图发现模式,再用表格确认具体数字,最后通过清单回到来源和参数。三种输出都从同一个聚合对象生成,不要分别手工复制数据,否则很容易出现图表和表格不一致。
import json
from pathlib import Path
import pandas as pd
summary = pd.DataFrame({
"topic": ["Python", "数据", "模型"],
"records": [12, 8, 3],
"average_score": [82.5, 91.0, 88.0],
})
output = Path("output")
output.mkdir(parents=True, exist_ok=True)
summary.to_csv(output / "summary.csv", index=False)
(output / "summary.json").write_text(
json.dumps(summary.to_dict(orient="records"), ensure_ascii=False, indent=2),
encoding="utf-8",
)
loaded = pd.read_json(output / "summary.json")
assert loaded["records"].sum() == summary["records"].sum()
print("bundle rows:", len(loaded))这里的回读断言只检查结构和计数,真实项目还可以比较列名、排序和关键指标。图表、表格和清单并列后,复核者不必依赖作者口头解释,数据处理的责任边界也更加清楚。
图表报告要区分观察、解释和决定
一份成熟的图表说明可以分成三层。观察层只描述可直接从数据看到的事实,例如“模型主题的有效记录数为 3,平均分为 88”;解释层提出可能原因,并明确它只是猜想;决定层说明下一步要抽查、补充数据还是改变实验。把三层混在一句“模型学习效果最好”里,会让读者误以为图表已经证明了因果。
图表的标题和正文也应保持一致。若图表排除了缺失分数,正文要写“有效分数的平均”;若只展示前五个主题,要说明排序和截断;若数据来自人工示例,要明确它不是现实总体。报告中可以同时放小表格,提供图表无法展示的精确数字、样本量和拒绝数量。
复现检查可以做成一个固定清单:输入文件和版本是否相同,清洗后的行数是否相同,summary 的列和排序是否相同,图表尺寸和轴范围是否相同,输出文件是否能打开。任何一项不同时,先记录差异再解释结论,不要先调整样式让两张图看起来相似。
| 层次 | 示例 | 证据要求 |
|---|---|---|
| 观察 | 数据主题的有效样本为 8 | summary 的 count |
| 解释 | 可能因为资料更容易完成 | 需要额外变量或实验 |
| 限制 | 人工小样本,不能推广 | 来源和样本范围 |
| 决定 | 抽查缺失记录并增加下一周样本 | 行动、负责人、时间 |
import json
from pathlib import Path
report = {
"observation": "数据主题有效记录更多",
"evidence": {
"input_rows": 20,
"valid_rows": 17,
"groups": 4,
"missing_score_rows": 2,
},
"interpretation": "样本量和资料难度可能共同影响平均分",
"limitation": "示例数据不是总体样本",
"next_action": "补充按周记录并抽查拒绝行",
}
path = Path("output/report-snapshot.json")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
loaded = json.loads(path.read_text(encoding="utf-8"))
assert set(["observation", "evidence", "limitation"]).issubset(loaded)
print(loaded["observation"])可读性、可访问性和不确定性同样重要
图表首先要让读者读懂,其次才是好看。字号、留白、标签长度和图例位置都要在实际输出尺寸下检查;在手机或窄窗口上,过长的主题名应换行、缩写或改用横向图。标题不能只写“结果图”,应包含对象、指标和时间范围;坐标轴单位不能依赖正文的猜测。
不要只用颜色区分类别,使用位置、线型、标记或直接标注提供第二种线索。连续色带应从低到高有明确顺序,离散类别使用数量有限且对比足够的颜色。对需要打印、投影或色觉差异读者的场景,先做灰度和小尺寸检查。图表中展示的数字应和汇总表一致,标签四舍五入不能让读者误以为原始值就是整数。
平均值本身也有不确定性。样本量少、分布偏斜或异常值较多时,可以同时展示中位数、范围、四分位数或误差提示。第二章不要求马上学习复杂的统计推断,但要养成“不把一个点当成完整事实”的习惯。图表旁写清样本量和筛选规则,读者才能判断差异是否值得进一步研究。
| 维度 | 检查问题 | 改进手段 |
|---|---|---|
| 可读性 | 缩小后标签还能读吗? | 调整 figsize、横向布局、标签换行 |
| 可访问性 | 不看颜色能否区分? | 线型、标记、直接标注、文字说明 |
| 单位 | 数字的量纲和范围清楚吗? | 轴标签、标题、注释 |
| 样本 | 每个点有多少有效记录? | n 标注、计数表、缺失率 |
| 不确定性 | 平均值是否被少数异常值影响? | 中位数、范围、抽查原始记录 |
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd
summary = pd.DataFrame({
"topic": ["Python", "数据分析", "模型基础"],
"average": [82.5, 91.0, 88.0],
"count": [12, 8, 3],
"minimum": [60, 78, 80],
"maximum": [98, 100, 95],
})
fig, ax = plt.subplots(figsize=(8, 4.5))
bars = ax.barh(
summary["topic"],
summary["average"],
color=["#176b66", "#a15f2d", "#5d5a8e"],
)
ax.set_title("学习主题平均分(有效记录数见柱末)")
ax.set_xlabel("平均分(0–100)")
ax.set_xlim(0, 100)
ax.grid(axis="x", alpha=0.25)
for bar, count in zip(bars, summary["count"]):
ax.text(
bar.get_width() + 2,
bar.get_y() + bar.get_height() / 2,
f"n={count}",
va="center",
)
fig.tight_layout()
path = Path("output/readable-summary.png")
path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(path, dpi=180, facecolor="white")
plt.close(fig)
print(path)横向柱状图让较长的主题名更容易阅读,直接标注样本量也比让读者去猜更可靠。颜色在这里只是辅助,真正的比较依据仍然是柱子的长度和文字标签。若要显示范围,可以用误差线或在旁边提供表格;不要把装饰线解释成统计置信区间。
把你生成的图缩小到手机宽度或打印成灰度,邀请一位没有看代码的人回答:图表对象是什么?单位是什么?一个柱子代表多少条记录?最高值和最低值分别是多少?如果对方答不出来,修改标题、标签、颜色或布局,再重复测试。
读图也要像读代码一样逐项验证
看见一条上升的线,先不要说“学习效果越来越好”。第一步读标题和时间范围,第二步确认横轴是否等间隔、纵轴单位和起点,第三步查看每个点的样本量与缺失,第四步回到汇总表抽查原始记录,最后才提出可能的解释。图表能帮助发现模式,却不能单独证明原因。
刻度范围会改变视觉重量。柱状图通常从 0 开始,让长度与数值差异对应;折线图是否从 0 开始要看问题,但任何截断都必须明确。颜色和排序也会影响读者注意力,排序依据应写在代码和报告中。类别太多时,宁可拆成多个小图或只展示预先说明的前几项,也不要把不可读的标签挤在一张图里。
复现的关键是参数固定和输入可追踪。把随机抽样固定种子,把数据清洗后的版本保存下来,把图表文件名和生成时间写入清单。若图表重新生成后不同,先比较输入行数、列类型、依赖版本和绘图参数;不要通过手动移动标签来掩盖数据变化。一个好的复现记录能够说明“为什么这张图今天和昨天不同”。
| 检查对象 | 提问 | 证据 |
|---|---|---|
| 范围 | 坐标轴是否截断或单位改变? | 刻度、标签、绘图参数 |
| 样本 | 每个点或柱有多少有效记录? | count、缺失率、原始行 |
| 顺序 | 类别为什么按这个顺序? | sort_values 的键和方向 |
| 关系 | 相关是否被误解成因果? | 问题定义和额外证据 |
| 复现 | 换一台机器能否得到同一结构? | 输入、版本、参数、输出清单 |
import json
from pathlib import Path
from datetime import datetime, timezone
manifest = {
"input": "data/clean/study-clean.csv",
"aggregation": "groupby topic: count, mean",
"sort": "average_score descending",
"axis": {"x": "topic", "y": "average_score", "y_unit": "0-100"},
"filters": ["score is not missing", "0 <= score <= 100"],
"generated_at": datetime.now(timezone.utc).isoformat(),
"script": "plot_artifact.py",
}
path = Path("output/average-score.manifest.json")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
loaded = json.loads(path.read_text(encoding="utf-8"))
assert loaded["axis"]["y_unit"] == "0-100"
print("manifest keys:", sorted(loaded))清单中的时间会变化,但输入路径、聚合口径、轴单位和过滤规则应该稳定可读。若需要严格比较,可以另外保存代码版本或输入文件校验和。复现不是要求每个像素永远相同,而是要求数据口径和结论可以被追溯、解释和重新检查。
使用本章任意一张汇总表生成柱状图或折线图。提交图像、绘图代码和清单,并回答五个问题:图表回答什么问题?一个视觉元素代表什么?样本量是多少?坐标轴和单位是否容易误读?如果输入多一行或缺一列,哪一步会发现?
阅读图表时,先看标题和单位,再看横纵轴范围、样本量和图例,最后观察异常点。一个峰值可能来自真实变化,也可能来自重复行、缺失值填补、合并放大或刻度截断。复现图表时,使用同一份清洗后的数据和相同参数;如果只能通过手动拖动表格得到结论,说明分析过程还没有被写成代码。
可靠的图表不是从“画出来”结束,而是能沿着来源、清洗和参数回到原始证据。
对一份按主题统计的表画一张柱状图,再回答:每个柱子代表多少条记录?平均值是否受缺失值影响?纵轴是否从 0 开始?颜色是否表达了某种含义?把数据来源、清洗规则和图像文件路径写入同一个实验记录。
09 · DATA CAPSTONE
综合项目:完成一条可复现的数据管道
最后把本章内容串成一个小项目:读取学习记录,验证字段,清洗缺失和异常值,按主题聚合,生成一张图和一份 JSON 报告。项目不追求真实数据规模,而追求每一步都有输入、输出、规则和验收证据。未来把学习记录换成模型评测结果、文档统计或检索日志,流程仍然成立。
2.9.1 先写规格,再创建目录
目录结构是数据血缘的一部分
一个可复现项目要让人一眼看出原始资料、处理中间物和最终交付物的边界。data/raw 只保存原始输入,不在其中覆盖清洗结果;data/clean 保存经过契约检查的表;output 保存面向读者的 JSON、图像和摘要;src 保存可复用代码;tests 保存最小的自动检查。目录不是越复杂越好,而是要让每个文件只有一个容易解释的位置。
README 应该像一份操作说明,而不是项目宣传语。开头写项目回答的问题和数据来源,接着列出环境创建命令、入口命令、输入字段、输出字段和已知限制,最后放一组最小示例。别人按照 README 从一个新的终端运行时,如果仍然需要询问“文件放在哪里、先运行哪条命令”,说明规格还不完整。
输出文件要区分可重建和不可重建。PNG 是可重建的交付物,JSON 是机器可读的摘要,清洗后的 CSV 是中间证据,原始 CSV 是来源。生成新版本时,可以在输出清单中记录时间、脚本版本和输入校验和;不要用“最终版”“最终版2”这样的文件名表达版本。路径和文件名一旦被其他代码引用,就应当当作接口管理。
| 位置 | 可以放什么 | 谁会读取 | 禁止的习惯 |
|---|---|---|---|
data/raw | 原始 CSV、JSON、来源说明 | 读取函数、人工复核 | 覆盖、手工改值、删除来源 |
data/clean | 通过规则后的表和拒绝表 | 聚合函数、绘图函数 | 不记录清洗规则 |
src | 读取、验证、变换、保存函数 | 入口脚本、测试 | 把个人绝对路径写死 |
output | 报告、PNG、JSON、清单 | 读者、下游程序 | 只保留截图不保留参数 |
tests | 小样本和失败案例 | 自动测试、冷启动 | 只测试成功路径 |
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
RAW_DIR = PROJECT_ROOT / "data" / "raw"
CLEAN_DIR = PROJECT_ROOT / "data" / "clean"
OUTPUT_DIR = PROJECT_ROOT / "output"
for directory in (RAW_DIR, CLEAN_DIR, OUTPUT_DIR):
directory.mkdir(parents=True, exist_ok=True)
input_path = RAW_DIR / "study.csv"
clean_path = CLEAN_DIR / "study-clean.csv"
report_path = OUTPUT_DIR / "study-report.json"
print("root:", PROJECT_ROOT)
print("input exists:", input_path.exists())
print("clean output:", clean_path)
print("report output:", report_path)通过 __file__ 推导项目根目录,可以减少从不同工作目录启动时的差异。入口脚本仍然应该在 README 中指定启动方式;路径推导解决的是代码内部的一致性,不是替代清楚的操作说明。
项目目录可以分成 data/raw、data/clean、output、src 和 tests。原始数据只读保存,清洗结果单独写出,图表和 JSON 放在输出目录,代码不把绝对路径写死。README 要写输入字段、运行命令、输出字段、已知限制和示例结果。
项目规格:学习记录质量报告
输入一份 UTF-8 CSV,输出清洗后的 CSV、按主题聚合的 JSON、平均分柱状图和终端摘要。每条被拒绝的记录都要保留原因,报告能够被另一个 Python 程序再次读取。
| 阶段 | 成功证据 | 失败证据 | 不可省略的元数据 |
|---|---|---|---|
| 读取 | 记录文件路径、编码和输入行数 | 文件不存在或无法解码时停止 | source、encoding、input_rows |
| 验证 | 必需列和范围检查通过 | 指出字段名、行号或规则 | schema、validation_rules |
| 清洗 | 保留表与拒绝表行数对账 | 保存拒绝原因,不静默删除 | cleaned_rows、rejected_rows |
| 聚合 | 报告主题、样本量和平均分 | 空分组或重复键被发现 | group_key、aggregation |
| 交付 | JSON 可回读,PNG 可打开 | 输出路径不可写时给出提示 | generated_at、script_version |
2.9.2 把管道拆成可以测试的函数
函数边界决定错误能否被定位
读取函数只负责把字节或文本变成表,验证函数只负责判断契约,清洗函数负责产生保留表和拒绝表,聚合函数负责从保留表计算指标,保存函数负责序列化结果。每个函数都应有明确的输入和返回值,尽量不要依赖隐含的全局变量。这样设计的好处不是形式上的“模块化”,而是每个失败都能定位到一个小范围。
可测试的函数通常具有两个特征:输入相同就得到相同结构的输出,不通过打印屏幕传递主要结果。打印可以作为调试或命令行界面,但核心函数应返回 DataFrame、字典或报告对象。副作用集中在入口层,便于测试时使用内存样本和临时目录。函数名可以说明动作,参数名可以说明单位和粒度。
在管道中传递的数据对象也应保持契约。清洗函数返回的表需要保留原始行索引或稳定 ID,拒绝表需要有原因字段;聚合结果需要同时保留分组键和样本量;保存函数接收已经准备好的 payload,不在内部偷偷重新计算。职责清楚后,换成 NumPy 数组或模型评测表时,只需要替换对应的读取和契约部分。
| 函数 | 输入 | 返回 | 最小测试 |
|---|---|---|---|
read_input | 路径、编码 | 原始 DataFrame | 正常文件、缺文件、坏编码 |
validate_table | 表、契约 | 通过表、拒绝表、问题 | 缺列、空值、范围、重复键 |
aggregate | 通过表、分组键 | 汇总表 | 空表、单组、多组、缺失指标 |
save_json | 路径、字典 | 输出文件 | 可回读、目录创建、写入失败 |
run | 配置或路径 | 整体报告 | 成功路径和失败不伪报成功 |
import pandas as pd
def add_score_ratio(frame):
data = frame.copy()
data["score_ratio"] = data["score"] / 100
return data
def summarize_by_topic(frame):
return (
frame.groupby("topic", as_index=False)
.agg(
records=("topic", "size"),
average_score=("score", "mean"),
total_minutes=("minutes", "sum"),
)
.sort_values("topic")
.reset_index(drop=True)
)
sample = pd.DataFrame({
"topic": ["Python", "数据", "Python"],
"score": [82, 91, 76],
"minutes": [35, 50, 20],
})
with_ratio = add_score_ratio(sample)
summary = summarize_by_topic(with_ratio)
assert "score_ratio" not in sample.columns
assert summary["records"].sum() == len(sample)
print(with_ratio)
print(summary)上例用断言验证了一个接口承诺:add_score_ratio() 返回新表,不改变传入的 sample。真实项目可以使用类型提示、文档字符串和更小的测试样本来加强这个承诺,但不要为了“看起来专业”添加无法维护的抽象层。
不要把读取、清洗、聚合、绘图和保存全部写进一个主循环。每个函数都应该有清楚的输入和返回值,主函数只负责编排。这样你可以用一个五行 DataFrame 测试清洗规则,用一个两行表测试聚合,用临时目录测试文件输出,而不必每次都从原始文件开始。
from pathlib import Path
import json
import pandas as pd
REQUIRED = ["topic", "minutes", "score", "completed"]
def read_input(path):
return pd.read_csv(path, encoding="utf-8")
def clean_input(frame):
data = frame.copy()
data["topic"] = data["topic"].astype("string").str.strip()
data["minutes"] = pd.to_numeric(data["minutes"], errors="coerce")
data["score"] = pd.to_numeric(data["score"], errors="coerce")
invalid = (
data["topic"].isna()
| data["topic"].eq("")
| data["minutes"].isna()
| data["minutes"].lt(0)
| data["score"].isna()
| data["score"].lt(0)
| data["score"].gt(100)
)
return data[~invalid].copy(), data[invalid].copy()
def aggregate(data):
return (
data.groupby("topic", as_index=False)
.agg(
records=("score", "size"),
average_score=("score", "mean"),
total_minutes=("minutes", "sum"),
)
.sort_values("average_score", ascending=False)
)
def save_json(path, payload):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def run(source_path, output_dir):
raw = read_input(source_path)
clean, rejected = clean_input(raw)
summary = aggregate(clean)
clean.to_csv(output_dir / "clean.csv", index=False)
rejected.to_csv(output_dir / "rejected.csv", index=False)
save_json(
output_dir / "summary.json",
{
"source": str(source_path),
"input_rows": len(raw),
"cleaned_rows": len(clean),
"rejected_rows": len(rejected),
"summary": summary.to_dict(orient="records"),
},
)
return summary, rejected这份骨架没有包含命令行解析和绘图,是为了先让核心数据契约稳定。运行前可以检查 set(REQUIRED) 是否是输入列的子集,运行后可以回读 summary.json,验证输入行数等于保留行数加拒绝行数。绘图函数应接收 summary,不要再次读取原始文件;同一份聚合结果才能保证图和 JSON 讲的是同一个故事。
2.9.3 测试、报告和一次失败运行
测试矩阵要覆盖失败,而不是只截图成功
最小测试可以先不用测试框架,使用普通 Python 文件和 assert 验证关键不变量;熟悉后再把它们迁移到 unittest 或其他测试工具。测试对象应当是规则和职责,而不是某次运行的完整打印文本。比如清洗测试验证行数对账、拒绝原因和原始表未被修改;聚合测试验证样本量和分组结果;保存测试验证 JSON 能回读。
失败运行也应当是项目记录的一部分。文件不存在、缺列、编码错误和输出目录不可写,都应该产生非零状态或清楚的错误消息,不应该继续生成一份空报告。报告中可以保存运行时间、输入行数、规则版本、警告数量和输出路径。这样读者能区分“没有数据”“数据被全部拒绝”和“程序根本没有读到文件”。
一次冷启动是很有价值的验收:开一个新的终端,不依赖当前会话里已经存在的变量,按 README 创建环境、准备输入、运行命令、检查输出并回读 JSON。冷启动失败时把完整错误、定位过程和修复方式写下来。专业性不在于永远不失败,而在于失败可定位、可解释、可恢复。
| 案例 | 预期状态 | 要断言的事实 | 报告内容 |
|---|---|---|---|
| 正常输入 | 成功 | 行数对账、JSON 可回读、PNG 存在 | 输入、保留、输出数量 |
| 空表 | 按契约决定 | 不产生除零或伪造平均值 | 空输入的约定 |
| 缺列 | 停止 | 错误包含列名 | 缺失字段列表 |
| 违规行 | 拒绝或警告 | 原因可定位、原始值保留 | 每类数量 |
| 输出失败 | 停止 | 不报告整体成功 | 路径和权限提示 |
import json
from pathlib import Path
import pandas as pd
def make_sample():
return pd.DataFrame({
"topic": ["Python", "数据", "模型"],
"minutes": [30, -5, 45],
"score": [80, 90, 120],
})
def split_valid_rows(frame):
invalid = (
frame["minutes"].lt(0)
| frame["score"].lt(0)
| frame["score"].gt(100)
)
return frame.loc[~invalid].copy(), frame.loc[invalid].copy()
sample = make_sample()
clean, rejected = split_valid_rows(sample)
assert len(clean) + len(rejected) == len(sample)
assert sample.equals(make_sample())
assert rejected["score"].max() == 120
payload = {
"input_rows": len(sample),
"cleaned_rows": len(clean),
"rejected_rows": len(rejected),
}
path = Path("output/test-summary.json")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
loaded = json.loads(path.read_text(encoding="utf-8"))
assert loaded["input_rows"] == loaded["cleaned_rows"] + loaded["rejected_rows"]
print("invariants passed")不变量比固定的完整输出更稳定:即使样本数字变化,输入行数仍应等于保留与拒绝之和,JSON 仍应能回读,原始表仍应保持不变。测试失败时不要立即放宽断言,先确认断言对应的业务规则是否真的成立。
项目验收至少覆盖正常输入、空文件、缺列、负数、无法转换的数字、重复键和输出目录不可写。对每个案例写出预期退出状态和输出文件是否应该存在。测试不只是证明成功,也要证明失败时不会生成假报告。把一次失败命令和修复动作写在 README 或复盘中,未来才知道为什么采用当前规则。
最小测试矩阵
| 案例 | 输入变化 | 预期 | 检查输出 |
|---|---|---|---|
| 正常 | 三条合法记录 | 全部保留并生成汇总 | JSON 可回读,图表存在 |
| 空文件 | 只有表头 | 按规则停止或生成空报告 | 行为写在 README,不可含糊 |
| 缺列 | 删除 score | 验证阶段失败 | 提示缺少字段,不进入聚合 |
| 异常值 | 分钟为负数,分数为 120 | 进入拒绝表 | 拒绝原因和行数对账 |
| 编码 | 文件不是 UTF-8 | 读取失败 | 指出文件并给转换建议 |
| 输出 | 目标目录不可写 | 写入失败 | 不把读取成功误报为整体成功 |
import pandas as pd
sample = pd.DataFrame({
"topic": ["Python", "数据", "模型"],
"minutes": [30, -5, 45],
"score": [80, 90, 120],
"completed": [True, True, False],
})
from data_pipeline import aggregate, clean_input
clean, rejected = clean_input(sample)
assert len(clean) + len(rejected) == len(sample)
assert len(clean) == 1
assert len(rejected) == 2
assert aggregate(clean).loc[0, "topic"] == "Python"2.9.4 从表格数据走向模型数据
进入大模型前,先把文本变成可追溯记录
模型实验中的数据通常比学习记录更复杂:一份文档会被切成多个段落,一个问题可能有多个参考答案,一次模型调用会产生提示词、参数、响应、耗时和错误。不要因为对象变复杂就放弃表格契约。先定义一行代表什么,例如“一次文档分块”或“一次评测样本”,再列出来源、版本、内容长度、语言、许可和处理状态。
文本数据还要关注字符编码、换行、空白、重复段落和敏感信息。清洗时保留原文来源与段落编号,派生字段单独命名;如果要截断长度,记录截断前后的字符数和规则。模型输入不能只保存最终字符串,否则出现错误时无法判断是分块、清洗还是提示模板导致的。
训练、验证和测试的划分也属于数据处理。相同来源的近重复文本不能同时出现在训练和测试中,否则评估会过于乐观;按时间划分时要写清截止日期;随机划分时固定种子并保存索引。第二章只介绍这些前置原则,不在这里提前展开模型结构,避免把数据质量问题混在模型参数里。
| 字段 | 作用 | 检查 | 不应丢失 |
|---|---|---|---|
source_id | 回到原始文档 | 非空、稳定 | 来源和版本 |
chunk_id | 定位文本片段 | 同源内唯一 | 段落顺序、起止位置 |
text | 实际模型输入 | 长度、编码、空白 | 清洗前后长度 |
license | 说明使用边界 | 值可追溯 | 许可链接或来源文件 |
split | 训练/验证/测试 | 无泄漏、可复现 | 划分规则和随机种子 |
from pathlib import Path
import pandas as pd
documents = pd.DataFrame([
{
"source_id": "guide-01",
"version": "2026-08",
"paragraph": 1,
"text": "数组需要先确认 shape 和 dtype。",
"license": "internal-note",
},
{
"source_id": "guide-01",
"version": "2026-08",
"paragraph": 2,
"text": "聚合结果应同时报告数量。",
"license": "internal-note",
},
])
records = documents.copy()
records["chunk_id"] = (
records["source_id"] + ":" + records["paragraph"].astype(str)
)
records["char_count"] = records["text"].str.len()
records["split"] = "train"
records.loc[records["paragraph"] == 2, "split"] = "validation"
required = [
"source_id", "version", "paragraph", "text",
"license", "chunk_id", "char_count", "split",
]
assert set(required).issubset(records.columns)
assert records["chunk_id"].is_unique
assert records["char_count"].gt(0).all()
print(records[required])这个例子仍然是普通表格,不调用模型。等你在下一章或后续章节学习 Token、Embedding 和检索时,可以把同样的 source_id、chunk_id 和版本字段一路传下去。数据血缘保留下来,模型输出才有机会被定位、复核和更新。
用本章项目生成一份清洗表、拒绝表、汇总 JSON 和图表。随后复制项目到一个新的目录,按照 README 重新创建环境并运行。记录输入文件、依赖版本、行数对账、未匹配键、图表参数和一次失败运行。最后写一段迁移说明:如果把学习记录换成文档分块或模型评测样本,哪些字段和规则需要保留或改变。
评分重点仍然是职责是否清楚:读取、验证、清洗、聚合、绘图和保存可以分别测试;输出 JSON 能回读;图表包含单位和样本量;异常记录有原因;README 能让另一台机器完成冷启动。
迁移到模型数据时,至少保留来源、版本、粒度、许可、长度、划分和错误原因。不要因为进入模型学习就跳过数据契约;模型越复杂,越需要可追溯的输入。
完成这个项目后,数据处理的对象可以逐步换成模型评测表、文本分块清单、向量检索日志或训练指标。新对象会带来新的字段和规模,但基础问题不变:数据从哪里来,粒度是什么,哪些值缺失,如何验证合并,结果能否复现。进入 NumPy、Pandas 之外的工具时,也先用本章的契约和测试方法。
下一步学习大模型时,建议先把一份公开且许可清楚的文本转换成结构化记录:来源、标题、段落、字符数、语言、清洗状态和错误原因。暂时不调用模型,先让数据管道稳定;等数据可以追溯和回读,再学习向量、Embedding、Token 和检索。模型不会替你修复错误的输入,可靠的输入仍然是 Python 和数据处理的责任。
本章的完成标准是:你能建立虚拟环境,读懂数组的形状和轴,用 Pandas 检查表格,明确处理缺失和重复,验证合并关系,选择合适图表,保存可回读的结果,并能解释一次失败运行。做到这些,就可以进入数学直觉和机器学习,而不是只会把库函数拼在一起。
从零创建一个小项目:准备一份 CSV,至少包含 12 条学习记录,其中故意加入空值、重复标题、负数分钟和超范围分数。完成读取、契约验证、清洗、分组统计、柱状图和 JSON 报告。要求保存原始数据、清洗数据、拒绝数据、生成参数和一次可回读检查。
评分重点不是调用了多少个 Pandas 方法,而是粒度和职责是否清楚:读取函数不负责绘图,清洗函数返回保留与拒绝两张表,聚合函数同时返回样本量和指标,保存函数记录来源与规则。至少验证“输入行数 = 保留行数 + 拒绝行数”,验证 JSON 可以用 json.loads() 再次读取,并把图表的单位、样本量和排序规则写在报告中。
完成后做一次冷启动:打开新的终端,按 README 创建环境、运行帮助或入口命令、检查输出目录。若运行失败,保留完整错误和修复步骤;能解释失败比只展示成功截图更能证明数据管道可靠。