←返回大模型学习资料
学习资料第 2 章 · 数据处理与可视化

CHAPTER 02 · DATA WORKFLOW

数据
处理

Python 能够读写文件只是起点。真正进入大模型学习后,你还要面对一批形状固定或不固定的数据:表格、数组、文本统计、实验记录和模型指标。本章从 NumPy 的数组思维开始,过渡到 Pandas 的表格思维,再用清洗、分组、合并和可视化把一份“看起来像数据”的文件变成可以检查、解释和复现的样本。

难度:基础进阶建议:边读边运行依赖:Python 3.11+小节:28 张独立学习卡片
TEXTBOOK MAP · DATA WORKFLOW

本章学习地图:从数组到报告

先理解数据形状,再选择工具;先检查质量,再解释图表。

本章示例使用人工编写的小数据,不依赖真实用户资料;图示为本站绘制,外部文档只作为 API 和概念参考。

使用方式:本章每个主题都先说明数据形状和规则,再给出最小代码。请先预测输出,再运行;发现结果不符合预期时,优先检查 dtype、shape、索引、缺失值和来源,而不是立刻换工具。

01 · DATA MINDSET

先理解数据任务,再选择工具

数据处理不是把文件交给某个库就结束了。你需要先说清楚:一行代表什么,一列代表什么,哪些字段允许为空,哪些字段必须唯一,输出要回答什么问题。NumPy 适合处理同一种类型、形状规整的数值数组;Pandas 适合用列名和索引管理表格;Matplotlib 负责把已经整理好的结果变成可观察的图形。工具是表达规则的方式,规则本身仍然由你负责。

2.1.1 一行、一列和一个字段到底代表什么

数据血缘:知道每个数字从哪里来

当一个汇总数字被质疑时,最有价值的问题不是“哪个函数算的”,而是“它来自哪些原始行”。数据血缘就是给输入、清洗、聚合和输出之间建立可回溯关系。最小做法是保留稳定的 row_id 或 source_id,清洗时不丢掉原始索引,聚合时记录来源范围,报告中写入输入文件和规则版本。这样一条柱子的平均分可以回到具体的学习记录,而不是停在一张图片上。

版本信息也要分层记录:原始文件版本、数据字典版本、处理脚本版本和输出生成时间。文件名变化不等于内容变化,最好使用来源链接、日期或校验和辅助识别。学习阶段可以先把这些信息写进 JSON;后面学习版本控制和实验追踪时,再将它们接入更完整的工具。

表 2.1D · 一个数字的回溯路径
层次示例要留下的字段
原始记录CSV 第 17 行source、row_id、原始文本
规范化“ Python ”变成“Python”原值、新值、规则
清洗负分钟进入拒绝表reject_reason、规则版本
聚合主题 Python 的平均分group_key、valid_count
交付图表和 JSON输入、脚本、参数、时间
PYTHON · lineage_payload.py
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。展示给人的中文标题可以在最后一步生成,不要一开始就用难以在代码中复用的长中文列名。

表 2.1A · 从问题翻译成字段契约
业务问题建议字段类型与单位必须写出的边界
这次学习用了多久?minutes非负数,分钟是否允许 0,是否接受小数
这条记录属于什么主题?topic非空字符串空白、大小写、同义词如何统一
这次练习得到多少分?score0 到 100 的数缺失代表未评分还是 0 分
如何回到原始来源?source_id稳定字符串是否唯一,路径变化后是否仍可追踪
记录何时发生?recorded_at带时区的日期时间时区、格式和夏令时处理
PYTHON · record_contract.py
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)

这段代码不是完整的数据验证器,却示范了一个重要习惯:先把必需字段和基本范围写成可执行的规则。随着项目变大,可以把规则拆成函数,加入行号、字段名和原始值,让错误能够回到具体记录。验证越早发生,后面的聚合和绘图越容易解释;验证越晚,错误越可能已经被平均值和格式化输出掩盖。

易错点:“一列看起来都是数字”不等于它就是数值列。CSV 中的空字符串、百分号、千分位和带单位文本都会让类型判断变得不可靠;先查看原始样本和转换失败的行,再决定规则。

把表格想成一组记录,每条记录都有相同的字段。比如学习资料实验表可以包含 source、topic、minutes、score 和 completed。一行是一次学习记录,列是记录的属性;如果把“多个主题”塞进同一个单元格,后续统计就会变得困难。数据处理的第一步不是调用 groupby(),而是写出这种最小语义。

表 2.1 · 一个表格的基本契约
概念学习资料示例需要提前约定的规则不清楚会造成什么问题
记录 / 行一次资料学习或一次实验运行是否允许同一来源出现多次重复行被误当成更多样本
字段 / 列minutes 学习分钟数单位、类型、是否允许负数数字和带单位的字符串无法比较
键source_id 或文件路径是否唯一、是否可为空合并时一对多关系被悄悄放大
缺失没有填写分数缺失代表未知、未发生还是不适用把缺失直接填 0 改变结论
输出按主题的平均分和样本量排序、舍入、来源和生成时间不同版本的报告无法比较

2.1.2 安装数据工具并保持环境可复现

环境问题要分成三层排查

数据工具的安装问题通常不是某个库“坏了”,而是解释器、环境和项目路径没有对齐。第一层是确认正在使用哪个 Python:在终端运行版本命令,并用 sys.executable 打印解释器的绝对路径。第二层是确认包安装到了同一个解释器:使用 python -m pip show pandas,不要只看系统里是否存在一个名为 pip 的命令。第三层是确认编辑器或 Notebook 的内核没有偷偷切换到另一个环境。

虚拟环境的价值不是让代码变快,而是把本项目依赖和系统 Python 隔开。项目目录中可以保留创建环境的命令,但不应把 .venv 本身提交到版本库。依赖记录也要有层次:学习阶段可以先保存主要包的版本,交付时再锁定完整依赖。升级包之前先记录当前版本并运行最小测试,升级之后比较输出,而不是只看安装命令是否成功。

不同系统的激活命令不一样,路径分隔符也不一样。把 Windows、macOS 和 Linux 的命令分别写清楚,比复制一条在自己电脑上有效的命令更友好。PowerShell 如果阻止脚本执行,应先阅读错误提示和当前执行策略;不要为了省事把系统安全设置改成完全开放。无法联网时,可以先用标准库完成数据模型练习,等环境恢复后再安装第三方包。

表 2.1B · 环境诊断的证据顺序
要确认的事实命令或代码正常证据异常时先查什么
解释器版本python --version版本满足项目要求命令是否指向预期安装
解释器路径sys.executable路径位于项目环境编辑器内核是否不同
包安装位置python -m pip show pandasLocation 与当前环境一致是否误用了全局 pip
导入状态import pandas as pd能导入并打印版本包名和导入名是否不同
工作目录Path.cwd()输入输出路径可预测相对路径基准是否改变
PYTHON · environment_report.py
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;以后结果不一致时,先比较这些事实。

TERMINAL · 建立第二章实验环境
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 第一个可复现实验:把输入、处理和输出写在一起

数据契约也要能面对变化

真实项目中的字段会增加、改名或改变允许值。面对新版本输入,先比较列集合和数据字典,再决定兼容、迁移还是停止。新增可选列通常可以警告并继续;删除必需列、改变单位或改变主键含义必须停止。把版本号和变更说明写入输出清单,未来才能区分“数据变了”与“代码算错了”。

小实验可以模拟这种变化:复制一份三行数据,增加一个备注列、改掉一个列名、把分钟单位从分钟改成秒,观察哪些检查能够发现。练习的目标不是设计完美的版本系统,而是形成一个习惯:输入契约发生变化时,输出结论不能悄悄沿用旧解释。

表 2.3B · 面对输入变化的动作
变化默认动作需要更新
增加可选列警告并保留数据字典、输出列选择
删除必需列停止输入准备或契约版本
单位改变停止并确认转换公式、图表标签、报告
主键含义改变停止并迁移去重、合并和血缘规则

从需求到数据字典的完整走法

可以把一次数据任务拆成五个连续动作。第一步写问题:希望比较什么、观察什么、交付给谁;第二步写粒度:一行代表一次事件、一个文档片段还是一个聚合主题;第三步写字段:每列的名称、类型、单位、允许值和空值含义;第四步写验收:哪些事实必须成立,哪些情况要停止;第五步才是选择 NumPy、Pandas 或绘图库。这个顺序能让工具服务于问题,而不是让 API 决定问题。

数据字典可以是一张 Markdown 表,也可以是一份 JSON。它不需要一开始就非常复杂,但要能够指导代码和测试。字段名改变时,数据字典、验证函数和输出说明应一起更新;如果只改了代码里的字符串,读者会看到“程序运行成功”,却不知道结果是否仍然和原问题一致。为每个派生字段写出公式,例如 score_ratio = score / 100,并明确是在清洗前还是清洗后计算。

验收指标最好包含结构指标和业务指标。结构指标包括行数、列数、必需列、唯一键和缺失率;业务指标包括有效分数范围、主题数量、总分钟数和输出排序。结构正确但业务不合理时要停下来,业务数字看起来合理但结构变化时也要重新确认。通过这种双重检查,才不会把一个“格式正确的错误答案”交给后续章节。

表 2.3A · 任务说明到代码接口
说明层要写清楚代码中的落点验收证据
问题要比较或解释什么报告标题、函数文档读者能复述目标
粒度一行代表什么主键、分组键唯一性和行数对账
字段类型、单位、缺失转换、验证、列名dtypes、范围、缺失率
规则过滤、修复、聚合方式纯函数和配置边界案例测试
交付文件、排序、版本JSON、PNG、清单回读和冷启动
PYTHON · data_dictionary.py
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 框架,先理解“规则是数据的一部分”更重要。规则被保存后,未来把学习记录换成模型评测表时,只需重新写字段和约束,检查、报告和复现的方法仍然相同。

MINDSET LAB 02写一页数据字典

为自己的学习日志设计至少六个字段,包含一个主键、一个类别、两个数值、一个日期和一个状态。写出每列的类型、单位、空值含义、合法范围和示例值,再写三条必须停止的检查和三条可以警告的检查。最后用字典或 JSON 保存这份说明。

检查点不是字段越多越好,而是每个字段都能指导实现和验收。主键应能回到来源,数值字段要有单位和边界,日期要有格式和时区,状态要列出允许值。把数据字典提交后,任何新增列或规则变化都应留下更新记录。

一个实验要留下可追踪的四类证据

“能运行”只是数据实验的起点。可复现要求别人拿到同样的输入和规则,能够得到结构相同、含义一致的输出。第一类证据是输入快照:记录文件名、来源、编码、行数和生成时间;第二类证据是处理规则:写明过滤条件、转换方式、舍入方式和随机种子;第三类证据是输出契约:说明每个结果字段、排序规则和文件格式;第四类证据是检查结果:列出断言、警告和被拒绝记录的数量。

观察顺序也很重要。先看 shape,再看列名和类型,再看前几行、唯一值和缺失比例,最后才进行均值或排名。这样做不是形式主义,而是避免在“空表”“错列”“日期被当字符串”等情况下得到看似合理的结果。每一步都可以留下一个小输出,不必把整张表打印到屏幕上。

表 2.1C · 最小实验记录
阶段要回答的问题建议证据
输入数据来自哪里,有多少条?路径、来源、编码、行数、列名
规则哪些值会被转换或拒绝?函数、参数、阈值、随机种子
处理每一步改变了什么?前后 shape、缺失数、唯一键数
输出别人如何读取并验证结果?文件路径、格式、字段说明、校验和
PYTHON · inspect_frame.py
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。

PYTHON · reproducible_summary.py
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)
DATA MINDSET CHECK给一次实验留下证据

用三到五条人工记录完成一次小实验。不要只提交最终平均值,而是同时输出:输入行数、列名、每列类型、缺失数量、分组键数量、处理规则和 JSON 回读结果。故意删掉一列或把一个数字改成空字符串,观察哪一个检查最先发现问题,并把错误消息改写得让同学可以采取行动。

评分重点是证据链是否完整:输入事实、规则、变换前后形状和输出契约都能被复查。只打印一张漂亮的汇总表不算可复现;如果别人不知道原始行数、缺失处理和排序规则,就无法判断你的结论是否稳定。

一个最小的数据实验应当能回答四个问题:输入从哪里来,清洗规则是什么,输出有哪些字段,别人怎样重复运行。下面使用手写的三行记录,不连接网络,也不依赖隐藏文件。它的重点不是统计结论,而是把“数据准备”写成可观察的流水线。

PYTHON · first_data_experiment.py
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 应该是三行四列;分组结果只有两个主题。先看形状和列名,再解释平均分,不要只复制最后一行输出。
STARTER 2.1.3写出你的数据契约

准备一个包含“资料名、主题、阅读分钟、是否完成”的小表。先用自然语言写明每列类型、允许的空值和一行的含义,再用 Pandas 创建 DataFrame。故意把一个分钟数写成带空格的字符串,观察 dtype 和平均值计算如何变化。

契约至少要说明:分钟数是非负整数,完成状态是布尔值或明确的类别,资料名是否允许重复。遇到数字列混入字符串,不要直接把异常值变成 0;先用 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 转来的数组,确认列顺序没有改变;对从文本转来的数组,确认缺失值没有被当成字符串。数组本身只知道数值,不知道单位和来源,这些信息要由外部契约携带。

表 2.4D · 数值数组的可靠性检查
检查代码线索目的
类型范围dtype、min、max发现溢出和异常量级
浮点比较np.isclose()允许合理误差
缺失np.isnan()区分 NaN 和普通字符串
形状shape、size确认列顺序和样本数量
来源旁边的字典或清单补上单位和版本语义
PYTHON · numeric_safety.py
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,能在错误扩散前发现问题。

表 2.4A · ndarray 的结构词汇
属性回答的问题示例常见误解
ndim有多少条轴?二维表为 2不是元素总数
shape每条轴有多长?(2, 3)顺序不能随意交换
size总共有多少个位置?2 × 3 = 6不是内存字节数
dtype每个元素怎样存储?int64不等于业务单位
itemsize单个元素占多少字节?8 字节小 dtype 可能溢出
PYTHON · array_inspector.py
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 的自动推断。数组的属性是观察工具,不是装饰信息;把它们打印在每个实验的开头,能让后续输出有上下文。

检查习惯:每当数组从文件、DataFrame 或模型输出中来,先打印 shape 和 dtype。如果你无法用一句话解释每个轴,暂时不要进行 reshape 或矩阵乘法。

一维数组可以理解为一行数值,二维数组可以理解为矩形表格,更高维数组则像一叠表格。shape 描述每个轴的长度,ndim 是轴的数量,size 是所有元素的总数,dtype 说明元素采用什么数值类型。形状不是装饰信息,它决定索引方式、矩阵运算和广播能否成立。

PYTHON · array_basics.py
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(),再转型。转换前后应比较最小值、最大值和缺失情况。

表 2.4B · 创建函数与边界
方式意图终点规则先验证什么
zeros / ones / full初始化固定形状的缓冲区由 shape 决定默认 dtype 和填充值
arange整数索引或近似步长通常不含 stop步长符号和元素数量
linspace指定数量的连续采样默认包含 stopnum 是否包含起终点
array.astype显式改变元素类型不改变 shape截断、溢出、精度损失
asarray尽量复用已有数组不负责清洗值输入是否已经满足 dtype
PYTHON · dtype_choices.py
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() 适合生成规则输入。类型转换要有意识:整数转浮点通常是安全的,浮点转整数会截断小数,过小的整数类型还可能溢出。做模型特征时,保存原始值和转换规则,避免只剩一个无法解释的数组。

常用创建函数的选择

表 2.2 · NumPy 数组创建方式
函数返回什么适合场景边界提醒
np.array(data)由已有序列构成的数组把列表或嵌套列表交给数值工具嵌套长度不一致会形成意外对象或报错
np.zeros(shape)全为 0 的数组初始化累计量或掩码先确认默认浮点类型是否合适
np.arange(start, stop, step)等差序列索引、整数测试数据浮点步长可能产生边界误差
np.linspace(start, stop, num)指定数量的等距值绘图横轴和实验采样默认包含终点,和 range() 不同
array.astype(dtype)转换类型后的数组明确控制存储和计算类型转换可能截断、溢出或产生缺失
PYTHON · create_arrays.py
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 或轻量的结果类携带轴名称、指标名称和规则,避免在函数之间传递“裸数组”。

当数组进入模型或绘图时,检查顺序尤其重要。列顺序一旦改变,形状可能仍然相同,模型却会把分钟当成分数,把分数当成完成状态。把特征名称列表和数组列一一对应,并在保存或加载后断言名称和顺序一致,是低成本但很有效的保护。

PYTHON · named_features.py
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 和临时数组数量会影响大数据处理;把一个大数组复制三次可能比清晰的循环更昂贵。学习阶段先保证规则正确,再用规模化样本测量。

聚合时应把结果和元数据放在一起。除平均分外,保存有效计数、最小值、最大值或分位数;除归一化后的数组外,保存均值、标准差和缺失策略。这样模型或图表出现异常时,能够回到变换参数,而不是重新猜测数据被怎样缩放。

表 2.6A · 向量化与循环的取舍
任务优先方式原因需要额外验证
统一加减、比较、裁剪数组运算表达短、规则一致shape、dtype、广播方向
按轴统计sum/mean/count直接表达聚合语义axis、缺失、样本量
每行复杂分支先写清楚的循环或函数错误更容易定位输出顺序和副作用
大规模热点先测量再优化避免凭感觉优化结果一致和内存峰值
PYTHON · compare_vectorized.py
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() 比较浮点结果;浮点数不应简单用 == 判断完全相等。验证通过后,再根据数据量和维护成本决定保留哪一种实现。

PYTHON · aggregate_with_metadata.py
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() 会忽略缺失值,但样本量也随之改变。报告平均数时应该同时报告有效样本数,并说明缺失值被忽略、填补还是拒绝。否则两个看似相同的平均值可能来自完全不同的数据量。

表 2.4C · 聚合前后的形状
表达式输入 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)每行结果仍可沿列广播
PYTHON · axis_and_missing.py
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 是报告平均值时不可缺少的伴随信息。若一个主题只有一条有效记录,平均值的解释力度和有一百条记录时不同。向量化运算让计算表达清楚,却不会替你判断统计是否可靠;样本量、单位和缺失策略仍要由你写入输出。

PYTHON · vectorized_rule.py
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)
NUMPY REVIEW 02用形状解释一次计算

创建一个 (4, 3) 的成绩数组,其中至少包含一个缺失值。完成每个学习者的有效平均分、每次练习的有效平均分和有效样本数;再用 keepdims=True 计算每行中心化结果。提交时写出每个变量的 shape、缺失策略和一个不应使用普通 mean() 的理由。

行平均的结果通常是 (4,),列平均通常是 (3,),保留维度的行平均是 (4, 1)。如果缺失值会使普通平均值变成 NaN,应使用明确的忽略或填补规则,并把有效样本数一起输出;不能只用一个漂亮的平均值掩盖数据量变化。

数组运算通常可以一次作用于所有元素,不必手写逐项循环。两个形状相同的数组可以逐元素相加、相减或相乘;sum()、mean()、min() 和 max() 可以按整体或指定轴聚合。这里的“轴”不是抽象词:对形状为 (2, 3) 的数组,axis=0 通常得到每一列的结果,axis=1 通常得到每一行的结果。

PYTHON · vectorized_summary.py
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())

向量化写法通常更短,也能利用底层实现提高速度,但它不会自动替你检查业务含义。两个数组形状相同并不代表它们的元素一一对应;要确认行顺序、单位和样本来源。遇到广播或聚合结果不对时,先打印形状和一两个小样本,再检查公式。不要用“大数组跑完没有报错”证明结果正确。

NUMPY LAB 2.2.6用数组完成一次指标检查

创建一个形状为 (3, 4) 的分数数组,行表示三个学习者,列表示四次练习。分别计算每个学习者的平均分、每次练习的平均分和全体平均分;再用布尔掩码找出低于 60 的分数。写下每个结果的形状,并解释为什么它们不同。

行平均通常使用 axis=1,结果形状为 (3,);列平均使用 axis=0,结果形状为 (4,);整体平均是一个标量。掩码形状与原数组相同,应用后得到一维筛选结果。评分重点是轴的语义和形状解释,不是把所有结果打印得很长。

03 · SHAPE & INDEX

数组的形状、索引、切片与广播

很多数据错误并不是数值算错,而是“把正确的数放到了错误的位置”。NumPy 用下标、切片和布尔数组选择数据,用广播把兼容形状的数组组合起来。理解这些操作时,始终把数组画成一个小矩形:每个轴表示什么,每个位置代表什么,切片返回的是副本还是视图。

2.3.1 索引、切片与视图风险

切分数据时防止重叠和泄漏

训练、验证和测试的切分本质上也是索引问题。随机切分前要固定种子并保存索引;按时间切分要保证未来记录没有跑进训练集;按来源切分要避免同一文档的相邻片段出现在不同集合。NumPy 只执行你给的切片,不会检查样本是否重复,因此切分契约需要你自己写断言。

切分后检查三件事:各集合的形状和比例、稳定 ID 是否互斥、类别分布是否出现意外偏移。对小数据,打印交集和几个边界样本;对大数据,保存索引清单和计数摘要。不要在切分后再随机打乱而不记录顺序,因为复现和错误定位都会变得困难。

表 2.7B · 数据切分的索引证据
切分方式适合风险应保存
按位置已经排序的时间记录输入顺序不稳定起止位置和排序规则
按随机索引独立同分布的小样本种子变化、重复样本随机种子和索引
按来源文档、用户或设备分组来源键缺失分组键和互斥检查
按时间预测未来数据时区和边界日期截止时间和时区
PYTHON · split_indices.py
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()。当数据要传给另一个函数,而你不希望函数原地改变输入时,复制是一种清楚的接口承诺。

高级索引还包括整数数组索引和布尔索引。整数索引可以按任意顺序抽取位置,结果通常是副本;布尔索引要求掩码形状能够对应被选择的轴。高级索引很灵活,也更容易把行列顺序写错。初学阶段先用小数组打印下标和结果,再把同样的表达式放进真实数据。

表 2.7A · 常见索引写法
写法含义结果特点检查重点
a[2]取位置 2可能降一维是否真的要丢掉轴
a[1:4]取位置 1、2、3常为视图修改是否会回写
a[:, 0]取二维数组第一列结果通常一维列语义是否明确
a[[2, 0]]按指定顺序取两行常为副本顺序是否改变
a[a > 0]取满足条件的元素可能变成一维是否还需要原二维形状
PYTHON · indexing_matrix.py
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()。这和第一章讲过的列表别名问题很像,但在数值实验中更容易因为“只改了一个小窗口”而改变整个输入。

PYTHON · view_or_copy.py
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 不匹配。广播成功后也要回到业务语义,确认复用方向正是你想要的。

表 2.8A · 掩码与广播检查
问题正确做法错误做法为什么
两个条件同时成立(a > 0) & (a < 10)a > 0 and a < 10数组需要逐元素运算
取反~masknot masknot 只接受单个布尔值
保留原二维形状使用列掩码或 where直接 a[mask] 后忘记维度筛选结果可能被压平
按列加偏置偏置形状为 (列数,)把学习者数组误当列偏置广播方向改变含义
PYTHON · mask_and_broadcast_detail.py
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() 适合表达上下限裁剪;它们都不会自动告诉你有多少值被改变。做数据清洗时要同时统计命中掩码的数量,并保留原始值或拒绝表,避免把“修正”伪装成原始事实。

PYTHON · broadcast_guard.py
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)
先验证再广播:打印双方 shape,并在注释中写“这是按列复用”还是“按行复用”。广播没有报错,只能说明形状兼容,不能证明业务含义正确。

布尔筛选先得到一个与数组形状对应的真假数组,再用它选出满足条件的元素。组合条件时使用 & 和 |,每个条件都要加括号;Python 的 and 和 or 不能直接替代数组条件。广播允许一个形状较小的数组沿兼容轴重复使用,但“能广播”不代表“业务上应该广播”。

PYTHON · mask_and_broadcast.py
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 更能提醒读者第一维是什么;测试使用编号数组比随机数更容易发现排列错误。等数据进入模型后,还要保存特征名称、标准化参数和形状,确保推理阶段使用同样的列顺序。

表 2.9C · 形状契约的四句话
问题示例答案
一行代表什么?一个学习记录或一个样本
每一列代表什么?一个有名称和单位的特征
允许哪些特殊形状?空输入、单样本是否单独处理
变换后怎样证明没乱?shape、编号值、特征名称断言
PYTHON · require_shape.py
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 按行组织元素,转置只是改变视图的轴顺序;如果原始数组代表图像、时间序列或批量特征,排列顺序错误不会总是立即报错,却会让模型学到错误关系。用一个编号数组做小实验,观察每个元素从哪里移动,再处理真实数据。

表 2.9B · 形状错误排查表
症状先打印常见原因修复前必须确认
矩阵乘法维度不符左右两边 shape样本轴和特征轴交换乘法公式和轴语义
结果少了一维索引表达式、ndim整数索引或聚合降维是否需要保留长度 1 的轴
广播结果形状正确但数值错双方 shape、示例值沿错误方向复用每个偏置对应哪一列或哪一行
reshape 不报错但模型变差编号数组、样本顺序排列语义被打乱元素顺序和来源边界
PYTHON · shape_debugger.py
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 断言,删除无关的全部输出。

PYTHON · numbered_reshape.py
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)
SHAPE DEBUG 02用编号数组定位维度错误

用 1 到 24 的编号数组模拟一个批量数据,尝试构造“4 个样本、6 个特征”“2 个批次、4 个样本、3 个特征”两种形状。分别取一列、保留二维列、交换样本和特征轴,并为每一步写出一个你能检查的编号值。最后记录一次故意的错误 reshape 和它真正应该修复的原因。

重点不是把 24 个数变成任意形状,而是说明每个轴的语义和元素顺序。若目标形状元素乘积不等于 24,应该回到样本数或特征数的定义;若乘积相等但顺序改变,也要用编号数组确认没有把不同样本拼接在一起。

reshape 之前先写样本轴和特征轴

reshape() 只重新组织元素,不会增加或删除元素,因此目标形状的各维乘积必须等于原来的 size。但“元素数量正确”只是数学条件,不是业务条件。把 12 个数字从 (3, 4) 改成 (2, 6) 在语法上可行,若原来每行是一个学习者,新的每行可能把两个学习者拼在一起,结果就失去含义。每次 reshape 都要在注释中写出旧形状、新形状和轴的解释。

转置 .T 对二维数组交换行列,对高维数组会反转轴顺序;需要交换指定轴时使用 transpose()。flatten() 总是生成副本,ravel() 尽量返回视图,是否共享内存应视具体情况而定。为了把一列特征变成二维输入,可以使用 [:, np.newaxis] 或 reshape(-1, 1);-1 只允许在一个维度中出现,NumPy 会根据总元素数推断它。

在机器学习前处理里,最常见的形状是“样本数 × 特征数”。单个样本可能写成 (特征数,),一批样本写成 (批大小, 特征数)。这两个形状在矩阵乘法和模型接口中并不等价。建议在函数入口处断言二维、断言特征数,并在输出中记录样本数量;这样模型报错时可以回到数据变形这一步。

表 2.9A · 形状变换的选择
操作改变什么是否改变元素适合先检查的事实
reshape轴的组织方式不改变size 与目标乘积、轴语义
.T二维行列或高维轴顺序不改变后续矩阵乘法的方向
transpose按指定顺序重排轴不改变每个新轴对应哪个旧轴
flatten压成一维副本不改变是否允许丢失样本边界
expand_dims增加长度为 1 的轴不改变新增轴用于哪种广播
PYTHON · reshape_pipeline.py
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 传进去。形状图的作用是让这种差异可见。遇到结果维度突然多一条或少一条时,优先查看是否调用了一个会自动降维的索引。

SHAPE REVIEW 02把形状写成契约

创建一个 (5, 4) 的数组,表示 5 个样本和 4 个特征。分别得到第一列、第一列的二维版本、每个样本的均值和每个特征的均值;再用 reshape(-1, 1) 构造一个可广播的偏置。提交每一步的 shape,并说明哪一步如果误用 .T 会把样本轴和特征轴交换。

第一列通常是 (5,),二维列是 (5, 1);样本均值为 (5,),特征均值为 (4,)。关键不是背出答案,而是能解释每个轴的含义,并在函数入口用断言保护样本数和特征数。

reshape() 只改变组织方式,元素总数必须保持不变;转置会交换轴,二维数组常见写法是 .T。在进入机器学习模型前,经常需要把“样本数 × 特征数”的表格变成模型需要的形状。不要只靠报错猜形状,先打印每一步的 shape,并把“哪一维代表样本”写进注释。

图 2.1 · 形状变化的读法自制数组示意图
shape (6,)6 个连续元素reshape(2, 3)样本 1:□ □ □样本 2:□ □ □mean(axis=1)每行得到 1 个值

形状变化时,先写出轴的语义,再选择 reshape、转置或聚合;图中每个方框都对应一个可检查的数组维度。

SHAPE LAB 2.3.9画出你的数组

创建一个包含 12 个数的数组,分别变成 (3, 4) 和 (2, 2, 3)。访问第二个样本的第三个特征,计算每行平均值,再尝试把 10 个数 reshape 成 (3, 4),记录错误信息。最后说明为什么总元素数是形状约束。

每个目标形状的元素乘积都必须等于 12;(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" 只是一种策略,不是默认答案。

表 2.10A · Series 的观察顺序
对象要看什么示例风险
值顺序、范围、缺失s.head()把标签顺序当成业务顺序
索引类型、唯一性、是否有序s.index重复标签导致返回形状不稳定
类型数值、字符串、日期s.dtype字符串数字无法直接聚合
对齐两个 Series 是否共享标签a.align(b)按位置相加却得到按标签结果
PYTHON · series_alignment.py
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 的长度相同不代表它们会按位置相加。Pandas 默认尊重标签;如果你不想要标签对齐,就明确转换并记录原因。

Series 的值和索引同时存在。默认索引从 0 开始,也可以使用日期、文件名或业务 ID 作为索引。索引适合表达“这一行是谁”,但不应把索引当成永远唯一的主键;重复索引会让选择和合并变得复杂。初学时先使用简单整数索引,明确主键后再设置业务索引。

PYTHON · series_basics.py
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 适合表达表格,不能替你管理版本、许可和来源,这些元数据应放在伴随清单中。

表 2.11B · 交付前的 DataFrame 形状
动作什么时候做需检查
copy()建立独立清洗表原表是否保持不变
reset_index(drop=True)旧索引不再有意义业务 ID 是否已保留为列
显式列排序保存或交付前下游字段顺序和缺列
rename()统一字段语义旧名映射和 README
convert_dtypes()希望使用更合适的 Pandas 类型缺失和下游兼容性
PYTHON · dataframe_delivery.py
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_unique

DataFrame 是一组按索引对齐的列

可以把 DataFrame 想成多列 Series 拼成的矩形,但每一列的 dtype 可以不同。创建 DataFrame 时,字典的键通常成为列名,列表长度应当一致;从记录列表创建时,缺少字段的记录会产生缺失值。不要把“每行长度一样”当成完整契约,还要检查列名、单位、唯一键和类别取值。

列名是程序接口的一部分。读取文件后可以先去除首尾空格、统一大小写,再和必需列集合比较。重命名或新增列时,尽量使用一次性、可追踪的表达式;如果同一个列名被反复覆盖,读者很难判断它是原始值、清洗值还是派生值。需要保留原始数据时,先复制 DataFrame,或把新表命名为 cleaned、summary。

查看 dtype 时不要只看打印结果。日期、类别和字符串的处理方法不同,数字列混入一个“未知”字符串后,比较和平均都会失败。对类型转换记录成功数量和失败数量;对类别列先列出唯一值,检查拼写、大小写和同义词。info()、describe() 和 value_counts() 是互补工具,不能用一张统计表替代三者。

表 2.11A · DataFrame 初检清单
检查方法要回答的问题发现异常后的动作
形状df.shape行数和列数是否符合预期回到输入和筛选步骤
列名df.columns.tolist()必需列是否齐全,有无空格清理或更新契约
类型df.dtypes数值、日期、文本是否正确转换并保存失败记录
缺失df.isna().sum()哪些列空得最多按字段语义制定策略
类别df[col].value_counts()是否有拼写和大小写分裂先规范化再统计
PYTHON · dataframe_contract.py
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 选择和赋值;现在先形成习惯:每次修改列之前,知道原始表是否需要保留。

避免隐式修改:如果一个函数接收 DataFrame 并会新增或改写列,最好在函数开头使用 frame.copy(),并在函数名或文档中说明返回的是新表。

DataFrame 的每列可以有不同类型,但同一列最好保持语义一致。df.info() 适合快速查看非空数量和 dtype,df.describe() 适合观察数值列的范围,df.head() 和 df.sample() 适合抽查内容。不要只看前五行就相信数据质量;首行正常不代表中间没有空值、异常字符串或重复记录。

PYTHON · dataframe_profile.py
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(),不要把空值当成普通字符串。

赋值之前要考虑数据所有权。直接修改原表适合记录“这就是清洗后的对象”,复制后修改适合探索和对照。新增列时尽量使用有意义的名称,不要反复覆盖原始指标;若必须覆盖,要在报告中说明旧值不再保留。排序和重置索引会改变位置语义,之后如果要与另一张表按位置拼接,先确认这真的是你想要的关系。

表 2.12B · 一次安全选择的步骤
步骤示例输出证据
生成条件mask = df["score"].ge(80)命中行数、缺失处理
选择列df.loc[mask, columns]列名和顺序
明确所有权.copy()原表是否保持不变
派生字段subset["level"] = ...公式和单位
排序交付sort_values(...).reset_index()排序键和新索引
PYTHON · safe_selection.py
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 和索引,确保后面的代码没有依赖旧位置。

表 2.12A · 选择方式对照
需求推荐写法返回的语义注意
按条件选行df.loc[df["score"] >= 80]满足条件的记录条件括号和缺失值
按标签选列df.loc[:, ["topic", "score"]]指定列的新视图或副本列名拼写和顺序
按位置选行列df.iloc[0:3, 1:3]位置范围索引标签不参与判断
条件赋值df.loc[mask, "level"] = "达标"有意修改原表mask 与行索引对齐
安全独立筛选df.loc[mask].copy()可独立修改的表复制的成本和所有权
PYTHON · loc_iloc_rules.py
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:它先明确复制,再在副本上加分,所以不会悄悄改变原表。这个例子中的加分只是演示赋值,不代表真实评分规则;在实际项目中,所有改变原始指标的操作都要写进数据契约和报告。

PANDAS REVIEW 02用标签完成一次安全筛选

创建一个至少八行的 DataFrame,包含主题、日期、分钟数、分数和完成状态。用 loc 筛选已完成且分数不低于 80 的记录,用 iloc 取前两行,再新增一个等级列。故意把索引改成字符串后重复运行,写下 loc 和 iloc 的区别,并验证原表与独立子表是否互相影响。

答案不在某个固定数字,而在选择语义:条件筛选使用列名和布尔表达式,位置切片使用 iloc;需要修改原表时用 df.loc[mask, column] = value,需要独立实验时用 .copy()。提交时同时展示索引、shape 和一条修改前后的记录。

选列可以使用 df["topic"] 或 df[["topic", "minutes"]];按条件过滤时,先构造布尔掩码,再把它放入 DataFrame。修改筛选结果时,使用 .loc[条件, 列名] = 值 能减少链式赋值歧义。计算新列时,先写出单位和公式,再执行赋值,并立刻抽查几行。

loc、iloc 和布尔掩码的任务分工

表 2.3 · Pandas 选择方式
写法依据适合场景易错点
df["score"]单列名称取一列继续计算结果是 Series,不是二维表
df[["a", "b"]]列名列表保留多列并维持表形状少一层方括号会改变返回类型
df.loc[rows, cols]标签按列名和条件更新条件组合要加括号
df.iloc[rows, cols]整数位置按位置切片或快速抽查位置从 0 开始,终点通常不含
df[mask]布尔数组筛选满足条件的行掩码长度和索引必须对齐
PYTHON · select_update.py
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)
PANDAS LAB 2.4.12用列名说清筛选规则

创建一个至少五行的 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。

表 2.13A · 文件读取前的确认
事实检查方式影响不要直接假设
路径Path.exists()、Path.resolve()能否找到同一份文件当前目录永远不变
编码文件说明或小样本检测中文和特殊字符能否解码所有 CSV 都是 UTF-8
分隔符查看首行、测试解析列是否被正确拆开逗号一定是分隔符
缺失标记列出空字符串和特殊词是否读成 NaN字符 “0” 代表缺失
JSON 顶层type(json.load(...))记录列表如何定位每个 JSON 都是一组行
PYTHON · read_inputs.py
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)

代码把“文件不存在”作为明确错误,而不是返回一个空表继续运行。空表和缺文件的意义不同:空表可能是一次合法的零记录输入,缺文件通常表示路径或准备步骤错误。把这两种情况区分开,后续报告才不会把“没有读到数据”误报为“数据量为零”。

路径习惯:项目从 README 指定的根目录启动,或者让入口函数显式接收根目录;不要把个人电脑上的绝对路径写进代码和示例。

CSV 适合矩形表格,JSON 适合嵌套记录;选择格式时要看下游是否需要保留层级。路径使用 Path,读取时明确编码;CSV 的分隔符、引号和缺失标记也应记录。导入成功后先打印文件名、行数、列名和前几行,不要立即计算复杂指标。

PYTHON · load_sources.py
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 会改变统计分布,把所有缺失都删除又可能丢掉重要的未完成记录。应该按列写策略,并在输出中报告处理了多少行。

表 2.14A · 常见转换策略
原始值转换目标先做的操作失败时的证据
" 35 "数值 35去空白再转换保留原文本和行号
"1,200"数值 1200约定千分位规则记录被替换的字符
"82%"比例或 82 分明确量纲和除以 100 规则不要让单位悄悄改变
"2026-08-27"日期时间指定格式和时区记录无法解析的文本
"" / "NA"缺失统一缺失词按字段语义选择填补或拒绝
PYTHON · normalize_columns.py
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。名称、单位和转换代码必须一致,否则后面的图表会给出一个数值正确但含义错误的结果。

不要隐式填补:填 0、填均值、向前填充和删除整行代表不同的假设。先写“缺失意味着什么”,再选择方法,并报告受影响的记录数。

外部文件中的所有字段本质上都是文本或某种序列化形式。日期可能写成 2026/08/27、2026-08-27 或带时区的时间;数字可能包含千位分隔符、百分号或空字符串。使用 pd.to_datetime()、pd.to_numeric() 等转换函数时,先选择错误策略,再检查转换失败的数量。

PYTHON · normalize_columns.py
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。

表 2.15B · 小文件与大文件读取策略
规模或格式读取方式需要保留常见风险
小型 CSV完整读取后检查路径、编码、列名、行数把空文件当缺文件
大型 CSV分块读取并逐块验证块编号、累计行数、错误数只检查第一块
嵌套 JSON先判断顶层结构再展开记录路径和展开规则一对象被误当一行
JSONL逐行解析、保留行号坏行和解析错误失败行被静默跳过
PYTHON · chunk_contract.py
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 的数”就具有行动性。

表 2.15A · 契约检查的三层
层次例子检查方法失败后的处理
列必需列、dtype、单位issubset、类型检查停止并指出缺失或冲突
行非负分钟、分数范围布尔掩码和行号拒绝、修复或人工确认
键source_id 唯一is_unique、重复计数阻止错误合并
关系主题表一对多事件表连接验证和行数对账使用 validate 或停止
输出JSON 字段和图表路径回读、文件存在性不报告伪成功
PYTHON · validate_contract.py
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,或返回一张检查报告,记录每个规则命中的数量。无论采用哪种表示,必须能回答“输入有多少行、保留多少行、拒绝多少行、为什么拒绝”。

INGEST REVIEW 02把规则变成可执行检查

为一份包含 source_id、topic、minutes、score 的表写出契约。准备一个正常样本和至少四个违规样本,分别覆盖缺列、空主题、负分钟、超范围分数和重复键。函数返回通过表、拒绝表和问题列表;提交时展示每条违规样本被哪一条规则捕获。

评分重点是规则和证据的对应关系。列级错误应阻止后续处理;行级错误应保留原始值和原因;重复键要说明是整行拒绝还是先聚合。最后用断言检查行数对账,并把检查结果写进 JSON 或终端摘要。

数据契约不是复杂的数据库规范,而是一份可以被程序检查的承诺:必需列存在、行数在合理范围、主键不为空、数值位于可解释区间、类别值没有意外拼写。把这些检查写成函数,导入阶段失败就停止,不要等到绘图时才发现列名拼错。

一个可复用的导入检查函数

PYTHON · validate_table.py
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,分别检查缺列、空表、负数和正常输入。错误消息包含字段名和下一步,调用者才知道应回到原始文件还是修改规则。

INGEST LAB 2.5.15设计一份导入清单

为一份学习记录表写出至少六项检查:必需列、编码、行数、日期范围、分钟数范围、主键重复和缺失比例。把检查分为“必须停止”和“可以警告”两类,准备一个正常表和三个故意违规的小表。

缺少必需列、文件无法解码和关键数值无法转换通常应停止;某个可选备注列有少量缺失可以警告。每条检查都要说明证据、阈值和行动,不要只写“数据质量不好”。如果字段规则尚未确定,先把“待确认”写入契约,而不是用任意默认值填满。

06 · CLEANING

数据清洗:每一次修复都要留下理由

清洗不是把所有“不好看”的值删除,而是在明确目标和损失后,把原始数据转换成可分析的数据。缺失、重复、类型错误和异常值的处理方式都依赖任务:用于展示的表可能需要保留原文,用于模型训练的特征可能需要统一尺度。保留原始文件、清洗规则和变化数量,是让结果可追溯的基本要求。

2.6.1 缺失值:未知、不适用和零不是一回事

先给缺失值命名,再决定怎样处理

缺失不是一种单一状态。一个人没有填写分数,可能代表还未评分;某个字段对这类记录不适用;导入时解析失败;或者原始系统根本没有收集这项信息。它们在业务上不同,却常常都被显示成空白。处理缺失前,先为字段写出状态含义,至少区分“未知”“不适用”“没有发生”和“转换失败”。

删除缺失行很方便,但会改变样本构成。填充 0 会把未知变成一个具体的数;用均值填充会压低方差;向前填充适合某些按时间持续的状态,却不适合独立学习记录。没有一种方法对所有列都正确。更稳妥的做法是保留原始列、增加缺失指示列,并在报告中统计每种处理影响了多少行。

缺失还会影响分组和图表。Pandas 的聚合通常会忽略缺失数值,导致不同主题的有效样本数不同;分类列的缺失可能在分组时被排除,除非明确设置保留策略。看到平均值时同时看 count;看到趋势断点时确认是否因为某几天没有记录。数据少时,逐行查看比自动填补更有价值。

表 2.16A · 缺失的语义与动作
缺失含义例子可选动作不能直接做什么
未知尚未填写分数保留缺失、单独统计直接当 0 分
不适用某类资料没有截止日期使用明确类别或标记与真正日期混合比较
没有发生未完成任务的耗时按业务规则设为 0 或单独状态悄悄用均值替代
转换失败"unknown" 无法转数字保留原文和拒绝原因吞掉错误继续聚合
系统漏采某一周没有上传记录区分零记录与零值画出不存在的连续趋势
PYTHON · missing_report.py
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) 前,先问这个字段的零是否有实际含义。填补均值也不是中立操作,它会改变分布和方差。

PYTHON · missing_values.py
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 参数,不是业务判断。

去重会改变计数和平均值,所以应该在清洗报告里记录去重前后行数、重复键数量和保留策略。对事件流或学习记录,简单删除可能把真实的多次行为误删;对维度表,重复键会让合并后的行数放大。先用分组查看重复样本,再决定动作,避免一行代码把问题隐藏起来。

表 2.17A · 重复检查的层级
层级判断依据可能含义建议动作
完全重复全部列都相同重复导出或重复提交保留一条并记录数量
键重复主键列相同维度表违反唯一性停止合并或人工确认
标题重复标题相同、来源不同同名资料或不同版本保留来源字段,不按标题删除
事件重复来源、时间、事件相同重复上报按事件契约判断
近似重复文本相似但不完全相同版本更新或格式差异本章暂不自动删除
PYTHON · deduplicate.py
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、保留哪一行以及是否先排序。不要看到行数减少就认为清洗成功。

重复检查要同时保留证据

表 2.4 · 重复记录的判断层次
层次示例规则可执行检查需要记录的决定
完全重复所有字段都相同df.duplicated()保留第一条还是最后一条
业务键重复source_id + dateduplicated(subset=[...])重复是错误还是多次事件
规范化重复标题去空格并忽略大小写后相同先生成规范化键再检查是否会合并不同版本内容
近似重复拼写或标点只有小差异需要相似度工具和人工复核阈值、误合并风险和审核记录
PYTHON · duplicate_report.py
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 文件,而不是依赖清洗表中的猜测。

清洗也应尽可能可重复和可回滚。不要在原始文件上直接覆盖,不要把临时手工修改混入脚本之外;每次运行使用同样的规则得到同样的结果,并把规则版本写入报告。若规则改变,保留旧输出或标明版本,避免把不同口径的结果放在同一目录里却无法区分。

表 2.18B · 清洗步骤的依赖关系
步骤依赖产物审计字段
列名规范化读取成功统一列名的表旧列名到新列名映射
类型转换列名稳定数值、日期、字符串列失败原值、转换数量
缺失处理类型可判断缺失标记或策略结果字段、原因、行数
重复检查主键已定义保留表和审查表键、保留规则
范围关系类型和单位稳定通过表和拒绝表规则、原值、行号
PYTHON · cleaning_audit.py
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;先掌握这种“原始—处理—审计”的分离,再考虑更复杂的数据质量框架。

CLEANING LAB 02画出你的清洗流水线

用箭头画出从 raw 到 clean、rejected、audit 和 summary 的关系,并为每一步写一个输入、一个输出和一个不变量。实现后故意修改一条规则,比较两次输出的行数和拒绝原因,说明为什么需要版本或运行清单。

合格答案应能追踪每一行的去向:保留行进入 clean,拒绝行进入 rejected,原始值和原因进入 audit,summary 只从 clean 计算。规则变化要在报告中可见,不能覆盖旧结果后只留下一个无法解释的数字。

异常值要有规则、证据和去向

类型正确不代表值正确。分钟列可以是数值,却出现负数;分数可以是浮点,却出现 120;日期可以成功解析,却落在项目开始之前。范围检查要从业务契约来,而不是从样本的最小值和最大值倒推。样本本身可能已经被错误值污染,使用它自动生成边界会把异常合法化。

异常值的处理至少有四种去向:拒绝并保留原因、修复并记录原值、截断到允许范围、保留但在统计中单独标记。选择取决于错误来源和问题目标。测试数据里可以故意制造异常,观察每条规则是否命中;真实数据里不要因为“看起来离群”就自动删除,先检查单位、重复导入和测量过程。

统计上的离群检测,例如四分位距规则,只能描述分布中的极端位置,不能证明它是错误。一个高分可能是真实进步,一个很长的学习时长可能是跨夜计时。将统计提示和业务拒绝分开:前者提醒人工查看,后者依据契约阻止下游计算。报告中同时写出阈值和被影响的行。

表 2.18A · 异常值决策
发现可能原因动作必须保留的证据
负分钟符号错误、列错位拒绝并回看原始行原值、行号、规则
分数超过 100百分比未转换或量表不同确认单位后修复或拒绝量表、转换公式
极端长时长计时未停止或真实长任务标记并人工复核上下文和确认结果
日期在范围外时区、格式或导入错误解析后检查项目边界原文本和时区
类别拼写分裂空格、大小写、同义词规范化并记录映射原值到标准值映射表
PYTHON · quality_rules.py
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"]])

循环在这里不是为了把所有逻辑塞在一起,而是让规则表逐条写出。若一行同时违反多条规则,示例保留第一条原因;你也可以把原因列表连接起来,但要在报告中说明计数方式。无论怎样,最终都应满足输入行数等于保留行数与拒绝行数之和。

CLEAN REVIEW 02制作一份异常记录表

准备至少 10 行数据,故意加入负数、超范围值、不可转换字符串、空主题、完全重复行和键重复行。先输出质量报告,再分别生成清洗表、拒绝表和重复审查表。对每条规则写明是停止、警告、修复还是保留;不要只输出“删除了几行”。

评分重点是缺失、重复和异常值没有被混成一条“清洗”命令:每类问题都有自己的语义、证据和去向。提交时检查行数对账、原始数据未被覆盖、拒绝原因可读,并用一个真实或人工确认的案例说明为什么选择该策略。

转换成功并不代表值合理。字符串 "9999" 可以被转换成整数,但如果分数范围是 0 到 100,它仍然是异常值。清洗函数最好按顺序执行:复制原始数据、规范空白、转换类型、检查范围、记录失败、输出新表。不要在同一个循环里同时覆盖原列、打印错误和删除行,这会让问题难以复盘。

PYTHON · clean_records.py
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 两份结果,并让调用者知道清洗损失了多少记录。大模型语料清洗时还要考虑版权、个人信息、提示注入和原文回溯,不应只用一个正则表达式宣称数据已经安全。

CLEAN LAB 2.6.18写一份清洗前后对账

准备 8 行包含空白、重复、空值、负数和超范围分数的记录。完成清洗后输出:原始行数、保留行数、拒绝行数、每种拒绝原因的数量,以及一份拒绝记录表。要求原始 DataFrame 不被原地修改。

可以为每种规则建立布尔列或错误原因列表,再统一生成拒绝报告。原始行数必须等于保留行数加拒绝行数;若一行同时违反两条规则,要明确是计为两种原因还是只保留第一条。验收重点是对账关系、原始数据不变和规则可解释。

07 · TRANSFORM

分组、合并与形状变换:让表回答问题

清洗后的表还不是结论。你需要根据问题选择分组、聚合、排序、合并或透视。每种变换都会改变行数、列数或粒度,最危险的错误是结果仍然“像一张表”,但含义已经变了。操作前先写一句粒度说明:一行代表一个人、一个主题、一天,还是一次实验。

2.7.1 groupby 与聚合:同时报告数量和数值

聚合的第一步是定义结果的一行代表什么

groupby() 会按照一个或多个键把记录分组,但它不会替你决定每个结果字段的意义。按主题分组后,一行可能代表一个主题,也可能代表“主题 + 周次”的组合;mean 可能是有效分数的平均,size 是所有记录的数量,count 只统计非缺失值。写聚合前先完成一句话:“输出表的每一行代表……,每一列用于回答……”。

建议使用命名聚合,把输出列名、输入列和函数写在同一个位置。这样比先分组再得到多层列名更容易阅读,也方便后续保存 JSON。聚合结果应同时报告样本量、有效数值数量和指标;只保留平均值会让小样本和大样本看起来一样可靠。

分组键的清洗也会改变结果。"Python"、" Python " 和 "python" 如果没有规范化,就会产生三个组。日期的时区和周起始日也会改变分组边界。聚合前打印唯一值和分组数量,聚合后检查总记录数是否仍能对账。空分组、缺失键和排序规则都要在报告中说明。

表 2.19A · 聚合函数的区别
函数统计什么缺失值影响适合回答
size组内所有行数包含缺失行收到多少条记录
count某列非空值数忽略该列缺失有多少个有效分数
sum数值总和需明确空组处理总学习分钟
mean有效值平均通常忽略缺失平均分或平均时长
nunique不同值数量可选择是否计缺失不同来源或不同资料数
PYTHON · named_aggregation.py
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 分记录,不应被当作同等证据。输出字段名要避免多层索引难以阅读,必要时使用命名聚合。

PYTHON · group_and_aggregate.py
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 而不做解释。

表 2.20A · 连接关系与验证
关系典型场景validate最重要的检查
一对一资料 ID 与资料元数据one_to_one两侧键都唯一
多对一多条事件连接主题字典many_to_one右表键唯一、左表行数不应无故增加
一对多主题连接其子资料one_to_many左表键唯一、展开倍数可解释
多对多标签与资料的中间关系谨慎使用预估笛卡尔积规模并单独建关系表
PYTHON · merge_with_checks.py
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 参数可以把预期关系写进代码,让关系不符合时立即失败。

PYTHON · merge_with_contract.py
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”把所有数据都透视成宽表,也不要为了调用一个函数把明确的关系变成难以阅读的嵌套对象。

表 2.21C · 变换的可逆性
操作可能丢失的信息如何保留线索
过滤被排除的行拒绝表、过滤规则、行数
聚合组内明细样本量、来源键、原始表
透视重复组合的明细聚合函数、重复审计表
重置索引旧位置标签稳定 ID 或显式来源列
多对多连接关系倍增中间关系表、基数和行数
PYTHON · relation_table.py
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()

关系表的设计让一个资料拥有多个标签,同时保留每条关联的独立行。以后需要按标签统计时,可以直接分组;需要展示资料时,再连接标题。每一步关系都可检查,避免在一个单元格里存列表后无法查询。

TRANSFORM LAB 03为变换建立回滚点

选择一张包含重复键的事件表,先保存原始快照,再完成过滤、聚合、透视和连接。每一步写出哪些信息被保留、哪些信息被压缩,保存一份行数与键数量清单。最后尝试从汇总表回到明细,说明哪些信息已经不能恢复,以及为什么必须保留原始表。

高质量答案会把聚合造成的信息损失说清楚:平均值不能恢复每条明细,透视后的重复键也需要原始关系或审计表补回。保留回滚点不是为了让流程更复杂,而是为了让结论可核查。

变换之后要重新定义主键和粒度

每次 melt()、pivot()、concat() 或连接操作之后,都应该重新写出“一行代表什么”。宽表转成长表通常增加行数,长表透视回宽表通常减少行数但增加列;这两个变化本身都不是好坏判断。关键是变换后的联合键、排序和缺失含义是否仍然符合下一步任务。

拼接表格时要区分纵向追加和横向对齐。concat(axis=0) 适合多批相同字段的记录,要求列名和单位一致;concat(axis=1) 按索引对齐,索引不一致会产生缺失,不能当成“把两张表并排贴上”。横向拼接前确认索引代表同一粒度,必要时先用稳定键连接。

透视和排序常常需要辅助字段。显示名称可以保留中文,排序键可以是整数或日期;聚合值保留原始精度,展示时再格式化。输出给模型或其他程序时尽量使用平坦、稳定的列名,避免多层列索引在序列化和后续读取时变成难以处理的字符串。

表 2.21B · 变换后的三项复核
复核项问题示例证据
粒度一行代表什么?主题 + 周次 + 指标
键哪组列应该唯一?duplicated(subset=...)
行数行数改变是否符合预期?输入输出 shape、展开倍数
缺失空白代表无记录还是零?填充值和规则说明
顺序展示和计算使用什么排序?整数周次、日期、显式 sort
PYTHON · transform_audit.py
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() 当成“自动修复重复”的按钮。每次变换后都保留一行审计输出,复杂流程才不会变成不可解释的黑箱。

结构自检:每个 reshape 或 concat 后至少检查三件事:粒度、联合键、行数变化。任何一项说不清,都先停下来,不要继续画图或训练模型。

表格形状应该服从下一步问题

宽表把多个变量放在列中,例如 week_1、week_2;长表把变量名称和值拆成行,通常包含一个标识列、一个变量列和一个数值列。两种形状没有绝对的优劣:宽表便于人阅读和某些矩阵运算,长表便于按类别筛选、分组和绘图。先写下一步需要什么粒度,再选择 melt()、pivot() 或 pivot_table()。

pivot() 要求索引和列的组合唯一,重复组合会直接报错;pivot_table() 可以通过聚合函数处理重复值,但它不会告诉你该聚合是否符合业务。使用透视前先统计重复的索引—列组合,决定是去重、求和、取最后一次还是保留多条记录。填充缺失值也要说明“没有记录”和“值为零”的区别。

形状转换常常是为图表或模型准备数据。转换完成后要重新写出一行的含义、列的单位和排序规则。不要因为表格看起来规整,就跳过唯一键和缺失检查;长表中的重复变量组合会在绘图时生成重复线段,宽表中的隐藏空列会让模型输入列数变化。

表 2.21A · 形状选择
形状一行代表适合先检查
宽表一个主题及其多个测量列人类阅读、矩阵输入列名是否包含变量和单位
长表一个主题的一次变量测量分组、过滤、绘图标识 + 变量组合是否唯一
透视表一个索引与多个列组合报告展示、交叉表重复组合的聚合规则
中间关系表两个实体的一次关联多对多关系联合键和重复关系
PYTHON · reshape_for_chart.py
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 前面。展示名称和排序键可以分离,这种小小的额外列常常能避免图表顺序错误。透视后的报告如果要交给其他程序,最好把列名和填充值写入输出契约。

TRANSFORM REVIEW 02追踪一次形状与行数变化

准备一张包含主题、周次、分钟数和分数的长表,再把它透视成宽表。故意加入同一主题同一周的两条记录,分别尝试 pivot() 和 pivot_table()。记录报错或聚合结果,说明你选择的汇总规则如何改变一行的含义,并画出转换前后的列关系。

关键检查包括:长表联合键是否唯一、重复值如何聚合、填充 0 是否代表真实零值、转换后列顺序是否稳定。提交前写出每一步的输入输出 shape 和一行的语义,不要只展示最终表。

宽表把不同类别放在多个列中,长表把类别和值拆成两列。绘图和分组通常更喜欢长表,因为一个变量可以通过类别列筛选;展示给人时,宽表可能更直观。melt()、pivot_table() 和 sort_values() 只改变表示方式,不会自动修复重复键或缺失值。

先写目标粒度,再选择变换

PYTHON · reshape_for_chart.py
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 之一。

TRANSFORM LAB 2.7.21追踪一次行数变化

准备一张事件表、一张主题表和一张周次表。分别完成分组、左连接和长宽转换,每一步记录输入行数、输出行数、唯一键数量和未匹配数量。故意在右表加入重复键,观察 validate="many_to_one" 怎样保护你。

每次变换前后都打印或记录形状和唯一键数;左连接后行数通常不应因为右表唯一键而增加,若增加先检查关系。透视前要处理同一索引和列组合的重复值,选择 aggfunc 不是为了消除报错,而是明确重复值如何汇总。

08 · VISUALIZE

Matplotlib:先选问题,再画图

图表不是数据的装饰,而是把比较、趋势、分布或关系变得可见。画图前先问问题:要比较类别,观察时间变化,查看数值分布,还是寻找两个变量的关系?图表必须标出单位、样本量和数据来源;否则读者看到的只是形状,不知道形状代表什么。

2.8.1 图表类型与问题类型

先写句子,再选择图形

图表选择可以从一个句子开始:“我想比较不同主题的记录数量”“我想观察每周平均分的变化”“我想了解学习时长的分布”或“我想看看分钟数和分数是否一起变化”。句子的动词通常暗示图表:比较对应柱状图,变化对应折线图,分布对应直方图,关系对应散点图。先写问题能避免为了展示某个漂亮模板而改变问题。

图表中的视觉编码也有优先级。位置和长度通常比面积、角度和颜色更容易比较,所以类别比较优先使用排序柱状图;类别很多时可以使用横向条形图并缩短标签。折线图连接的是有顺序的观测,不能把没有时间顺序的类别随意连起来。散点图中的一个点要有清楚的记录粒度,重叠点很多时应考虑透明度或计数,而不是直接得出“没有关系”。

图表准备数据时要避免把聚合和绘图混在一起。先生成一张列名清楚、单位明确的 summary 表,再把它交给绘图函数。绘图函数不应该偷偷删除缺失值或改变排序,否则同一张 summary 无法复用于 JSON 报告。图表标题、坐标轴、图例和注释分别回答“这是什么、单位是什么、颜色代表什么、需要注意什么”。

表 2.22A · 从问题选择图表
问题动词图表位置编码常见误读
比较多少排序柱状图柱顶高度纵轴截断夸大差异
观察变化折线图连续点的位置和连线把缺失期间连成真实趋势
了解分布直方图或箱线图区间计数或四分位数箱宽改变后结论改变
发现关系散点图两个轴上的点相关性被说成因果
展示部分构成堆叠条形图同一基线上的长度类别过多难以比较
PYTHON · chart_question.py
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.5 · 问题到图表的选择
问题优先图表需要标注容易误读的地方
哪个主题记录更多?排序柱状图类别、计数、排序规则类别太多导致标签重叠
分数随周次怎样变化?折线图周次、单位、缺失周用线连接不存在的中间数据
分钟数分布如何?直方图箱宽、样本量、单位箱宽改变后视觉结论改变
时间和分数有关吗?散点图每点代表的记录、相关性边界相关不等于因果,重叠点可能被忽略

2.8.2 Figure、Axes 和保存文件

Figure 管画布,Axes 管坐标系

Matplotlib 的对象接口把职责拆开:Figure 是整张画布,可以包含一个或多个 Axes;Axes 负责数据、坐标轴、标题、图例和网格。显式写出 fig, ax = plt.subplots() 后,读者能看到样式和数据都作用于哪一个坐标系。使用全局状态接口并非永远错误,但在生成多张图或写成函数时容易把上一张图的设置带进来。

图形尺寸用英寸指定,分辨率用 dpi 指定;屏幕预览和报告打印需要的参数可能不同。tight_layout() 可以减少标签被裁切的机会,但它不能替你检查中文字体、长标题和极端刻度。保存前先创建输出目录,保存后检查文件存在和大小;如果报告需要再次生成,保留代码、输入 summary 和参数,不要只保存 PNG。

颜色应当有目的:同一类别在多张图中保持同色,连续数值使用有顺序的色带,强调色只给真正需要读者注意的对象。考虑黑白打印、色觉差异和低分辨率屏幕,不能只依赖颜色传递信息。标签中的单位、日期格式和小数位要统一,避免一张图显示“分钟”,另一张图显示“小时”却没有说明。

表 2.23A · 一张可交付图表的组成
对象回答的问题代码位置验收
Figure画布多大,输出到哪里plt.subplots(figsize=...)不裁切、不覆盖旧结果
Axes数据如何编码ax.bar/plot/scatter每个元素粒度明确
标题图表在回答什么ax.set_title()包含主题和范围
标签轴的单位是什么set_xlabel/set_ylabel单位和刻度一致
保存别人怎样复现fig.savefig()代码、数据、参数可追溯
PYTHON · plot_artifact.py
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 是样本量提示,不应替代数据表。若数据中有缺失或拒绝记录,图题或注释还应说明统计口径。

保存原则:图片是交付物,代码和数据才是证据。每次生成图时保存一个简短的参数清单,至少包含输入文件、过滤规则、排序方式、尺寸和 dpi。

Matplotlib 推荐显式创建 fig, ax,让图形对象和坐标轴对象的职责清楚。标题、标签、图例和网格应服务于阅读,不要把所有样式都堆在一行调用中。保存图像时指定尺寸、分辨率和输出路径,先创建输出目录;同时保存生成图表的代码和数据快照,图片本身不能说明所有规则。

PYTHON · plot_summary.py
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:读者先看图发现模式,再用表格确认具体数字,最后通过清单回到来源和参数。三种输出都从同一个聚合对象生成,不要分别手工复制数据,否则很容易出现图表和表格不一致。

PYTHON · export_summary_bundle.py
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 的列和排序是否相同,图表尺寸和轴范围是否相同,输出文件是否能打开。任何一项不同时,先记录差异再解释结论,不要先调整样式让两张图看起来相似。

表 2.24C · 观察到决定的写法
层次示例证据要求
观察数据主题的有效样本为 8summary 的 count
解释可能因为资料更容易完成需要额外变量或实验
限制人工小样本,不能推广来源和样本范围
决定抽查缺失记录并增加下一周样本行动、负责人、时间
PYTHON · report_snapshot.py
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"])
报告底线:图表可以支持比较和提问,但不能凭一张图证明因果。把限制和下一步写出来,读者才知道怎样继续验证。

可读性、可访问性和不确定性同样重要

图表首先要让读者读懂,其次才是好看。字号、留白、标签长度和图例位置都要在实际输出尺寸下检查;在手机或窄窗口上,过长的主题名应换行、缩写或改用横向图。标题不能只写“结果图”,应包含对象、指标和时间范围;坐标轴单位不能依赖正文的猜测。

不要只用颜色区分类别,使用位置、线型、标记或直接标注提供第二种线索。连续色带应从低到高有明确顺序,离散类别使用数量有限且对比足够的颜色。对需要打印、投影或色觉差异读者的场景,先做灰度和小尺寸检查。图表中展示的数字应和汇总表一致,标签四舍五入不能让读者误以为原始值就是整数。

平均值本身也有不确定性。样本量少、分布偏斜或异常值较多时,可以同时展示中位数、范围、四分位数或误差提示。第二章不要求马上学习复杂的统计推断,但要养成“不把一个点当成完整事实”的习惯。图表旁写清样本量和筛选规则,读者才能判断差异是否值得进一步研究。

表 2.24B · 图表交付前检查
维度检查问题改进手段
可读性缩小后标签还能读吗?调整 figsize、横向布局、标签换行
可访问性不看颜色能否区分?线型、标记、直接标注、文字说明
单位数字的量纲和范围清楚吗?轴标签、标题、注释
样本每个点有多少有效记录?n 标注、计数表、缺失率
不确定性平均值是否被少数异常值影响?中位数、范围、抽查原始记录
PYTHON · readable_plot.py
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)

横向柱状图让较长的主题名更容易阅读,直接标注样本量也比让读者去猜更可靠。颜色在这里只是辅助,真正的比较依据仍然是柱子的长度和文字标签。若要显示范围,可以用误差线或在旁边提供表格;不要把装饰线解释成统计置信区间。

VISUAL REVIEW 02做一次“读者测试”

把你生成的图缩小到手机宽度或打印成灰度,邀请一位没有看代码的人回答:图表对象是什么?单位是什么?一个柱子代表多少条记录?最高值和最低值分别是多少?如果对方答不出来,修改标题、标签、颜色或布局,再重复测试。

好的图表不需要作者口头补充关键事实。提交时保留修改前后的版本和理由,说明哪些改动提高了可读性或可访问性;同时检查图表数字与 summary 表一致,避免展示层格式化造成新的误解。

读图也要像读代码一样逐项验证

看见一条上升的线,先不要说“学习效果越来越好”。第一步读标题和时间范围,第二步确认横轴是否等间隔、纵轴单位和起点,第三步查看每个点的样本量与缺失,第四步回到汇总表抽查原始记录,最后才提出可能的解释。图表能帮助发现模式,却不能单独证明原因。

刻度范围会改变视觉重量。柱状图通常从 0 开始,让长度与数值差异对应;折线图是否从 0 开始要看问题,但任何截断都必须明确。颜色和排序也会影响读者注意力,排序依据应写在代码和报告中。类别太多时,宁可拆成多个小图或只展示预先说明的前几项,也不要把不可读的标签挤在一张图里。

复现的关键是参数固定和输入可追踪。把随机抽样固定种子,把数据清洗后的版本保存下来,把图表文件名和生成时间写入清单。若图表重新生成后不同,先比较输入行数、列类型、依赖版本和绘图参数;不要通过手动移动标签来掩盖数据变化。一个好的复现记录能够说明“为什么这张图今天和昨天不同”。

表 2.24A · 图表质疑清单
检查对象提问证据
范围坐标轴是否截断或单位改变?刻度、标签、绘图参数
样本每个点或柱有多少有效记录?count、缺失率、原始行
顺序类别为什么按这个顺序?sort_values 的键和方向
关系相关是否被误解成因果?问题定义和额外证据
复现换一台机器能否得到同一结构?输入、版本、参数、输出清单
PYTHON · plot_manifest.py
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))

清单中的时间会变化,但输入路径、聚合口径、轴单位和过滤规则应该稳定可读。若需要严格比较,可以另外保存代码版本或输入文件校验和。复现不是要求每个像素永远相同,而是要求数据口径和结论可以被追溯、解释和重新检查。

PLOT REVIEW 02写一份图表审查报告

使用本章任意一张汇总表生成柱状图或折线图。提交图像、绘图代码和清单,并回答五个问题:图表回答什么问题?一个视觉元素代表什么?样本量是多少?坐标轴和单位是否容易误读?如果输入多一行或缺一列,哪一步会发现?

评分重点是图表与数据契约一致:标题、单位、样本量、排序和过滤规则都能回到代码或表格;结论使用“观察到”而不是无证据的因果断言;图像可以重新生成,失败时有清楚提示。

阅读图表时,先看标题和单位,再看横纵轴范围、样本量和图例,最后观察异常点。一个峰值可能来自真实变化,也可能来自重复行、缺失值填补、合并放大或刻度截断。复现图表时,使用同一份清洗后的数据和相同参数;如果只能通过手动拖动表格得到结论,说明分析过程还没有被写成代码。

图 2.2 · 一张图的可信度检查自制阅读框架
来源文件 / 版本 / 许可清洗缺失 / 重复 / 类型图表轴 / 单位 / 样本量结论可质疑

可靠的图表不是从“画出来”结束,而是能沿着来源、清洗和参数回到原始证据。

PLOT LAB 2.8.24让一张图经得起追问

对一份按主题统计的表画一张柱状图,再回答:每个柱子代表多少条记录?平均值是否受缺失值影响?纵轴是否从 0 开始?颜色是否表达了某种含义?把数据来源、清洗规则和图像文件路径写入同一个实验记录。

图表标题和坐标轴要包含单位;报告平均值时同时给出样本量;若类别顺序经过排序,写明排序依据。不要把图表的相关性描述成因果关系,发现异常后回到原表抽查,而不是只调整颜色或刻度。

09 · DATA CAPSTONE

综合项目:完成一条可复现的数据管道

最后把本章内容串成一个小项目:读取学习记录,验证字段,清洗缺失和异常值,按主题聚合,生成一张图和一份 JSON 报告。项目不追求真实数据规模,而追求每一步都有输入、输出、规则和验收证据。未来把学习记录换成模型评测结果、文档统计或检索日志,流程仍然成立。

2.9.1 先写规格,再创建目录

目录结构是数据血缘的一部分

一个可复现项目要让人一眼看出原始资料、处理中间物和最终交付物的边界。data/raw 只保存原始输入,不在其中覆盖清洗结果;data/clean 保存经过契约检查的表;output 保存面向读者的 JSON、图像和摘要;src 保存可复用代码;tests 保存最小的自动检查。目录不是越复杂越好,而是要让每个文件只有一个容易解释的位置。

README 应该像一份操作说明,而不是项目宣传语。开头写项目回答的问题和数据来源,接着列出环境创建命令、入口命令、输入字段、输出字段和已知限制,最后放一组最小示例。别人按照 README 从一个新的终端运行时,如果仍然需要询问“文件放在哪里、先运行哪条命令”,说明规格还不完整。

输出文件要区分可重建和不可重建。PNG 是可重建的交付物,JSON 是机器可读的摘要,清洗后的 CSV 是中间证据,原始 CSV 是来源。生成新版本时,可以在输出清单中记录时间、脚本版本和输入校验和;不要用“最终版”“最终版2”这样的文件名表达版本。路径和文件名一旦被其他代码引用,就应当当作接口管理。

表 2.25A · 一个小项目的文件职责
位置可以放什么谁会读取禁止的习惯
data/raw原始 CSV、JSON、来源说明读取函数、人工复核覆盖、手工改值、删除来源
data/clean通过规则后的表和拒绝表聚合函数、绘图函数不记录清洗规则
src读取、验证、变换、保存函数入口脚本、测试把个人绝对路径写死
output报告、PNG、JSON、清单读者、下游程序只保留截图不保留参数
tests小样本和失败案例自动测试、冷启动只测试成功路径
PYTHON · project_paths.py
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 程序再次读取。

INPUTtopic、minutes、score、completed
PROCESS读取 · 验证 · 清洗 · 聚合
OUTPUTCSV · JSON · PNG · 摘要
表 2.6 · 项目验收契约
阶段成功证据失败证据不可省略的元数据
读取记录文件路径、编码和输入行数文件不存在或无法解码时停止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 数组或模型评测表时,只需要替换对应的读取和契约部分。

表 2.26A · 函数职责与测试输入
函数输入返回最小测试
read_input路径、编码原始 DataFrame正常文件、缺文件、坏编码
validate_table表、契约通过表、拒绝表、问题缺列、空值、范围、重复键
aggregate通过表、分组键汇总表空表、单组、多组、缺失指标
save_json路径、字典输出文件可回读、目录创建、写入失败
run配置或路径整体报告成功路径和失败不伪报成功
PYTHON · pure_transform.py
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。真实项目可以使用类型提示、文档字符串和更小的测试样本来加强这个承诺,但不要为了“看起来专业”添加无法维护的抽象层。

职责检查:如果一个函数同时读取文件、删除异常、画图和写 JSON,它几乎一定难以测试。先拆出三个最小职责,再考虑是否需要更多函数。

不要把读取、清洗、聚合、绘图和保存全部写进一个主循环。每个函数都应该有清楚的输入和返回值,主函数只负责编排。这样你可以用一个五行 DataFrame 测试清洗规则,用一个两行表测试聚合,用临时目录测试文件输出,而不必每次都从原始文件开始。

PYTHON · data_pipeline.py
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。冷启动失败时把完整错误、定位过程和修复方式写下来。专业性不在于永远不失败,而在于失败可定位、可解释、可恢复。

表 2.27A · 测试结果应该留下什么
案例预期状态要断言的事实报告内容
正常输入成功行数对账、JSON 可回读、PNG 存在输入、保留、输出数量
空表按契约决定不产生除零或伪造平均值空输入的约定
缺列停止错误包含列名缺失字段列表
违规行拒绝或警告原因可定位、原始值保留每类数量
输出失败停止不报告整体成功路径和权限提示
PYTHON · test_invariants.py
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 或复盘中,未来才知道为什么采用当前规则。

最小测试矩阵

表 2.7 · 数据管道的最小测试
案例输入变化预期检查输出
正常三条合法记录全部保留并生成汇总JSON 可回读,图表存在
空文件只有表头按规则停止或生成空报告行为写在 README,不可含糊
缺列删除 score验证阶段失败提示缺少字段,不进入聚合
异常值分钟为负数,分数为 120进入拒绝表拒绝原因和行数对账
编码文件不是 UTF-8读取失败指出文件并给转换建议
输出目标目录不可写写入失败不把读取成功误报为整体成功
PYTHON · test_pipeline_contract.py
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 从表格数据走向模型数据

进入大模型前,先把文本变成可追溯记录

模型实验中的数据通常比学习记录更复杂:一份文档会被切成多个段落,一个问题可能有多个参考答案,一次模型调用会产生提示词、参数、响应、耗时和错误。不要因为对象变复杂就放弃表格契约。先定义一行代表什么,例如“一次文档分块”或“一次评测样本”,再列出来源、版本、内容长度、语言、许可和处理状态。

文本数据还要关注字符编码、换行、空白、重复段落和敏感信息。清洗时保留原文来源与段落编号,派生字段单独命名;如果要截断长度,记录截断前后的字符数和规则。模型输入不能只保存最终字符串,否则出现错误时无法判断是分块、清洗还是提示模板导致的。

训练、验证和测试的划分也属于数据处理。相同来源的近重复文本不能同时出现在训练和测试中,否则评估会过于乐观;按时间划分时要写清截止日期;随机划分时固定种子并保存索引。第二章只介绍这些前置原则,不在这里提前展开模型结构,避免把数据质量问题混在模型参数里。

表 2.28A · 模型前数据的最小字段
字段作用检查不应丢失
source_id回到原始文档非空、稳定来源和版本
chunk_id定位文本片段同源内唯一段落顺序、起止位置
text实际模型输入长度、编码、空白清洗前后长度
license说明使用边界值可追溯许可链接或来源文件
split训练/验证/测试无泄漏、可复现划分规则和随机种子
PYTHON · model_ready_records.py
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 和版本字段一路传下去。数据血缘保留下来,模型输出才有机会被定位、复核和更新。

CHAPTER 02 CAPSTONE REVIEW完成一次冷启动验收

用本章项目生成一份清洗表、拒绝表、汇总 JSON 和图表。随后复制项目到一个新的目录,按照 README 重新创建环境并运行。记录输入文件、依赖版本、行数对账、未匹配键、图表参数和一次失败运行。最后写一段迁移说明:如果把学习记录换成文档分块或模型评测样本,哪些字段和规则需要保留或改变。

评分重点仍然是职责是否清楚:读取、验证、清洗、聚合、绘图和保存可以分别测试;输出 JSON 能回读;图表包含单位和样本量;异常记录有原因;README 能让另一台机器完成冷启动。

迁移到模型数据时,至少保留来源、版本、粒度、许可、长度、划分和错误原因。不要因为进入模型学习就跳过数据契约;模型越复杂,越需要可追溯的输入。

完成这个项目后,数据处理的对象可以逐步换成模型评测表、文本分块清单、向量检索日志或训练指标。新对象会带来新的字段和规模,但基础问题不变:数据从哪里来,粒度是什么,哪些值缺失,如何验证合并,结果能否复现。进入 NumPy、Pandas 之外的工具时,也先用本章的契约和测试方法。

下一步学习大模型时,建议先把一份公开且许可清楚的文本转换成结构化记录:来源、标题、段落、字符数、语言、清洗状态和错误原因。暂时不调用模型,先让数据管道稳定;等数据可以追溯和回读,再学习向量、Embedding、Token 和检索。模型不会替你修复错误的输入,可靠的输入仍然是 Python 和数据处理的责任。

本章的完成标准是:你能建立虚拟环境,读懂数组的形状和轴,用 Pandas 检查表格,明确处理缺失和重复,验证合并关系,选择合适图表,保存可回读的结果,并能解释一次失败运行。做到这些,就可以进入数学直觉和机器学习,而不是只会把库函数拼在一起。

FINAL DATA LAB 02把学习记录变成质量报告

从零创建一个小项目:准备一份 CSV,至少包含 12 条学习记录,其中故意加入空值、重复标题、负数分钟和超范围分数。完成读取、契约验证、清洗、分组统计、柱状图和 JSON 报告。要求保存原始数据、清洗数据、拒绝数据、生成参数和一次可回读检查。

评分重点不是调用了多少个 Pandas 方法,而是粒度和职责是否清楚:读取函数不负责绘图,清洗函数返回保留与拒绝两张表,聚合函数同时返回样本量和指标,保存函数记录来源与规则。至少验证“输入行数 = 保留行数 + 拒绝行数”,验证 JSON 可以用 json.loads() 再次读取,并把图表的单位、样本量和排序规则写在报告中。

完成后做一次冷启动:打开新的终端,按 README 创建环境、运行帮助或入口命令、检查输出目录。若运行失败,保留完整错误和修复步骤;能解释失败比只展示成功截图更能证明数据管道可靠。