01 · PROBLEM FRAMING
从问题到机器学习任务:先说清楚要预测什么
很多“模型效果不好”的根因并不在算法,而在问题还没有被写成一个可以验证的任务。本节建立四个基本对象:样本、特征、标签和预测时点;再用基线和可行性检查,把模糊的业务愿望变成一个可以运行、可以失败、也可以复盘的实验。
4.1.1 把自然语言问题翻译成监督学习任务
“预测用户会不会流失”“判断一条评论是否有帮助”“估计一篇文章需要几分钟读完”都是自然语言愿望,还不是机器学习任务。一个可以训练的任务,至少要写出输入 x、目标 y、预测时点 t、允许使用的信息以及评价方式。预测时点尤其重要:如果我们要在用户点击按钮之前给出结果,就不能把点击之后生成的字段放入特征。时间边界不是附加说明,而是任务定义的一部分。
监督学习的基本形式是从带标签样本 (xᵢ, yᵢ) 中学习一个函数 f,使新样本的预测 ŷ=f(x) 尽量接近真实标签。标签是连续数值时通常称为回归,是有限类别时通常称为分类。相同的数据也可以支持不同任务:订单金额可以做回归,是否退款可以做分类,未来七天是否退款还会改变标签生成窗口。先确定标签类型、预测窗口和可行动的结果,再讨论算法,能显著减少后续返工。
任务定义还要写明“一个样本是什么”。按用户建模、按订单建模、按会话建模和按事件建模,会得到不同的样本数、标签重复关系和误差含义。若一个用户有几十行事件,而切分时把这些行随机分到训练和测试,模型可能记住用户特征而不是学习可迁移规律。样本粒度必须与部署时真正收到的输入一致,否则离线分数再高也不能说明线上可用。
| 问题 | 必须写出的内容 | 示例 | 漏写的风险 |
|---|---|---|---|
| 预测谁 | 样本单位、唯一 ID、去重规则 | 一次会话一个样本 | 同一对象跨集合泄漏 |
| 预测什么 | 标签定义、窗口、单位 | 未来 7 天是否退款 | 标签漂移、正负例混乱 |
| 何时预测 | 特征可用截止时间 | 提交订单时刻 | 把未来字段当输入 |
| 怎样算好 | 指标、阈值、业务代价 | 召回率至少 0.8 | 只追逐不相关的准确率 |
from dataclasses import dataclass
from datetime import timedelta
@dataclass(frozen=True)
class TaskSpec:
name: str
entity: str
prediction_time: str
label_window: timedelta
task_type: str
metric: str
spec = TaskSpec(
name="七天退款风险",
entity="order",
prediction_time="paid_at",
label_window=timedelta(days=7),
task_type="binary_classification",
metric="recall_at_precision_0.6",
)
assert spec.entity and spec.prediction_time
assert spec.task_type in {"regression", "binary_classification", "multiclass"}
print(spec)代码中的数据类不是为了增加形式感,而是把口头约定变成可以审阅的对象。prediction_time 提醒我们检查特征时间戳,label_window 让标签生成规则可以复算,metric 迫使团队讨论“好”究竟是什么意思。真实项目还可以加入数据版本、负责人、正例定义和允许延迟。任务规格越具体,模型结果越容易被另一个人复核。
易错:把相关性误写成可用特征
一列字段与标签高度相关,不代表它在预测时可获得。例如退款完成后的“退款原因”可能是最强的特征,却不可能用于付款瞬间的拦截;从全量订单统计得到的“用户历史退款率”也必须截断在预测时点之前。检查特征时要问“这列数据是谁在什么时候写入的”,而不是只看相关系数。
另一个常见误区是把“模型输出概率”直接当作行动。概率只是排序或风险刻度,行动还需要阈值、人工复核容量、误报成本和申诉流程。将预测与决策拆成两个步骤,才能在成本变化时重新选择阈值,而不必重新训练模型。
选择“预测学习资料是否会被收藏”或你自己的问题,写出样本单位、预测时点、标签窗口、至少三列可用特征、一个主指标和一个业务约束。再列出一列看似有用但在预测时点之后才产生的字段。
4.1.2 特征、标签与数据粒度
特征是模型在预测时可以读取的描述,标签是我们希望模型学习的结果。把一列数据命名为 feature_1 并不会让它成为好特征;我们还要说明它的语义、单位、时间范围、缺失含义和允许的变换。标签也不是永远固定的“真相”,它可能来自人工标注、后续行为、规则系统或另一个模型。标签来源改变时,旧模型的分数不能直接与新模型比较。
粒度决定统计分母。按用户聚合时,平均阅读时长回答“一个用户通常读多久”;按文章聚合时,回答“这篇文章通常被读多久”;按一次阅读事件记录时,回答“这一次实际读了多久”。三个问题都合理,却不能把一套标签和另一套特征随意拼接。聚合窗口、去重键和缺失事件要写进数据字典,并在构建数据集时加入断言。
类别特征、数值特征、文本特征和时间特征的预处理职责不同。类别值不应因为字典序被当作有序数值,文本长度不等于语义,时间戳拆成小时后要考虑时区,计数特征还要注明窗口与是否包含当前事件。特征工程的目标不是制造尽可能多的列,而是把可用信息用稳定、可解释的方式表达出来。
| 字段 | 示例 | 为什么要记 | 检查方式 |
|---|---|---|---|
| 名称与语义 | reading_minutes_7d | 避免同名不同义 | 数据字典审阅 |
| 类型与单位 | 浮点、分钟 | 决定缩放和边界 | 类型/范围断言 |
| 时间截止点 | 预测时点之前 | 防止未来信息 | 时间戳比较 |
| 缺失含义 | 无记录还是未知 | 决定填补策略 | 缺失标记统计 |
| 版本与来源 | 事件表 v3 | 支持复现与回滚 | 元数据快照 |
from datetime import datetime, timezone
PREDICTION_TIME = datetime(2026, 8, 1, tzinfo=timezone.utc)
def feature_row(user_id, events):
before = [event for event in events
if event["at"] <= PREDICTION_TIME]
minutes = sum(event["minutes"] for event in before)
count = len(before)
return {
"user_id": user_id,
"reading_minutes_7d": minutes,
"reading_events_7d": count,
"has_history": int(bool(before)),
}
events = [{"at": datetime(2026, 7, 29, tzinfo=timezone.utc), "minutes": 18}]
row = feature_row("u-17", events)
assert row["reading_minutes_7d"] == 18
assert set(row) == {"user_id", "reading_minutes_7d", "reading_events_7d", "has_history"}
print(row)这个例子把“没有历史记录”和“历史记录为零”区分开:has_history 是一个额外事实,避免模型被迫猜测零的含义。生产数据中还要处理时区、重复事件、迟到事件和删除事件。对于聚合特征,建议保存原始事件数量、最早和最晚时间、聚合窗口以及一两个人工核对样本,出现异常时才能追溯。
特征可用性问题
字段是否在预测时点已经写入?写入后是否会被回填?线上和离线是否使用同一套逻辑?如果答案不明确,先把字段标成待验证,而不是直接放进模型。
标签质量问题
标签是否存在延迟、噪声或选择偏差?谁负责标注?正例和负例的定义是否随时间变化?用小样本人工复核标签,常常比调参更能提升可靠性。
给定用户表、订单表和事件表,设计一个“预测订单是否退款”的数据集。写出每张表如何连接、唯一键是什么、一个用户有多个订单时怎样避免把未来订单信息带入当前订单。
4.1.3 基线、可行性与任务契约
基线是一个诚实而简单的参照物。回归可以用训练集标签均值,分类可以总是预测多数类,推荐可以展示热门项目,文本任务可以用关键词规则。基线并不丢人,它告诉我们数据本身有多少信号、指标是否容易被类别比例误导,以及复杂模型增加的收益是否值得计算和维护成本。
可行性检查应在训练复杂模型前完成。先统计样本数量、标签分布、缺失率、重复 ID、时间覆盖和训练/推理延迟;再用一个极小的端到端脚本确认数据能被读取、切分、训练、预测和评估。若连十几行样本都无法手算一遍,直接上大模型只会把错误隐藏得更深。
任务契约还应该写出“不做什么”。例如本章示例只讨论离线预测,不承诺因果关系;模型分数只能用于排序,不能自动决定处罚;测试集只在最终报告时使用。把边界写出来能防止读者将一个演示脚本误当成生产系统,也能让后续章节的神经网络实验沿用同一套纪律。
| 任务 | 最低基线 | 应该报告 | 不能据此得出 |
|---|---|---|---|
| 回归 | 训练集均值 | MAE、RMSE 与误差分布 | 因果影响成立 |
| 二分类 | 多数类/固定阈值 | 混淆矩阵、PR-AUC | 概率已校准 |
| 排序 | 热门或随机 | Recall@k、覆盖率 | 用户一定喜欢 |
| 异常检测 | 规则阈值 | 人工核验率、误报成本 | 所有异常都被发现 |
from math import sqrt
def mae(actual, predicted):
return sum(abs(a - p) for a, p in zip(actual, predicted)) / len(actual)
def rmse(actual, predicted):
mse = sum((a - p) ** 2 for a, p in zip(actual, predicted)) / len(actual)
return sqrt(mse)
train_y = [10.0, 12.0, 13.0, 15.0]
test_y = [11.0, 16.0, 14.0]
constant = sum(train_y) / len(train_y)
baseline = [constant] * len(test_y)
print({"baseline": constant, "mae": mae(test_y, baseline), "rmse": rmse(test_y, baseline)})
assert len(baseline) == len(test_y)报告基线时要使用同一份评估数据和同一套指标。不能让复杂模型在测试集上调到满意后,再拿基线在另一份数据上比较;也不能只报告一个平均分而隐藏极端样本。建议同时列出基线、当前模型和目标门槛,记录训练时间、推理成本、参数数量以及人工复核结果。模型只有在收益与代价同时满足时才算进步。
检查清单:模型开工前的十分钟
确认样本 ID 唯一;标签生成代码可独立运行;预测时点与特征时间戳有断言;正负例数量和缺失率已打印;至少有一个不依赖机器学习库的基线;切分策略符合数据生成过程;指标与业务错误代价对应;随机种子和版本会被记录;一条样本可以从原始数据追到最终预测;失败样例有保存位置。任何一项说不清,都值得先停下来补契约。
为一个分类任务设计基线报告表,至少包括多数类基线、规则基线和一个学习模型。设定一个“值得上线”的条件,要求同时考虑指标提升、误报数量和推理成本。
| 能力 | 最低证据 | 迁移到大模型项目的用法 |
|---|---|---|
| 定义任务 | 写出输入、标签、时点和指标 | 明确 RAG 评测问题与答案范围 |
| 识别粒度 | 解释一行样本代表谁 | 防止文档、段落和查询串位 |
| 建立基线 | 能复现常数或规则预测 | 判断 Embedding/LLM 是否真正增益 |
| 写出边界 | 列出可用与禁止字段 | 避免把评测答案泄漏给提示词 |
把任务卡变成数据接口
任务卡最终要落到一个明确的数据接口:输入对象有哪些字段,字段的类型和单位是什么,缺失如何表达,标签从哪张表生成,预测时点使用哪一个时区,返回值是数值、概率还是类别。接口一旦写清楚,数据工程、建模和评估就能围绕同一个对象协作。接口中的每个字段都应有来源和责任人,字段改名或含义改变时提高版本号,而不是让下游悄悄适配。
写接口时还要标出不可见信息和不可行动信息。不可见信息是在预测时点不存在的字段,例如未来订单状态;不可行动信息是虽然可读,但模型输出无法改变它的字段,例如已经发生的结果。把二者列入禁用清单,可以防止“离线分数很好、上线没有办法使用”的项目。对于大模型评测,参考答案、人工评分和未来用户反馈也应分别标注可见时间。
接口测试应覆盖空输入、重复 ID、错时区、负数、极端长度和未知类别。测试不要求预言所有业务规律,但要保证错误尽早暴露。建议为每个字段写一条最小断言,并保存一行经过人工核对的金样本;代码重构、数据表迁移或更换模型后,先跑这组金样本,再看整体指标。
| 用例 | 输入变化 | 应有行为 | 发现的问题 |
|---|---|---|---|
| 最小合法样本 | 每个字段都存在 | 成功生成一行特征与标签 | 字段映射/类型错误 |
| 缺失历史 | 没有过去事件 | 按契约填补并保留标记 | 零与未知混淆 |
| 未来事件 | 事件时间晚于预测时点 | 拒绝或排除 | 时间泄漏 |
| 重复实体 | 同一 ID 多次出现 | 按粒度聚合或报错 | 样本串位 |
| 未知类别 | 新类别值 | 走明确的 unknown 分支 | 线上崩溃/错编码 |
| 标签未成熟 | 窗口尚未结束 | 标为 pending,不进评估 | 假负例 |
def validate_row(row):
required = {"id", "user_id", "observed_at", "label"}
missing = required - row.keys()
if missing:
raise ValueError(f"缺少字段: {sorted(missing)}")
if not row["id"] or not row["user_id"]:
raise ValueError("id 与 user_id 不能为空")
if row["label"] not in (0, 1, None):
raise ValueError("标签必须是 0、1 或 pending(None)")
return True
golden = {"id": "a-1", "user_id": "u-1", "observed_at": "2026-08-01T10:00:00Z", "label": 1}
assert validate_row(golden)
try:
validate_row({"id": "bad", "user_id": "u-2", "observed_at": "now", "label": 2})
except ValueError as error:
print("拒绝非法样本:", error)当接口测试通过后,才进入模型比较。模型是任务契约的消费者,不应替数据层猜测字段意义。你可以把契约 JSON、金样本和校验脚本放在同一个版本目录,并在报告中列出校验通过的时间与代码版本。这样当下一章把线性基线换成神经网络时,变化会集中在模型部分,任务边界仍然稳定。
针对你在 4.1.1 中设计的任务,写出三条数据接口应拒绝的输入,并说明每条规则如果不执行会如何污染训练或评估。
本节小结:先把“预测对象”固定下来
一个任务真正开始前,要能用一行数据回答:谁是样本、什么时候做预测、标签观察到哪一天、输入的每个字段在当时是否存在、模型输出会触发哪一个动作。任何一句回答含糊,都可能在训练后变成无法解释的差异。把这些回答写进任务卡和数据接口,后续的切分、指标与模型才有共同的参照。
当你从业务语言转向数学语言时,不要急着寻找最复杂的算法。先写出 (xᵢ, yᵢ)、预测函数 f、误差或决策指标,再用一组手工样本走一遍。手工样本能暴露标签方向、时间窗口和粒度错误;它也是下一章学习神经网络输入输出形状的最好桥梁。
| 交付物 | 必须回答 | 完成标志 |
|---|---|---|
| 任务说明 | 预测谁、什么、何时 | 一页文字可复述 |
| 数据字典 | 字段语义、类型、单位、来源 | 每列都有负责人 |
| 标签脚本 | 窗口、成熟时间、正类定义 | 金样本断言通过 |
| 基线报告 | 简单规则与最低门槛 | 复杂模型有比较对象 |
task_card = {
"sample": "一次资料展示",
"prediction_at": "展示发生时",
"label": "七天内是否收藏",
"features": ["title_len", "minutes_7d", "category"],
"forbidden": ["收藏发生后的字段", "未来订单状态"],
"metric": "precision_at_recall_0.7",
}
required = {"sample", "prediction_at", "label", "features", "forbidden", "metric"}
assert required <= task_card.keys()
print("task card ready:", task_card["sample"])把“让推荐更准”改写成包含样本、时间窗、指标和行动门槛的一句话,并列出一句不能从该实验推出的结论。
从业务句到训练矩阵:先做三次对齐
把一句“预测用户是否收藏”变成可训练数据,至少要完成三次对齐。第一是对象对齐:一行到底代表一次展示、一个用户还是一篇文章;第二是时间对齐:特征只使用预测时点之前已经发生的事件,标签在规定窗口结束后生成;第三是形状对齐:特征矩阵 X 的每一行必须和标签向量 y 的同一行对应。任何一次对齐失败,模型都可能正常运行,却学到错误关系。
建议先制作十行以内的手工数据集,再把每一行从原始记录追踪到特征、标签和预测。手工样本应刻意包含无历史记录、重复事件、边界时间、缺失类别和一个明确的正例;这些样本比随机抽取的“平均行”更容易暴露逻辑漏洞。确认小样本可解释后,再扩大数据量,并把同样的断言放进自动化构建脚本。
| 检查 | 最小问题 | 示例断言 | 失败后果 |
|---|---|---|---|
| 对象 | 一行代表谁 | 样本 ID 唯一 | 重复或错配 |
| 时间 | 特征何时可见 | feature_at ≤ predict_at | 未来信息泄漏 |
| 标签 | 正类如何生成 | 窗口成熟才赋值 | 假正例/假负例 |
| 形状 | X 与 y 是否同长 | len(X) == len(y) | 行列错位 |
| 版本 | 规则是否可复现 | 记录 schema_version | 旧结果无法解释 |
def check_dataset(rows, features, labels):
if len(rows) != len(labels):
raise ValueError("样本与标签数量不一致")
ids = [row["id"] for row in rows]
if len(ids) != len(set(ids)):
raise ValueError("样本 ID 重复")
for row in rows:
if row["feature_at"] > row["predict_at"]:
raise ValueError(f"未来特征: {row['id']}")
if set(features) != set(rows[0]["features"]):
raise ValueError("特征字典不一致")
return {"rows": len(rows), "features": features, "schema_version": "v1"}
rows = [{"id": "a", "feature_at": 1, "predict_at": 2, "features": ["minutes", "category"]}]
print(check_dataset(rows, ["minutes", "category"], [0]))这段检查故意把“能运行”与“可信”分开:代码只负责发现明显违约,任务负责人仍需人工确认标签定义和业务边界。等数据契约稳定后,把契约版本、金样本和检查结果一起保存;后续模型只要违反契约,就在训练前失败。
选一个你熟悉的预测问题,画出“原始事件 → 特征 → 标签 → 切分 → 预测”的五步链,并在每一步标注时间、唯一 ID 和可能的失败条件。
02 · DATA BOUNDARIES
数据切分、泄漏与预处理:保护评估的可信度
训练集、验证集和测试集不是三个随意的文件夹,而是三种不同的证据职责。本节解释随机、分层、时间和群组切分的适用条件,并说明为什么标准化、填补和特征选择都必须放进正确的训练边界。
4.2.1 训练集、验证集和测试集各自负责什么
数据切分的目的不是制造三份看起来不同的文件,而是隔离三种决策。训练集用于拟合参数,例如回归系数、树的分裂规则和标准化均值;验证集用于在训练完成后选择模型、阈值和超参数;测试集只在方案冻结后给出一次近似的未来表现。若测试集反复参与选择,名字虽然仍叫“测试”,职责却已经变成验证。
想象一场考试:训练集像练习题,验证集像模拟考试,测试集像真正的期末考试。把期末题的答案看过很多次,再报告期末成绩,分数自然会偏乐观。机器学习的“看过”不只指直接读取标签,也包括根据测试分数挑选特征、调整阈值、删掉异常样本或决定停止训练。任何影响方案的选择都应该在训练/验证边界内完成。
切分比例没有统一答案。样本很少时,留出过大的测试集会浪费训练信息;样本很多时,测试集可以更小但仍要覆盖关键人群和时间段。比比例更重要的是独立性:同一用户、同一文档的近重复版本或同一设备的连续事件,不能因为随机抽样而同时出现在训练和测试。评估集要代表未来会遇到的分布,而不是只代表文件里的行。
| 数据 | 可以做什么 | 不能做什么 | 应保存的证据 |
|---|---|---|---|
| 训练集 | 拟合参数、估计预处理 | 代表最终泛化分数 | 行数、标签分布、版本 |
| 验证集 | 选超参数、阈值、特征方案 | 参与最终无偏报告 | 每次实验的配置与分数 |
| 测试集 | 冻结后做一次最终评估 | 反复调参或挑样本 | 访问时间、只读快照 |
| 线上监控集 | 观察漂移与真实反馈 | 未经审计直接回灌 | 时间窗口、反馈延迟 |
from random import Random
def split_rows(rows, seed=7, test_ratio=0.2, valid_ratio=0.2):
if not rows:
raise ValueError("数据不能为空")
if test_ratio <= 0 or valid_ratio <= 0 or test_ratio + valid_ratio >= 1:
raise ValueError("比例必须为正且总和小于 1")
shuffled = list(rows)
Random(seed).shuffle(shuffled)
test_size = max(1, round(len(shuffled) * test_ratio))
valid_size = max(1, round(len(shuffled) * valid_ratio))
test = shuffled[:test_size]
valid = shuffled[test_size:test_size + valid_size]
train = shuffled[test_size + valid_size:]
assert set(map(id, train)).isdisjoint(map(id, valid))
assert set(map(id, train)).isdisjoint(map(id, test))
return train, valid, test
rows = [{"id": i, "label": i % 2} for i in range(20)]
train, valid, test = split_rows(rows)
print(len(train), len(valid), len(test))这个手写版本适合理解职责,不是所有生产数据的最终方案。它使用固定种子让实验可重现,并用对象身份检查三份列表没有复用同一行。正式项目还应检查 ID、时间和近重复关系,而不是只检查 Python 对象是否不同。scikit-learn 的 train_test_split 可以完成基础随机切分,但它不会替你判断用户、时间或文档版本是否需要成组。
易错:把“随机”当成“独立”
随机打乱只改变抽样顺序,不会消除实体之间的关联。一个用户的十次会话、同一篇文章的多个切片、同一设备连续上报的传感器值仍然互相相似。评估前先问数据是怎样生成的,再选择按实体、按时间或按事件的切分。对近重复文本,可以用规范化文本哈希或相似度抽查。
你有 1000 条带标签样本,计划尝试 20 组超参数。说明哪些数据用于拟合、哪些用于选超参数、测试集什么时候可以被读取;再写出如果测试分数下降时你会怎样处理。
4.2.2 随机、分层、时间与群组切分
随机切分适合样本近似独立同分布、未来与过去没有明显顺序差异的场景。分类任务通常要做分层切分,让各集合的类别比例大致一致,避免一个小测试集恰好没有少数类。分层不是为了让分数好看,而是为了让每份证据都包含评估问题所关心的类别。少数类极少时,仍要报告绝对数量和置信区间,不能只看比例。
时间切分适合日志、交易、内容发布和用户行为等会随时间变化的任务。训练使用过去,验证使用更近的时间,测试使用最新时间;这样评估的是“用过去预测未来”。随机切分会让未来模式进入训练,产生不真实的高分。时间切分还要处理时间窗口重叠、迟到标签和季节性,最好用滚动窗口报告多段结果,而不是只选一个日期。
群组切分适合同一实体内部高度相关的数据。按用户、患者、设备、文档或项目分组后,一个群组只能进入一个集合。若一个群组同时出现在训练和测试,模型可能利用实体特有的指纹;这种设置测到的是“记住见过的实体”,而不是“泛化到新实体”。scikit-learn 的 GroupKFold、GroupShuffleSplit 可以帮助实现,但分组字段的定义仍由任务决定。
| 策略 | 适合的数据生成过程 | 常见风险 | 额外检查 |
|---|---|---|---|
| 随机 | 样本近似独立、分布稳定 | 近重复跨集合 | ID/文本去重、分布比较 |
| 分层随机 | 类别比例重要的分类 | 少数类样本不足 | 每类绝对数量与置信区间 |
| 时间 | 预测未来事件 | 窗口重叠、漂移 | 时间轴、延迟和滚动评估 |
| 群组 | 同一实体有多行 | 群组过少或不均衡 | 每组行数、组级标签分布 |
from collections import defaultdict
def group_split(rows, group_key, train_groups, valid_groups):
train, valid, overlap = [], [], set()
train_groups = set(train_groups)
valid_groups = set(valid_groups)
overlap = train_groups & valid_groups
if overlap:
raise ValueError(f"群组重叠: {sorted(overlap)}")
for row in rows:
group = row[group_key]
if group in train_groups:
train.append(row)
elif group in valid_groups:
valid.append(row)
return train, valid
rows = [
{"user": "a", "at": 1, "label": 0},
{"user": "a", "at": 2, "label": 1},
{"user": "b", "at": 3, "label": 0},
{"user": "c", "at": 4, "label": 1},
]
train, valid = group_split(rows, "user", {"a"}, {"b", "c"})
assert {row["user"] for row in train}.isdisjoint({row["user"] for row in valid})
print(train, valid)时间切分可以先排序,再按截止时间取样,但标签窗口可能使最后一段样本尚未成熟。例如要预测未来七天退款,距离数据末尾不足七天的订单不能直接标成负例。它们应该被暂存为“标签未到达”,而不是混入测试集。切分脚本应输出每段的最早/最晚时间、行数、群组数和标签比例,形成可审计的快照。
一个实用的选择顺序
先问是否预测未来;是,就优先考虑时间切分。再问同一实体是否多行;是,就加入群组约束。最后问少数类是否稀少;是,就在不破坏时间/群组边界的前提下做分层或扩大评估窗口。不要为了使用某个熟悉的函数而反过来迁就数据。
分别为“预测下周服务器故障”“给新用户推荐文章”“识别已知用户的异常登录”选择切分策略,并写出一个不能使用的策略及原因。
4.2.3 数据泄漏:预处理也会偷看答案
数据泄漏是指评估流程使用了在真实预测时不可获得的信息。最明显的泄漏是把标签或标签的后续字段放入特征;更隐蔽的泄漏来自预处理。用全体数据计算均值和标准差、用全体样本选择最高相关特征、先对全量文本建立词表,都会让验证或测试分布影响模型。即使没有直接读取标签,边界也已经被破坏。
正确的顺序是:先按实体/时间切分,再只在训练集上拟合预处理参数,最后把已经拟合的转换应用到验证和测试。缺失值填补、标准化、类别编码、词袋词表、PCA、特征选择和异常值截断都属于可能“学习参数”的步骤。把它们放进 Pipeline,可以让交叉验证的每一折都只用该折训练部分拟合,减少手工遗漏。
泄漏往往表现为不合理的好分数:小数据集几乎满分、加入一个字段后突然跃升、随机切分远高于时间切分、线上上线后迅速下降。发现这种信号时,不要马上庆祝;应逐列追踪来源、做时间穿越检查、用打乱标签的负对照,并建立一个完全独立的审计脚本。低分但诚实的结果,比高分但不可复现的结果更有价值。
| 泄漏模式 | 错误写法 | 修复方式 | 验证信号 |
|---|---|---|---|
| 全量标准化 | 切分前计算均值/方差 | 训练集拟合,其他集 transform | 比较两种流程的分数差 |
| 全量特征选择 | 用所有标签选列 | 选择步骤放进 CV/Pipeline | 嵌套验证分数更保守 |
| 未来字段 | 使用结果写回字段 | 按预测时点截断 | 时间戳逐列审计 |
| 实体重复 | 同用户跨训练/测试 | 按群组切分/去重 | 群组交集为空 |
from datetime import datetime, timezone
def assert_no_future_features(rows, feature_time_key, prediction_time_key):
violations = []
for row in rows:
feature_time = row[feature_time_key]
prediction_time = row[prediction_time_key]
if feature_time > prediction_time:
violations.append(row["id"])
if violations:
raise AssertionError(f"发现未来特征: {violations}")
rows = [
{"id": "ok", "feature_at": datetime(2026, 7, 1, tzinfo=timezone.utc), "predict_at": datetime(2026, 7, 2, tzinfo=timezone.utc)},
{"id": "bad", "feature_at": datetime(2026, 7, 3, tzinfo=timezone.utc), "predict_at": datetime(2026, 7, 2, tzinfo=timezone.utc)},
]
try:
assert_no_future_features(rows, "feature_at", "predict_at")
except AssertionError as error:
print(error)一个可操作的泄漏审计包括四步。第一,给每个字段标注来源时间和生成责任人;第二,随机抽取样本,把预测时点、特征时间和标签时间画成时间线;第三,在完全不使用标签的情况下运行转换,比较训练/验证统计量;第四,用打乱标签的负对照确认流程不会凭空得到高分。审计应在模型更换、数据表升级和线上回灌时重复。
下面流程先对全部数据做标准化,再切分训练集和测试集。请说明为什么不严谨,并写出修正后的伪代码顺序:scaler.fit_transform(X); train_test_split(X, y)。
X_train, X_test,再执行 scaler.fit(X_train)、scaler.transform(X_train) 和 scaler.transform(X_test);如果有交叉验证,应把 scaler 放进 Pipeline,让每折独立拟合。| 能力 | 最低证据 | 迁移到大模型时的用法 |
|---|---|---|
| 解释三份数据 | 能说出每份数据的唯一职责 | 区分开发集、评测集和线上反馈 |
| 选择切分 | 依据数据生成过程选择策略 | 按文档、查询或时间隔离评测 |
| 识别泄漏 | 能找出预处理偷看边界的位置 | 防止答案、未来文档进入提示上下文 |
| 写审计 | 输出时间、群组、比例和版本 | 让离线分数能被第三方复核 |
把切分结果当成一份数据产品
切分脚本输出的不应只有三个数组,还应有一份摘要:每份数据的行数、唯一实体数、最早和最晚时间、标签比例、缺失率、近重复数量以及彼此的 ID 交集。摘要保存为 JSON 后,下一次数据更新可以直接做差异比较。若训练集增加了很多旧样本、测试集的正类比例突然变化或群组交集不再为空,流水线应在训练前失败。
切分还决定不确定性如何传播。一次随机切分得到的指标只是一个抽样结果;多折、滚动窗口和群组重采样能展示结果在不同证据上的变化。报告平均值时同时给出标准差、最小/最大折和样本支持数,避免把小数点后的差异写成确定结论。对少数类,甚至应列出每折 TP、FP、FN,而不只是一个宏平均。
线上反馈有成熟延迟,不能把最新请求立即当作负例。可以设置标签冻结日:只把预测窗口已经结束且标签已到达的样本纳入下一轮评估,未成熟样本保存在 pending 区域。这样时间切分、指标计算和数据回灌都有一致的截止逻辑。
| 字段 | 示例 | 异常信号 | 处理 |
|---|---|---|---|
| 行数/实体数 | train 800/620 | 实体数明显少于行数 | 检查重复与群组粒度 |
| 时间范围 | 2025-01 至 2026-06 | 测试早于训练 | 直接失败 |
| 标签比例 | 0.18/0.17/0.19 | 某折没有正类 | 扩大窗口或分层 |
| 群组交集 | 0 | 大于 0 | 停止训练并修切分 |
| 近重复数 | train/test=0 | 测试出现训练哈希 | 去重或改评估 |
| 未成熟标签 | pending 43 | 被计入负类 | 延后评估 |
def summarize(name, rows, group_key="group", label_key="label"):
groups = {row[group_key] for row in rows}
labels = [row[label_key] for row in rows if row[label_key] is not None]
positive_rate = sum(labels) / len(labels) if labels else None
return {"name": name, "rows": len(rows), "groups": len(groups), "positive_rate": positive_rate, "pending": len(rows) - len(labels)}
def assert_boundaries(train, valid, test):
sets = [{row["group"] for row in rows} for rows in (train, valid, test)]
for left in range(len(sets)):
for right in range(left + 1, len(sets)):
overlap = sets[left] & sets[right]
if overlap:
raise AssertionError(f"群组泄漏: {sorted(overlap)}")
train = [{"group": "a", "label": 0}, {"group": "b", "label": 1}]
valid = [{"group": "c", "label": 0}]
test = [{"group": "d", "label": None}]
assert_boundaries(train, valid, test)
print([summarize(name, rows) for name, rows in (("train", train), ("valid", valid), ("test", test))])这份摘要可以直接嵌进实验日志,成为模型之外的证据。切分逻辑发生变化时,即使模型代码完全不变,也应提高实验版本并重新报告。对大模型的离线评测同样如此:文档版本、查询集合、参考答案和评测时间都应进入摘要,不能只保存一个最终分数。
写出至少五个应让训练流水线立即失败的条件,覆盖时间、群组、标签、近重复和样本量。为每个条件指定错误信息中必须包含的上下文。
本节小结:评估首先是边界设计
切分方案不是一个函数调用,而是对未来数据生成过程的模拟。随机切分模拟“任意样本都可能出现”;时间切分模拟“用过去预测未来”;群组切分模拟“面对一个新用户或新文档”。当任务同时有时间和群组约束时,宁可减少可用样本,也不要用不诚实的分数换取漂亮结果。
所有会从数据中学习参数的步骤都要放进训练边界:填补、标准化、词表、特征选择、PCA、阈值校准都包括在内。一个可靠的 Pipeline 能减少遗漏,但不能替你决定数据边界。每次实验都保存切分摘要和泄漏审计结果,才能知道模型变好还是评估被污染。
| 问题 | 是/否 | 证据位置 |
|---|---|---|
| 测试集是否从未用于选择配置 | □ | 实验日志与访问记录 |
| 同一实体是否只在一个集合 | □ | 群组交集报告 |
| 测试时间是否晚于训练时间 | □ | 时间范围摘要 |
| 预处理是否只在训练折拟合 | □ | Pipeline/CV 配置 |
| 未成熟标签是否被排除 | □ | pending 数量 |
manifest = {
"split": "time_then_group",
"train_end": "2026-05-31T23:59:59Z",
"valid_end": "2026-06-30T23:59:59Z",
"test_start": "2026-07-01T00:00:00Z",
"group_overlap": 0,
"pending_excluded": 43,
"fit_steps": ["imputer", "scaler", "selector", "model"],
}
assert manifest["group_overlap"] == 0
assert manifest["pending_excluded"] >= 0
print("boundary manifest is auditable")模型换成新版本后,随机切分 F1 从 0.62 升到 0.94,但时间切分只从 0.58 升到 0.60。列出优先审计的三件事。
切分后的最后一步:人工抽样核对
随机抽取每个集合的若干行,人工核对样本 ID、预测时点、特征来源和标签成熟状态。机器检查能发现交集和比例,却不一定看出一条事件被错误地归到另一个用户。把核对结果记录成金样本,下一次重跑即可自动比较。
当数据规模增加时,优先扩展审计摘要和失败条件,而不是只提高并行度。评估的可信度来自边界可解释、统计可复算和错误可定位;速度只是工程约束,不能替代证据。
如果切分后发现训练与测试分布差异很大,先描述差异再决定是否修复。可能是未来真实变化,也可能是过滤条件不一致;贸然重新采样会破坏评估对象。把“保留差异还是调整数据”的理由写入实验记录,下一次看到同样现象时就不必从头争论。
最后把“测试集”当成一种承诺而不是文件名:在方案冻结前不读取它,在报告中注明访问时间和快照哈希,之后任何改动都生成新的测试快照。这样分数才有明确的语境,读者也能判断一次提升到底来自模型、数据还是评估边界。若必须重新查看测试集,先记录原因并把它降级为开发集,同时准备一份全新的未读快照,不能继续把旧数字称为最终结果。
当线上标签陆续到达时,采用滚动窗口比一次性重训更容易解释。每个窗口保存训练截止、标签成熟截止、评估截止和反馈覆盖率;窗口之间只比较相同定义的指标。这样既能观察概念漂移,也能避免“新数据还没有标签”造成的假退化。切分和标签脚本应该在数据管道中共享同一套时区与时间函数。
对文档问答评测,可把文档发布时间、查询发布时间和答案生成时间也纳入同一时间线;没有时间戳的样本不应默认为“最早可用”。边界检查的价值就在于让每个分数都有明确的证据来源。
03 · REGRESSION
回归:用连续数值建立第一条可解释的基线
回归预测的是连续量,例如延迟、价格、用时或评分。线性回归的价值不只是“简单”,而是把特征、权重、残差和损失放在一张可检查的纸上。先理解这条基线,才能知道复杂模型究竟增加了什么。
4.3.1 线性回归:一条直线如何成为模型
回归的目标是预测连续数值。最简单的线性模型写成 ŷ = w₀ + w₁x:w₀ 是截距,w₁ 是特征每增加一个单位时预测值的变化率。直线不是在宣称世界真的线性,而是在用一个低复杂度假设建立可比较的起点。只要明确输入单位、输出单位和适用范围,线性模型就能成为一张很有用的“解释地图”。
训练过程是在寻找一组参数,使预测与真实标签的差异尽量小。常见的均方误差(MSE)是所有残差平方的平均值,平方会放大大错误,因此对离群点敏感;平均绝对误差(MAE)按绝对值计算,单位与标签相同,更容易解释。选择损失函数会改变模型关注的错误类型,不能因为库的默认值熟悉就跳过讨论。
一元线性回归可以手算斜率,也可以用梯度下降逐步逼近。手算公式在小数据上帮助理解相关性与尺度,库实现则提供稳定的求解器和多特征支持。学习时建议先用四五个点打印每轮参数,再用 scikit-learn 的 LinearRegression 比较结果;若两者差异很大,先检查特征中心化、偏置项和停止条件。
| 符号 | 含义 | 单位/形状 | 调试问题 |
|---|---|---|---|
x | 输入特征 | 一个样本或一列特征 | 是否在预测时可用 |
y | 真实连续标签 | 与业务目标同单位 | 是否存在延迟或截断 |
w | 特征权重 | 与特征数一致 | 尺度改变时如何解释 |
b | 截距/偏置 | 一个标量 | 是否包含了基线水平 |
ŷ | 预测值 | 与 y 同单位 | 是否超出合理范围 |
def fit_line(xs, ys):
if len(xs) != len(ys) or len(xs) < 2:
raise ValueError("需要至少两个配对样本")
x_mean = sum(xs) / len(xs)
y_mean = sum(ys) / len(ys)
numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys))
denominator = sum((x - x_mean) ** 2 for x in xs)
if denominator == 0:
raise ValueError("特征没有变化,无法确定斜率")
weight = numerator / denominator
bias = y_mean - weight * x_mean
return weight, bias
def predict(xs, weight, bias):
return [weight * x + bias for x in xs]
xs = [1.0, 2.0, 3.0, 4.0]
ys = [2.1, 3.9, 6.2, 8.0]
w, b = fit_line(xs, ys)
print({"weight": round(w, 3), "bias": round(b, 3), "pred": predict([5], w, b)})公式中的分子是特征与标签的共同变化,分母是特征自身的变化。特征几乎不变时,斜率会对噪声非常敏感;把所有值都乘以 100,预测曲线可能不变,但权重会缩小 100 倍,所以不能脱离单位比较权重大小。多特征时还会出现共线性:两列携带相似信息,单独解释某一个权重就不再可靠。
条形长度代表模型给出的连续预测;真实值与条形末端的差异就是残差。画出几条具体残差,通常比只看一个平均分更容易发现系统性偏差。
易错:把回归系数读成因果效应
线性回归中的权重描述在当前数据和假设下的条件关联,不自动表示“把特征增加一单位就会造成标签增加多少”。混杂变量、选择偏差、反向因果和测量误差都会破坏因果解释。若问题需要干预效果,应设计实验或使用明确的因果方法;本章的回归只负责建立预测基线。
用 x=[0,1,2]、y=[1,3,5] 手算斜率和截距,写出对 x=4 的预测。再把所有 x 乘以 10,观察预测是否改变、权重如何变化。
y=2x+1,预测为 9。缩放特征后,为保持同一预测,权重变为原来的十分之一;这说明权重大小必须结合单位解释。4.3.2 误差、残差与均方误差
残差定义为 eᵢ = yᵢ - ŷᵢ,正值表示模型低估,负值表示高估。把残差按样本列出来,能看到平均分隐藏的结构:某一时间段持续低估、某个群组误差更大、预测越大误差越分散,都是需要进一步调查的信号。平均残差接近零并不意味着模型准确,因为正负误差可能相互抵消。
MAE、MSE 和 RMSE 各有侧重。MAE 与标签同单位,解释为“平均偏离多少”;MSE 适合在数学上平滑地惩罚大错误,但单位变成平方;RMSE 开平方后回到标签单位,同时仍然更重视大错误。若业务对极端失误特别敏感,RMSE 可能有用;若每个单位偏差成本近似相同,MAE 常更直观。报告时应同时给出分布和典型样本。
指标必须与数据切分绑定。先在训练集拟合,再在验证/测试计算;不要为了让 RMSE 好看而删掉测试集上的坏样本。对于不同量纲的多个目标,不能直接把 RMSE 相加,除非先定义权重或标准化。对数变换、截断和加权损失也会改变“一个错误值”的含义,需要在报告里说明。
| 指标 | 公式直觉 | 优点 | 要警惕 |
|---|---|---|---|
| MAE | mean(|y-ŷ|) | 单位直观、较稳健 | 不特别强调大错 |
| MSE | mean((y-ŷ)²) | 平滑、便于优化 | 被离群点放大 |
| RMSE | sqrt(MSE) | 回到原单位 | 仍偏重大错误 |
| MAPE | 相对百分比误差 | 便于跨尺度沟通 | 真实值接近零时失真 |
| R² | 相对均值基线的解释量 | 可比较基线提升 | 不等于绝对误差小 |
from math import sqrt
def residual_report(actual, predicted):
if len(actual) != len(predicted) or not actual:
raise ValueError("长度必须一致且不能为空")
residuals = [a - p for a, p in zip(actual, predicted)]
mae = sum(abs(error) for error in residuals) / len(residuals)
mse = sum(error * error for error in residuals) / len(residuals)
return {
"residuals": residuals,
"mean_error": sum(residuals) / len(residuals),
"mae": mae,
"rmse": sqrt(mse),
"worst_index": max(range(len(residuals)), key=lambda i: abs(residuals[i])),
}
report = residual_report([10, 12, 20, 11], [11, 11, 15, 12])
print(report)
assert report["worst_index"] == 2残差报告中的 worst_index 只是定位入口,不是自动删除异常的理由。先查看对应样本是否录入错误、是否属于模型不覆盖的群组、是否发生分布变化;如果它是业务真正关心的极端情况,就应保留并考虑加权、分段模型或更合适的损失。评估的职责是暴露问题,不是把问题从数据里擦掉。
看三张图:残差对预测、残差对时间、残差对群组
残差对预测值呈漏斗状,说明误差随规模增大;残差对时间有趋势,可能是概念漂移或新版本字段;群组箱线图差异大,可能是采样不均衡或某个群组缺少特征。即使暂时不画图,也可以按分桶输出均值、MAE、样本数和分位数,先把结构数字化。
比较两组残差:A 为 [1,1,1,1],B 为 [0,0,0,4]。计算 MAE 和 RMSE,说明哪个指标更能表达“不能出现一次大错”的业务。
4.3.3 多特征、基线与可解释性
多元线性回归把一条直线扩展为超平面:ŷ = b + w₁x₁ + … + wₚxₚ。每个权重表示“在其他特征保持不变的线性近似下,特征增加一单位时预测怎样变化”。这个“其他特征保持不变”在高度相关的特征之间可能没有现实意义,因此解释时要报告相关性、单位和取值范围,而不能只按权重绝对值排名。
类别特征需要明确编码。把“初级、中级、高级”直接编码成 0、1、2,隐含了等距和顺序假设;把城市编码成 1、2、3 则通常没有意义。独热编码能避免伪顺序,但会增加列数;目标编码可能带来标签泄漏,必须只在训练折内计算。无论用哪种编码,都要保存列顺序、未知类别策略和缺失处理。
可解释性有三层。全局层面看基线、整体指标和权重方向;局部层面查看一个样本的特征贡献与残差;反事实层面问“如果这个特征改变,预测会怎样”。线性模型的局部变化容易计算,但不代表干预因果;对超出训练范围的反事实尤其谨慎。解释报告应同时给出不确定性、样本支持范围和不可推断的内容。
| 现象 | 可以说 | 不能直接说 | 补充证据 |
|---|---|---|---|
| 权重为正 | 在模型条件下同向关联 | 增加它一定造成结果增加 | 实验/因果设计 |
| 权重很大 | 该单位下变化敏感 | 它比所有特征更重要 | 标准化、置换重要性 |
| 相关性高 | 可能携带相似信息 | 两列都可独立解释 | 共线性诊断 |
| 测试误差小 | 在该切分上泛化较好 | 未来永远稳定 | 时间滚动、分组评估 |
def dot(weights, features, bias=0.0):
if len(weights) != len(features):
raise ValueError("权重和特征维度不一致")
return bias + sum(weight * value for weight, value in zip(weights, features))
features = [
[30.0, 0.8, 2.0], # 阅读分钟、完成比例、练习次数
[10.0, 0.4, 0.0],
[50.0, 0.9, 4.0],
]
labels = [88.0, 42.0, 96.0]
weights = [0.8, 25.0, 3.0]
predicted = [dot(weights, row, bias=5.0) for row in features]
for index, (actual, estimate) in enumerate(zip(labels, predicted)):
print(index, {"actual": actual, "predicted": round(estimate, 2), "error": round(actual-estimate, 2)})
assert len(predicted) == len(labels)示例权重没有经过真正训练,只是为了展示形状和解释流程。正式实验应把权重来源、训练算法、正则化和特征缩放写入配置;若换一套单位,必须重新拟合或同步转换。对高维稀疏特征,权重表可以帮助审阅,但不要把一次训练得到的正负号当作稳定规律,最好在多个时间折和重采样中报告方向一致性。
回归基线的验收清单
能复现均值基线;能输出 MAE、RMSE 和残差分位数;能按时间、群组和标签区间分桶;能指出最大误差样本及其来源;类别编码与缩放只在训练边界内拟合;每个权重有单位和取值范围;模型复杂度增加后必须与基线同一评估集比较。完成这些检查,线性模型才真正发挥了“可解释基线”的作用。
假设模型中 reading_minutes_7d 的权重为 0.6,单位是分钟;测试 MAE 为 4.2。用两句话向非技术同事解释结果:第一句描述模型条件下的关联,第二句明确不能据此推出什么。
| 能力 | 最低证据 | 迁移到大模型项目的用法 |
|---|---|---|
| 读懂线性模型 | 能解释偏置、权重和预测形状 | 理解线性层与 Embedding 投影 |
| 选择回归指标 | 能说明 MAE/RMSE 的代价差异 | 设计生成质量与延迟的复合指标 |
| 检查残差 | 能按时间/群组定位系统误差 | 发现提示模板或文档类型偏差 |
| 守住解释边界 | 区分关联、预测和因果 | 避免把相关 token 当成原因 |
回归的工程检查:先确认单位,再看分数
在回归任务中,单位和量级决定指标如何被理解。预测阅读分钟时,MAE=4 表示平均偏差约四分钟;预测价格时,MAE=4 可能毫无意义。报告应同时给出标签分位数、基线误差、模型误差和相对改善,并说明是否做过对数变换、截断或加权。不要把不同目标的 RMSE 直接横向比较,除非已经规定了共同尺度。
残差也要与样本分组绑定。总体 MAE 低,可能是大量简单样本掩盖了少数高价值样本的巨大误差;按资料类型、用户新旧、时间段和标签区间分桶,能发现模型在哪些条件下失灵。每个桶同时报告样本数、MAE、RMSE、偏差方向和分位数,样本太少时标记为不稳定而不是给出过度精确的结论。
训练回归模型时,先画出基线与当前模型的残差,再决定是否加入特征或非线性。若残差随输入呈明显曲线,线性假设不足;若残差只在极端值变差,可能需要稳健损失或分段模型;若所有群组都偏高,检查截距和标签偏移。每个诊断都应配一个最小可复现实验。
| 分桶 | 示例 | 要看什么 | 可能动作 |
|---|---|---|---|
| 标签区间 | 低/中/高分位 | 误差是否随规模变大 | 对数变换、分段模型 |
| 时间段 | 月份/周几 | 漂移和季节性 | 滚动训练、加入时间特征 |
| 实体状态 | 新用户/老用户 | 冷启动误差 | 单独基线、补充历史 |
| 资料类型 | 教程/项目/新闻 | 语义与长度差异 | 分组模型或特征交互 |
| 预测区间 | 负值/合理范围外 | 约束是否被违反 | 截断、变换或检查标签 |
from collections import defaultdict
def bucket(value):
if value < 30:
return "low"
if value < 70:
return "middle"
return "high"
def grouped_mae(rows):
errors = defaultdict(list)
for row in rows:
errors[bucket(row["actual"])].append(abs(row["actual"] - row["predicted"]))
return {name: {"count": len(values), "mae": sum(values) / len(values)} for name, values in errors.items()}
rows = [
{"actual": 20, "predicted": 23}, {"actual": 25, "predicted": 21},
{"actual": 80, "predicted": 65}, {"actual": 90, "predicted": 72},
]
report = grouped_mae(rows)
print(report)
assert set(report) == {"low", "high"}分桶并不等于为每个桶都训练一个模型。先用它定位问题,再评估是否有足够样本、稳定特征和可维护的行动方案。若高值区间误差大但样本很少,可以报告置信区间或采用加权损失;若某类型始终偏低,优先修正标签和特征时间,而不是在模型后面硬加补丁。
一个模型总体 MAE=3.0,但新用户 MAE=8.5、老用户 MAE=2.1。列出三种可能原因,并给出一个能区分它们的实验。
本节小结:让残差指导下一次实验
线性回归最重要的产出不只是一个 w 和 b,而是一套可以复用的诊断流程:先与均值基线比较,再查看 MAE、RMSE、平均偏差和分位数;随后按预测区间、时间、实体状态和资料类型分桶;最后抽查最大错误并判断是数据、标签、特征还是模型假设的问题。这个流程也适用于神经网络,只是参数更多、曲线更长。
解释权重时同时写单位和范围。权重的绝对值会受到特征缩放影响,高度相关的列会让单列解释不稳定,超出训练范围的反事实可能没有数据支持。若目标是做行动,先把预测关联与因果干预分开,再决定是否需要实验设计。诚实的限制说明不会削弱模型,反而使下一轮工作更聚焦。
| 写作对象 | 推荐表达 | 避免表达 |
|---|---|---|
| 整体误差 | 测试集 MAE 为 X,基线为 Y | 模型很准 |
| 偏差方向 | 高值区间平均低估 X | 模型不喜欢高值 |
| 特征权重 | 单位变化下的条件关联 | 特征造成结果变化 |
| 限制 | 新用户/未来窗口未验证 | 对所有人都有效 |
def acceptance(baseline_mae, model_mae, group_reports, max_gap):
improvement = baseline_mae - model_mae
worst_gap = max(report["mae"] - model_mae for report in group_reports)
return {
"improvement": improvement,
"passes_overall": improvement > 0,
"passes_group_gap": worst_gap <= max_gap,
}
groups = [{"name": "new", "mae": 6.0}, {"name": "returning", "mae": 2.5}]
result = acceptance(5.0, 3.0, groups, max_gap=4.0)
print(result)
assert result["passes_overall"] is True
# 练习:要求每个群组的 MAE 都不超过基线,重写验收条件基线 MAE=5.0,新模型 MAE=3.8,但高价值样本区间 MAE 从 4.2 变成 7.1。设计一个是否上线的条件,说明你会把哪个群组放进守护指标。
回归的最后一问:误差是否值得承担
模型给出的预测若要触发资源安排、内容排序或告警,误差需要翻译成行动后果。把 MAE=3 写成“平均偏差 3 个单位”还不够,还要说明错在什么样本、谁承担代价、是否可以人工修正,以及高值或少数群组是否被系统性低估。将技术指标和行动成本并列,是从教程走向项目的关键一步。
在模型升级前,固定一组回归金样本与基线输出。升级后先比较形状、单位、边界和预测方向,再比较整体与分组指标。若预测曲线变得更复杂但错误样例没有改善,优先回退或简化;若改善集中在一个小群组,报告覆盖范围和样本量,不要把局部收益写成全局规律。
| 门槛 | 示例 | 证据 |
|---|---|---|
| 整体误差 | MAE 比基线降低 ≥10% | 同一测试快照 |
| 高代价区间 | 高值样本 RMSE 不恶化 | 分桶报告 |
| 偏差方向 | 平均误差绝对值低于阈值 | 残差统计 |
| 资源预算 | 延迟/内存在上限内 | 基准日志 |
补充:先检查目标的边界
如果标签只能取非负值,而线性模型给出负预测,不要先用 max(0, prediction) 掩盖问题。先检查训练范围、变换和标签定义;截断是一个明确的建模决策,会改变残差分布。对数目标、广义线性模型或带约束的预测器都可以作为候选,但必须在同一验证协议下比较。
回归结果还需要一个误差预算:总体允许多少,关键群组允许多少,单个极端错误怎样处理。预算写清后,模型迭代就能从“感觉更平滑”变成“是否满足门槛”,也能为后续神经网络的损失设计提供依据。
如果你只能记住一个回归习惯,就是“先看误差,再看模型”。先问误差在哪些样本、时间段和群组集中,再决定是否要增加特征、换损失或提高复杂度;模型名称永远排在诊断之后。把每次改变和前后残差表一起保存,线性基线就能成为长期的质量仪表盘。特别要保留最大误差和典型误差两组案例:前者暴露风险上限,后者说明大多数请求的体验。
在真实项目中,标签可能被四舍五入、截断或人工修订,误差下限因此不是零。对这些规则要有单独的字段和版本,不能把标签噪声算成模型失误。若模型在可观测噪声范围内已经接近基线极限,下一步应改善数据收集或任务定义,而不是继续堆叠参数。
回归基线还可以帮助发现标签本身的上限。若人工标注者之间的差异已经接近模型误差,继续调参未必能带来可靠收益;此时更值得改进标注指南、收集上下文或把任务改成区间预测。把标注一致性和模型误差放在同一份报告里,才能知道“模型不够好”还是“目标本来就含有不可消除的不确定性”。
回归诊断:误差形状与预测范围同样重要
回归报告不能只给一个平均误差,还要观察残差随预测值、时间和群组如何变化。若残差在预测值变大时呈扇形,说明方差可能随规模增长;可以考虑对目标取对数、使用加权损失,或把预测区间而不是单点作为输出。若残差呈周期性,说明时间特征或季节性没有表达;若某个群组持续偏高,说明整体平均值掩盖了系统性偏差。
还要区分插值和外推。训练数据覆盖阅读时长 0 到 60 分钟时,模型对 300 分钟样本的预测属于外推,线性关系未必仍成立。发布前应给输入设定合理范围,对超出范围的请求返回提示、回退到规则或进入人工队列。预测区间可以用交叉验证残差、分位数模型或校准后的误差分布估计,但必须说明覆盖率、数据窗口和适用前提,不能把一个点估计包装成确定事实。
| 残差现象 | 可能原因 | 优先检查 | 谨慎动作 |
|---|---|---|---|
| 随预测值呈扇形 | 异方差或量纲跨度大 | 目标变换、分桶误差 | 报告分位数/区间 |
| 按时间成波纹 | 季节性、漂移或缺失时间特征 | 滚动窗口与时间特征 | 不要随机打乱掩盖变化 |
| 某群组同向偏差 | 样本代表性或标签规则不同 | 分组 MAE、样本量 | 先查数据再调权重 |
| 边界外误差暴涨 | 外推、截断或异常输入 | 训练范围与输入校验 | 回退并记录拒绝率 |
把诊断结果和模型系数放在同一份报告中,读者才能知道“平均上差不多”是否掩盖了局部风险。对大模型相关的连续评分,也可以按答案长度、检索文档数和时间窗口分桶查看误差;当某个桶样本很少时,明确标注不确定性,不要把偶然差异写成结论。
04 · CLASSIFICATION
分类:从分数到概率,再到可承担的决策
分类模型输出的往往不是最终答案,而是一个分数或概率。把概率阈值设为 0.5 只是默认动作,不是自然法则。本节从二分类和 logistic 函数出发,完整解释混淆矩阵、准确率、精确率、召回率、F1、ROC、PR 与阈值选择。
4.4.1 二分类与 logistic 概率
二分类的标签通常写成 0 和 1,例如“不收藏/收藏”“正常/异常”。模型先计算一个可排序的分数 z,再用 logistic 函数 σ(z)=1/(1+e⁻ᶻ) 把它压到 0 到 1。这个数可以被当作正类的概率样式,但只有经过校准和数据假设检验后,才适合直接解释成“发生概率”。先把分数、概率和最终类别三个对象分开,能避免大量沟通错误。
logistic 回归的线性部分是 z=b+w·x,输出概率后用阈值决定类别。阈值 0.5 对应正负错误代价相同、概率已经可信且类别先验没有特殊约束的默认情形;现实任务往往不满足这些条件。模型可以在阈值 0.2 时更重视召回,在阈值 0.8 时减少误报,同一套概率因此可以服务不同的业务策略。
分类学习还要先检查标签是否互斥、是否完整、是否存在多标签。把“既是教程又是新闻”的文本强行压成单一类别,会让标签噪声被误认为模型能力不足。若正类比例只有 1%,一个总是预测 0 的模型也有 99% 准确率;这正是为什么分类不能只看 accuracy。
| 层次 | 示例 | 用途 | 常见误读 |
|---|---|---|---|
| 线性分数 | z=-1.7 | 排序、分析方向 | 当作概率 |
| 概率样式 | p=0.15 | 风险排序、成本计算 | 一定真实发生 15% |
| 类别决策 | p≥τ 得 1 | 触发动作 | 阈值永远是 0.5 |
| 解释/行动 | 人工复核或放行 | 落实业务流程 | 模型替代全部判断 |
from math import exp
def sigmoid(score):
if score >= 0:
value = exp(-score)
return 1 / (1 + value)
value = exp(score)
return value / (1 + value)
def predict_probability(features, weights, bias):
if len(features) != len(weights):
raise ValueError("特征与权重维度不一致")
score = bias + sum(x * w for x, w in zip(features, weights))
return sigmoid(score)
weights = [0.08, 1.2, -0.7]
for row in ([20, 0.8, 1], [5, 0.2, 0]):
probability = predict_probability(row, weights, bias=-2.0)
print(round(probability, 4))
assert 0 < probability < 1数值稳定性也属于基础。直接计算很大的 exp(-score) 可能溢出,所以示例按分数正负选择等价公式。使用成熟库时,仍要知道它返回的是哪一列概率、类别顺序怎样、是否做了校准。模型输出中保存原始分数、概率和阈值,日后调整行动策略时就不必重新训练。
易错:把类别编码当作大小
二分类的 0/1 编码只是方便计算,不代表负类“比正类小”。多分类标签的整数编码尤其不能直接交给线性模型当作连续值;应使用 one-hot、独热或适合任务的目标表示。标签映射表必须随模型版本保存,否则线上输出的类别名称可能错位。
给出三个分数 [-2, 0, 2],计算 logistic 概率。分别用阈值 0.5 和 0.8 生成类别,记录哪些样本的行动发生变化。
4.4.2 混淆矩阵、精确率、召回率与 F1
混淆矩阵把预测和真实标签交叉起来:真正例 TP、假正例 FP、假负例 FN、真负例 TN。它比单一分数更接近业务语言:漏掉一个真正异常是 FN,误拦截一个正常用户是 FP。先列出四个数,再计算指标,能让团队明确争论的是哪一种错误,而不是陷入“准确率到底高不高”的空泛讨论。
准确率是 (TP+TN)/总数,在类别均衡且错误代价近似相同时才有代表性。精确率 TP/(TP+FP) 回答“判为正的里面有多少是真的”;召回率 TP/(TP+FN) 回答“所有真正的正类有多少被找出”。F1 是精确率与召回率的调和平均,任何一方很低都会拉低结果,但它仍然没有表达 FP 与 FN 的具体成本。
少数类任务还要报告支持数。一个测试集只有两个正例,召回率从 0 到 1 的变化可能只是一个样本;不报告置信区间和绝对数量,容易过度解读小数点。分层、按时间和按群组评估也要分别给出混淆矩阵,整体平均可能掩盖某一群组几乎无法使用的结果。
| 名称 | 公式 | 回答 | 什么时候重要 |
|---|---|---|---|
| TP | 预测 1、真实 1 | 正确抓到正例 | 成功案例计数 |
| FP | 预测 1、真实 0 | 误报/误拦截 | 人工成本、用户体验 |
| FN | 预测 0、真实 1 | 漏报/漏检 | 安全、风险和机会损失 |
| TN | 预测 0、真实 0 | 正确放行 | 整体负担与基准 |
| F1 | 2PR/(P+R) | 兼顾 P 与 R | 需要单数值排序时 |
def confusion(actual, predicted):
if len(actual) != len(predicted):
raise ValueError("标签长度不一致")
matrix = {"tp": 0, "fp": 0, "fn": 0, "tn": 0}
for truth, estimate in zip(actual, predicted):
key = {(1, 1): "tp", (0, 1): "fp", (1, 0): "fn", (0, 0): "tn"}.get((truth, estimate))
if key is None:
raise ValueError("标签必须是 0 或 1")
matrix[key] += 1
return matrix
def metrics(matrix):
tp, fp, fn, tn = (matrix[key] for key in ("tp", "fp", "fn", "tn"))
precision = tp / (tp + fp) if tp + fp else 0.0
recall = tp / (tp + fn) if tp + fn else 0.0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
accuracy = (tp + tn) / (tp + fp + fn + tn)
return {"precision": precision, "recall": recall, "f1": f1, "accuracy": accuracy}
matrix = confusion([1, 1, 0, 0, 1], [1, 0, 1, 0, 1])
print(matrix, metrics(matrix))实现指标时要显式处理分母为零。没有预测正例时,精确率没有自然定义;评估库通常按约定返回 0、发出警告或允许配置。学习阶段最好自己写一版,再与库函数对照,确认正类标签、pos_label、平均方式(binary、macro、micro、weighted)和样本权重都符合任务。
宏平均与加权平均不是装饰
多分类时,macro-F1 先为每个类别计算 F1 再平均,所有类别权重相同;weighted-F1 按各类支持数加权,容易被大类主导;micro-F1 汇总所有决策后计算,更接近总体样本层面的表现。类别不平衡时同时报告 macro、weighted 和每类指标,才能看见少数类是否被牺牲。
某风控模型在 1000 条样本上得到 TP=36、FP=24、FN=14、TN=926。计算准确率、精确率、召回率和 F1;假设 FN 成本是 FP 的 5 倍,说明是否应该只追求准确率。
4.4.3 阈值、ROC/PR 与概率校准
一个分类器通常产生连续分数,阈值扫描会得到一系列混淆矩阵。ROC 曲线以假正例率 FPR 为横轴、真正例率 TPR(也就是召回率)为纵轴;它回答模型在不同阈值下排序能力如何。PR 曲线以召回率和精确率为核心,在正类稀少时往往更能呈现实际负担。AUC 是对整条曲线的汇总,不等于某一个部署阈值下的效果。
阈值选择要在验证集完成,并写出容量与成本约束。例如人工团队每天只能复核 200 条,阈值就要限制正类预测数量;如果漏检代价极高,可以先设最低召回率,再在可接受范围内最大化精确率。测试集只用于冻结阈值后的最终报告。调整阈值不需要重新训练,但会改变混淆矩阵、覆盖率和用户体验。
校准回答另一个问题:预测为 0.8 的样本,长期来看是否约有 80% 为正类。排序好不代表概率可信;模型可以有很高 AUC,却系统性高估或低估风险。可靠性图、分桶实际正例率、Brier score 和温度缩放/等距回归等校准方法,都应只在训练/验证边界内拟合。线上分布变化后,校准也可能失效。
| 工具 | 核心问题 | 适合场景 | 不能替代 |
|---|---|---|---|
| ROC-AUC | 正负排序是否稳定 | 类别比例较稳、需要全阈值比较 | 部署阈值成本 |
| PR-AUC | 抓到正类时有多准 | 正类稀少、关注告警质量 | 概率校准 |
| 可靠性图 | 概率是否对应频率 | 需要风险概率的决策 | 排序能力 |
| 阈值表 | 某阈值下的 TP/FP/FN/TN | 上线前定行动规则 | 跨数据集泛化 |
def confusion_counts(actual, probabilities, threshold):
predicted = [int(value >= threshold) for value in probabilities]
tp = sum(truth == 1 and guess == 1 for truth, guess in zip(actual, predicted))
fp = sum(truth == 0 and guess == 1 for truth, guess in zip(actual, predicted))
fn = sum(truth == 1 and guess == 0 for truth, guess in zip(actual, predicted))
tn = sum(truth == 0 and guess == 0 for truth, guess in zip(actual, predicted))
precision = tp / (tp + fp) if tp + fp else 0.0
recall = tp / (tp + fn) if tp + fn else 0.0
return {"threshold": threshold, "tp": tp, "fp": fp, "fn": fn, "tn": tn, "precision": precision, "recall": recall}
truth = [0, 1, 1, 0, 1, 0, 1, 0]
probabilities = [0.10, 0.35, 0.62, 0.55, 0.81, 0.20, 0.92, 0.44]
for threshold in (0.3, 0.5, 0.7):
print(confusion_counts(truth, probabilities, threshold))
# 练习:加入“最多告警 3 条”的约束并选阈值当概率存在大量并列值时,阈值附近的样本会反复改变类别;这不是 bug,而是提醒你把阈值当作政策配置。保存每次部署使用的阈值、评估窗口、正类比例、曲线和抽样案例,才能解释“模型没变但告警量变了”的现象。对多群组任务,还要检查各群组的阈值是否公平、是否受到样本量差异影响。
分类报告应该至少回答五句话
模型排序是否比基线好?在选定阈值下会产生多少 FP 和 FN?正类稀少时 PR 表现如何?预测概率是否经过校准?不同时间段和群组的错误是否一致?如果报告不能回答这五句,只给一个 accuracy 或 AUC,读者仍然无法判断模型是否可用。
沿用代码中的概率,假设每天只能处理 3 条告警。扫描候选阈值,找出预测为正不超过 3 条且召回率尽可能高的阈值;写出若正类成本上升时你会如何调整。
| 能力 | 最低证据 | 迁移到大模型项目的用法 |
|---|---|---|
| 读懂概率输出 | 区分分数、概率与类别 | 理解生成置信度与拒答门槛 |
| 解释混淆矩阵 | 能把 FP/FN 翻译成业务错误 | 评估幻觉、漏召回与误召回 |
| 选择阈值 | 在验证集按成本/容量确定 | 设置检索或安全策略门槛 |
| 检查校准 | 报告分桶实际频率 | 避免把模型分数当事实概率 |
把分类指标还原成一张行动表
分类报告最实用的形式通常不是一行 AUC,而是一张阈值—容量—错误成本表。每个候选阈值列出预测正例数、TP、FP、FN、TN、精确率、召回率、F1、人工复核量和预计成本。决策者可以在容量或成本变化时重新选择阈值,模型本身无需重新训练。表中还应标明评估时间窗、正类定义和样本支持数。
指标比较必须保持正类方向一致。若把“正常”编码成 1、把“异常”编码成 0,却仍然按异常计算精确率,报告会完全反向。多分类时说明 one-vs-rest 方式和 macro/weighted/micro 平均;多标签时一个样本可同时为多个正类,不能套用互斥类别的混淆矩阵。先写标签映射,再写指标代码。
概率校准可以用分桶表检查。把预测概率分成 0.0–0.1、0.1–0.2 等区间,统计每桶样本数、平均预测和真实正例率;样本少的桶应合并或标记不稳定。校准后的概率仍然受时间漂移和群组差异影响,线上要监控预测分布、实际反馈和阈值触发量。
| 字段 | 含义 | 决策问题 | 保存要求 |
|---|---|---|---|
| threshold | 分数转类别的门槛 | 何时触发动作 | 版本与生效时间 |
| positive_count | 预测正例数量 | 人工容量够吗 | 按窗口和群组 |
| precision/recall | 正例质量/覆盖 | FP 与 FN 哪个更贵 | 支持数与置信区间 |
| cost | 错误的加权成本 | 总体是否值得 | 成本假设版本 |
| calibration | 概率与实际频率 | 能否解释风险 | 分桶图/表 |
def decision_row(actual, probabilities, threshold, fp_cost=1.0, fn_cost=5.0):
predicted = [int(value >= threshold) for value in probabilities]
tp = sum(a == 1 and p == 1 for a, p in zip(actual, predicted))
fp = sum(a == 0 and p == 1 for a, p in zip(actual, predicted))
fn = sum(a == 1 and p == 0 for a, p in zip(actual, predicted))
tn = sum(a == 0 and p == 0 for a, p in zip(actual, predicted))
precision = tp / (tp + fp) if tp + fp else 0.0
recall = tp / (tp + fn) if tp + fn else 0.0
return {"threshold": threshold, "positive_count": sum(predicted), "tp": tp, "fp": fp, "fn": fn, "tn": tn, "precision": precision, "recall": recall, "cost": fp * fp_cost + fn * fn_cost}
truth = [1, 0, 1, 0, 1, 0, 0, 1]
prob = [0.92, 0.70, 0.60, 0.52, 0.48, 0.30, 0.18, 0.10]
table = [decision_row(truth, prob, threshold) for threshold in (0.3, 0.5, 0.7)]
for row in table:
print(row)
best = min(table, key=lambda row: row["cost"])
assert best["threshold"] in (0.3, 0.5, 0.7)用成本选择阈值时,成本数字必须来自明确的假设。把 FN 设为 FP 的五倍只是教学例子,真实项目要与业务负责人确认人工时间、用户损失、漏检后果和补救成本。若成本不确定,可以报告多个情景下的最佳阈值,而不是伪装成一个精确结论。
把 10 个概率分成两个桶:0–0.5 和 0.5–1。对每桶统计平均预测、真实正例率和样本数,并判断模型在哪个桶高估或低估。
本节小结:分类器只是决策链的一环
分类器给出分数或概率,阈值把它变成类别,业务流程再把类别变成动作。每一层都要独立验证:分数排序是否优于基线,概率是否校准,阈值下 FP/FN 是否符合成本,动作是否有人工复核和回滚。把三层混在一起,模型换了、成本变了或容量变了就无法定位问题。
报告分类结果时至少同时呈现正类比例、混淆矩阵、精确率、召回率、F1、PR/ROC 选择理由、阈值表和分组差异。少数类和小样本要加支持数及不确定性;多分类和多标签要说明平均方式。概率不是承诺,AUC 也不是部署效果,所有数字都要绑定数据窗口和切分规则。
| 检查 | 合格证据 | 不合格信号 |
|---|---|---|
| 标签方向 | 正类定义和映射表版本化 | 不同脚本正类相反 |
| 排序能力 | PR/ROC 与基线对照 | 只给 accuracy |
| 概率校准 | 分桶预测/实际比例 | 0.9 桶实际仅 0.4 |
| 阈值策略 | 容量、成本、版本记录 | 固定写死 0.5 |
| 分组公平 | 各群组支持数与错误 | 整体平均掩盖小群组 |
def accept(report, min_recall, max_fp, min_precision):
return (
report["recall"] >= min_recall
and report["fp"] <= max_fp
and report["precision"] >= min_precision
)
report = {"recall": 0.78, "fp": 24, "precision": 0.64}
passed = accept(report, min_recall=0.7, max_fp=30, min_precision=0.6)
print({"passed": passed, "report": report})
assert passed
# 练习:加入每个群组都必须满足的守护条件当概率落在 0.45–0.55 的不确定区间时,不自动判定,转人工复核。说明这样做会如何影响覆盖率、精确率、召回率和人工容量,并设计需要监控的四个指标。
分类的最后一问:谁来处理边界样本
阈值附近的样本天然更不确定,最安全的系统通常为它们提供人工复核、延迟决定或回退基线,而不是强行输出一个确定类别。把拒答率、人工队列长度和复核一致性加入报告,能让概率模型真正接入业务流程。对大模型质量分类也一样:低置信答案可以转人工或触发检索,而不是伪装成确定事实。
分类结果应按时间和群组持续监控。正类比例变化会影响精确率,标签延迟会让近期召回率暂时失真,某个群组样本减少会让指标方差变大。保存预测分布、阈值版本和标签成熟状态,才能把“模型退化”和“反馈还没到”区分开。
补充:边界样本需要自己的指标
阈值附近的样本天然更不确定,最安全的系统通常为它们提供人工复核、延迟决定或回退基线,而不是强行输出一个确定类别。把拒答率、人工队列长度和复核一致性加入报告,能让概率模型真正接入业务流程。对大模型质量分类也一样:低置信答案可以转人工或触发检索,而不是伪装成确定事实。
分类结果还应按时间和群组持续监控。正类比例变化会影响精确率,标签延迟会让近期召回率暂时失真,某个群组样本减少会让指标方差变大。保存预测分布、阈值版本和标签成熟状态,才能把“模型退化”和“反馈还没到”区分开。
分类任务的最后输出应是一套行动规则,而不是一个孤立的概率。保存阈值、拒答区间、容量和成本假设,部署时再由策略层读取;模型升级只需重新验证这份规则。这样当业务把“漏检更贵”改成“误报更贵”时,可以重新选择阈值而不混淆训练与决策。若策略需要不同群组使用不同门槛,应把理由、样本量和公平性检查一起记录,避免阈值在配置文件里悄悄漂移。
分类评估还应保留校准曲线和原始概率,而不仅是最终类别。原始概率支持重新扫描阈值、计算成本和比较版本;删除它们后,任何策略变化都只能重新调用模型,难以复核历史结果。对少数类、标签延迟和人工复核,都要在报告中标出不确定性和覆盖范围。
从概率到行动:把分类分数接到成本矩阵
分类器输出概率后,真正的决策通常还要经过成本矩阵。假设一个正类代表需要人工复核的风险事件,那么漏掉一个正类的代价可能高于多分派一次人工;如果正类代表昂贵的营销触达,误报则可能更贵。把 FP、FN、人工容量和延迟写成成本函数,在验证集上扫描阈值,再把选定阈值冻结到版本配置中,能避免用测试集反复挑选“最好看”的数字。
多分类任务要先确认标签是否互斥。互斥类别可以用混淆矩阵、宏平均和加权平均描述;一个样本可同时属于多个标签时,应分别计算每个标签的 PR、阈值和校准,不能把多标签问题硬压成单一类别。宏平均让小类有发言权,加权平均反映总体样本量,报告时应同时给出两者和各类支持数。
| 环节 | 要记录 | 常见错误 | 修复 |
|---|---|---|---|
| 概率生成 | 模型版本、校准方式 | 把未校准分数叫概率 | 检查可靠性图与 Brier |
| 成本设定 | FP/FN、人工与延迟成本 | 只优化 F1 | 写出业务可解释的成本假设 |
| 阈值扫描 | 验证集、搜索范围、容量 | 在测试集挑阈值 | 冻结验证阈值后只读测试集 |
| 上线监控 | 分数分布、拒答率、反馈延迟 | 只盯准确率 | 按时间/群组监控 FP 与 FN |
因此,分类章节的最终产物应包含原始概率、混淆矩阵、阈值版本和代表性错误样例。任何一个数字都要能回答“在哪份数据、按什么正类、用哪个阈值算出”。这套证据链也适用于大模型的答案通过/拒答分类:低置信输出可以进入人工复核,而不是被迫当作确定答案。
05 · GENERALIZATION
泛化:为什么训练分数高不等于模型可靠
模型真正要面对的是没有见过的新样本。过拟合、欠拟合、偏差—方差、正则化和交叉验证,都是围绕“新样本会怎样”展开的工具。本节把复杂度当作可控制的旋钮,而不是神秘的模型性格。
4.5.1 过拟合、欠拟合与偏差—方差
训练误差低,只说明模型在已见样本上表现好;泛化误差才关心未见样本。过拟合时,模型把噪声、偶然的群组特征或训练集中的细节当成规律,训练分数继续上升而验证分数下降。欠拟合则是模型表达能力不足或特征没有提供所需信息,训练和验证都表现不好。判断两者要同时看训练曲线、验证曲线和数据质量,不能只盯一个最终数字。
偏差—方差是一种有用的诊断语言。高偏差模型假设过于简单,在不同训练抽样上都得到相似但不够准确的结果;高方差模型对训练抽样非常敏感,换一批数据就改变很多。增加模型复杂度通常可以降低偏差、提高方差,增加数据、正则化、集成和更稳定的特征则可能降低方差。它不是一个必须精确分解的公式,而是帮助你决定下一步实验的框架。
训练曲线应按训练样本量或迭代次数记录。若训练误差和验证误差都高且接近,先检查标签、特征和模型容量;若训练很低、验证很高,检查泄漏、重复、噪声和复杂度;若两者都在下降但尚未收敛,增加数据或训练预算可能更合理。每个判断都要用一个对照实验支持,而不是凭感觉换模型。
图形只表达趋势:应同时保存具体分数、样本量和随机种子,不能从一张示意图推断实际模型。
| 现象 | 可能原因 | 优先实验 | 不要先做 |
|---|---|---|---|
| 训练、验证都差 | 特征不足、模型太简单、标签噪声 | 检查标签与增加可用特征 | 盲目加深模型 |
| 训练好、验证差 | 过拟合、泄漏、重复或漂移 | 去重、按时间/群组评估、正则化 | 反复看测试集 |
| 验证波动大 | 样本少、分层不稳、随机敏感 | 交叉验证、置信区间 | 只挑最好一次 |
| 训练和验证都很高 | 任务简单、指标泄漏或数据重复 | 负对照、时间审计、独立样本 | 立即宣称泛化 |
def mean(values):
return sum(values) / len(values)
history = [
{"epoch": 1, "train": 0.82, "valid": 0.86},
{"epoch": 2, "train": 0.61, "valid": 0.68},
{"epoch": 3, "train": 0.42, "valid": 0.51},
{"epoch": 4, "train": 0.28, "valid": 0.55},
{"epoch": 5, "train": 0.19, "valid": 0.63},
]
best = min(history, key=lambda item: item["valid"])
gap = best["valid"] - best["train"]
print({"best_epoch": best["epoch"], "valid_loss": best["valid"], "generalization_gap": round(gap, 3)})
assert best["epoch"] == 3
# 练习:把第 4、5 轮的 valid 改低,观察“最佳轮次”如何变化训练曲线中“最佳轮次”是验证集上的选择结果,不是测试集上的选择结果。早停、模型检查点和学习率调度都应使用训练/验证边界;测试集只在最终冻结后使用。对于批量很小的实验,要重复多个随机种子并报告均值与离散程度,避免把一次幸运切分当成规律。
易错:把更多参数等同于更多信息
模型参数多只能增加表达能力,不能创造不存在的标签信号。若特征记录了错误时间、标签本身不一致或测试分布已改变,继续加深网络/树只会更快记住噪声。先用错误样例和数据契约定位瓶颈,再决定是否需要复杂模型。
训练损失从 0.9 降到 0.1,验证损失先从 1.0 降到 0.4,再升到 0.8。指出最佳检查点、可能的现象和两个可验证的下一步。
4.5.2 正则化、特征选择与复杂度控制
正则化是在拟合目标中加入对参数或结构的惩罚,让模型在“贴合训练数据”和“保持简单”之间取得平衡。L2 正则化惩罚权重平方,倾向于把许多权重缩小;L1 正则化惩罚绝对值,可能把部分权重压到零,因此兼具稀疏选择效果。惩罚强度是超参数,必须在验证流程中选择,不能根据测试集结果反复调整。
复杂度控制不只靠正则项。树模型可以限制深度、叶片最小样本数和分裂数量;线性模型可以减少特征、合并稀疏类别;神经网络可以用 dropout、权重衰减和早停。每种手段都改变了假设,可能降低方差,也可能删掉真实信号。实验记录要写清控制旋钮、候选范围和验证策略。
特征选择必须在训练边界内进行。用全体标签计算相关性后再切分,会把测试标签的信息带入列集合;即使最终模型没有看到测试标签,列的存在本身也已经受测试影响。把选择器放进 Pipeline 或在每一折单独拟合,可以避免这类泄漏。对高维文本,词表和最小频次阈值也属于选择过程。
| 旋钮 | 主要作用 | 可能副作用 | 验证方法 |
|---|---|---|---|
| L1 | 鼓励稀疏权重 | 相关特征间选择不稳定 | 多折选择频率 |
| L2 | 平滑缩小权重 | 仍保留所有列 | 验证损失/权重谱 |
| 树深度 | 限制规则链复杂度 | 太浅欠拟合 | 深度曲线与残差 |
| 最小叶样本 | 避免小叶记忆噪声 | 忽略稀有但真实模式 | 分组/少数类评估 |
| 特征数量 | 降低方差和成本 | 丢失互补信号 | 消融与稳定性 |
def ridge_objective(errors, weights, strength):
data_loss = sum(error * error for error in errors) / len(errors)
penalty = strength * sum(weight * weight for weight in weights)
return data_loss + penalty
errors = [0.2, -0.4, 0.1, 0.8]
weights = [2.0, -1.5, 0.2]
for strength in (0.0, 0.01, 0.1, 1.0):
print(strength, round(ridge_objective(errors, weights, strength), 4))
assert ridge_objective(errors, weights, 0.0) < ridge_objective(errors, weights, 1.0)
# 正式训练还要在验证集比较预测误差,而不是只看目标函数正则化目标函数的下降不保证验证指标一定上升。惩罚太强会造成欠拟合,太弱则几乎没有作用。最好的强度还取决于特征是否标准化:如果一列的单位比另一列大很多,直接惩罚权重会产生不公平的复杂度偏好。先处理尺度,再比较正则化强度,并记录是否包含截距项的惩罚。
消融实验
一次只移除一组特征或一个正则项,保持切分、种子和评估代码不变,观察指标、成本和错误样例的变化。消融回答“没有它会怎样”,比一次加入十种技巧更容易解释。
稳定性实验
在多个训练折、时间窗口或随机种子中重复选择,统计某列被选中的频率、权重方向和指标方差。稳定性低的特征不一定无用,但不适合被写成强因果故事。
一个文本分类器有 50,000 个稀疏词特征,其中大量词高度相关;你希望模型更小、便于审阅。说明会优先尝试哪种正则化,并列出一个可能的反例。
4.5.3 交叉验证与超参数搜索
交叉验证把训练数据轮流分成若干折:每次用部分折拟合、剩余折验证,再汇总指标。它比一次随机留出更充分地利用小数据,并能观察模型对抽样的敏感性。普通 K 折要求样本近似独立;分类可用分层 K 折;同一实体多行时用 GroupKFold;时间序列则用滚动或扩展窗口。选择错误的交叉验证方式会让分数看似精确却不代表部署。
超参数搜索是实验设计,不是“把所有组合都跑一遍”。先确定预算、候选范围和主指标,再选择网格、随机或贝叶斯搜索。每个候选都只能看到其训练折;预处理和特征选择必须在折内拟合。搜索结束后冻结最佳配置,用独立测试集做一次报告,并保留所有候选结果而不是只保存冠军。
搜索结果要报告平均值和方差。若两个配置的平均 F1 相差 0.002,但标准差为 0.02,不能宣称前者稳定更好;可以选择更简单、成本更低或更容易解释的方案。嵌套交叉验证在数据很少且需要估计无偏选择性能时有帮助,但计算成本更高,学习阶段先理解“外层评估、内层选择”的职责分离。
| 方案 | 验证单位 | 适用条件 | 关键限制 |
|---|---|---|---|
| KFold | 样本 | 近似独立同分布 | 会打乱时间/群组关系 |
| StratifiedKFold | 样本+类别 | 分类类别比例重要 | 少数类数量要足够 |
| GroupKFold | 群组 | 新实体泛化 | 组数和大小要平衡 |
| TimeSeriesSplit | 时间窗口 | 预测未来 | 需处理标签成熟延迟 |
| Nested CV | 外层评估/内层选择 | 评估调参偏差 | 计算成本较高 |
from statistics import mean, pstdev
def fold_score(rows, validation_ids, predictor):
validation = [row for row in rows if row["id"] in validation_ids]
train = [row for row in rows if row["id"] not in validation_ids]
model = predictor(train)
errors = [abs(row["y"] - model(row["x"])) for row in validation]
return mean(errors)
rows = [{"id": i, "x": float(i), "y": 2 * i + 1} for i in range(12)]
folds = [set(range(start, start + 3)) for start in (0, 3, 6, 9)]
def fit_mean(train):
average = mean(row["y"] for row in train)
return lambda x: average
scores = [fold_score(rows, ids, fit_mean) for ids in folds]
print({"scores": scores, "mean": mean(scores), "std": pstdev(scores)})
assert len(scores) == 4上面的示例使用常数预测器,重点是展示“每折只用剩余训练行拟合”。真实模型会返回一个带有 predict 方法的对象,评估代码则应固定输入输出接口。每一折的预处理、特征选择和模型训练都应在同一边界内完成。保存折 ID、配置、随机种子和失败日志,才能在搜索结束后复查异常。
易错:把交叉验证分数当成测试分数
交叉验证分数是模型选择过程中的开发证据,仍可能被搜索策略过拟合。若候选配置很多,最终平均分通常会偏乐观;独立测试集或外层交叉验证才是更诚实的最终估计。不要把“用了五折”当作自动无偏的保证。
只有 60 条带时间顺序的样本,候选超参数 12 组。设计验证方案、搜索预算和最终测试流程,要求不打乱未来顺序。
| 能力 | 最低证据 | 迁移到大模型项目的用法 |
|---|---|---|
| 诊断泛化 | 能读训练/验证曲线并提出对照 | 判断提示模板或微调是否过拟合 |
| 使用正则化 | 知道强度、尺度和副作用 | 理解权重衰减与早停 |
| 选择 CV | 依据时间/群组/类别选择折法 | 设计文档级和查询级评测 |
| 记录搜索 | 保存候选、方差和冻结版本 | 让模型比较可复现 |
泛化诊断要把数据问题和模型问题分开
看到训练/验证差距时,先排查数据边界:是否有同一实体跨集合、近重复文本、全量统计量或未来字段。边界错误会让复杂度实验失去意义,因为你无法知道高分来自真正的规律还是泄漏。确认边界可信后,再比较正则化、特征数量、树深度、早停和增加数据等模型旋钮。
样本量曲线能帮助判断“增加数据”是否值得。如果训练误差低而验证误差随着样本增加持续下降,更多数据可能有效;如果两条曲线都在高位,特征或标签更可能是瓶颈;如果曲线波动大,先增加重复折或改进采样。曲线上的每个点都应使用相同的评估协议,不能为了凑满数据更换切分。
复杂度不是一个数字。参数量、有效自由度、特征数量、树深、训练轮次、搜索次数和人工规则都可能增加模型适应噪声的能力。报告“模型更简单”时说明具体约束和资源影响,报告“模型更强”时说明在多少折、多少种子和哪些群组上稳定。这样复杂度才是可讨论的实验变量。
| 实验 | 只改变什么 | 主要读数 | 结论边界 |
|---|---|---|---|
| 特征消融 | 移除一组字段 | 验证指标、错误样例 | 该组对当前切分的贡献 |
| 正则路径 | 惩罚强度 | 训练/验证差距 | 候选范围内的平衡 |
| 样本量曲线 | 训练样本数量 | 两条误差曲线 | 更多数据的潜在收益 |
| 多随机种子 | 随机状态 | 均值、标准差 | 抽样敏感度 |
| 时间滚动 | 训练截止时间 | 未来窗口表现 | 漂移和稳定性 |
from statistics import mean, pstdev
def fake_score(seed, complexity):
# 教学用的确定性扰动,代表不同种子导致的验证波动
noise = ((seed * 17 + complexity * 5) % 11 - 5) / 100
return 0.72 + 0.03 * complexity - noise
results = []
for complexity in (1, 2, 3, 4):
scores = [fake_score(seed, complexity) for seed in range(5)]
results.append({"complexity": complexity, "mean": mean(scores), "std": pstdev(scores), "scores": scores})
for row in results:
print(row["complexity"], round(row["mean"], 3), round(row["std"], 3))
chosen = min(results, key=lambda row: (-row["mean"], row["std"]))
print("候选:", chosen)
# 练习:增加时间窗口维度,记录最坏窗口而不是只看均值示例用人工扰动模拟多种子结果,重点是比较均值和离散度;真实实验要保存原始分数和失败日志。若复杂度 4 的平均分略高但方差大很多,可以选择复杂度 3,或增加数据后再确认。模型选择应是风险—收益权衡,而不是把排行榜第一名自动写入生产。
为一个小数据分类任务制定停止调参规则,至少包含最大实验次数、最小实际提升、方差判断和测试集使用时机。
本节小结:把模型选择当成受约束的实验
泛化不是一个魔法系数,而是对未来样本的证据管理。先固定数据边界,再在训练/验证范围内比较复杂度、正则化和特征方案;每个候选保存平均值、离散度、训练成本和错误样例;最后冻结配置,用独立测试集做一次报告。这样即使最佳模型没有提升,也能知道是数据、特征、标签还是容量限制了结果。
交叉验证的价值在于暴露抽样敏感性,正则化的价值在于限制不必要的自由度,早停的价值在于在验证表现最佳处停止。它们都不能替代更好的标签和更诚实的切分。对时间和群组数据,优先选择符合生成过程的验证策略,再讨论折数和搜索算法。
| 记录项 | 示例 | 为什么保留 |
|---|---|---|
| 候选配置 | depth=3, alpha=0.1 | 能复查选择过程 |
| 验证均值/方差 | 0.72±0.03 | 区分真实提升与波动 |
| 训练成本 | 4 min, 120 MB | 评估上线代价 |
| 守护指标 | 少数类 recall≥0.7 | 防止平均分掩盖风险 |
| 冻结点 | config v5 | 界定测试集使用时机 |
candidates = [
{"name": "simple", "mean": 0.71, "std": 0.01, "cost": 1.0, "guard": 0.74},
{"name": "medium", "mean": 0.72, "std": 0.03, "cost": 1.8, "guard": 0.71},
{"name": "complex", "mean": 0.725, "std": 0.09, "cost": 4.5, "guard": 0.68},
]
eligible = [row for row in candidates if row["guard"] >= 0.70]
chosen = min(eligible, key=lambda row: (-row["mean"], row["cost"], row["std"]))
print({"chosen": chosen["name"], "reason": "先满足守护指标,再比较分数/成本/稳定性"})
assert chosen["name"] == "medium"复杂模型平均 F1 最高,但标准差是简单模型的 8 倍,且少数类召回低于守护门槛。写出你拒绝它的报告句子和下一步实验。
泛化的最低承诺
不要承诺“模型一定准确”,而要承诺一套可复核的范围:在指定数据窗口、群组边界、标签定义和评估指标下,模型与基线相比达到什么结果,结果在多少折和多少随机种子上稳定,哪些群组和时间段没有验证,发生什么条件时需要重新训练或回滚。这样的承诺虽然窄,却能随着数据变化被重新检查。
当验证集被多轮搜索使用后,它也会逐渐承担“被过拟合”的风险。控制搜索预算、保留独立测试集、使用嵌套验证或新时间窗口,都能降低这种风险。最简单的停止规则往往最有价值:收益小于最小门槛、方差没有改善或资源超过预算,就停止增加复杂度。
| 范围 | 写法 | 重新检查时机 |
|---|---|---|
| 数据 | 2026-01 至 2026-06,按用户分组 | 数据窗口滚动 |
| 性能 | 验证 F1=0.72±0.03 | 每次训练 |
| 守护 | 少数类召回≥0.70 | 每次发布 |
| 成本 | 单批延迟≤30 ms | 环境或模型变化 |
| 回退 | 指标/漂移超限使用基线 | 监控触发 |
将“这个模型泛化能力强”改写成四句可检查的承诺,必须包含数据范围、指标、守护条件和回退动作。
补充:给泛化结论加上停止线
当验证集被多轮搜索使用后,它也会逐渐承担“被过拟合”的风险。控制搜索预算、保留独立测试集、使用嵌套验证或新时间窗口,都能降低这种风险。最简单的停止规则往往最有价值:收益小于最小门槛、方差没有改善或资源超过预算,就停止增加复杂度。
不要把“训练误差下降”当作继续训练的理由。若验证误差已经连续若干轮上升,保存最佳验证检查点并记录停止原因;若训练与验证都不变,检查学习率、标签和特征;若不同折差异很大,优先改善采样和数据边界。每一种动作都应留下前后对照,而不是只写一句“调参后更好”。
| 信号 | 动作 | 记录 |
|---|---|---|
| 验证连续上升 | 恢复最佳检查点 | 最佳轮次与 patience |
| 守护指标下降 | 停止候选并分析群组 | 分组曲线/错误样例 |
| 方差过大 | 增加折/种子或降复杂度 | 均值、标准差、预算 |
| 资源超限 | 选择轻量候选或回退 | 延迟、内存、版本 |
泛化诊断的价值在于改变下一步,而不是给模型贴上“过拟合”标签。每个诊断都应绑定一个最小对照:去重前后、正则化强弱、特征消融、时间窗口或更多随机种子。若没有对照,就只能说“观察到现象”,不能说“找到了原因”。在记录对照时还要注明未改变的条件,像切分、数据版本和指标,否则多个差异同时变化会让解释失效。
训练、验证和测试的差距也可能来自标注质量。训练集标签更干净、验证集包含边界案例,模型就会看似过拟合;反过来,训练噪声更大也会造成欠拟合假象。抽查每个集合的标签一致性、缺失和时间覆盖,把数据质量作为泛化诊断的一部分。
把不确定性写进泛化结论
一次切分和一次随机种子只能给出一个结果点,不能代表模型在所有未来样本上的表现。对小数据集,建议报告多次种子或多折结果的均值、标准差、最小值和最大值,并列出每折样本量;对时间序列,使用多个滚动窗口观察模型是否只在某一个时期有效。若均值提升很小而方差很大,应优先增加数据、改善切分或降低复杂度,而不是把小数点后的优势写成稳定改进。
泛化还包含分布外输入。训练集范围之外的新类别、极端长度、缺失字段和新的时间段,可能让预测不可靠。为这些输入建立拒答、回退或人工复核路径,并在验证阶段人为构造少量压力样本。压力样本不是为了修改测试分数,而是检查系统是否知道自己何时不应自信。
| 证据 | 回答的问题 | 不足时的风险 | 补强方法 |
|---|---|---|---|
| 多折/多种子 | 结果是否依赖一次抽样 | 偶然胜出 | 增加折数并报告方差 |
| 时间窗口 | 能否面对未来分布 | 随机切分高估 | 滚动评估与漂移监控 |
| 压力样本 | 超范围时是否会失控 | 线上异常输入崩溃 | 拒答/回退规则 |
| 独立测试快照 | 最终数字是否未被调参污染 | 报告乐观偏差 | 冻结、只读并记录访问 |
把“模型更好”改写为带范围的陈述,例如“在 2026 年 1—6 月的时间窗口、三次种子和给定标签定义下,验证集 F1 平均提升 3.1 个百分点,标准差 0.8;对新群组仍缺少证据”。这种写法更长,却能准确表达已知、未知和下一步实验。
06 · TREE MODELS
树模型与集成:用分裂规则组织非线性
决策树把特征空间切成一块块区域,随机森林用许多棵相关性较低的树降低方差,梯度提升则逐步修正残差。它们很强,但也会因为深度、类别不平衡、泄漏特征和错误解释而失效。
4.6.1 决策树如何选择一次分裂
决策树通过一连串“如果特征小于某个阈值,就走左边;否则走右边”的规则,把特征空间切成若干区域。叶节点保存该区域的预测值:回归可以是叶内均值,分类可以是叶内多数类或类别概率。树的优点是能表达非线性和特征交互,规则也容易画出来;缺点是单棵树很容易记住训练样本,对输入微小变化可能产生跳跃。
一次分裂要选择特征、阈值和左右子集,使不纯度或误差下降。分类常见基准有基尼不纯度和熵:如果一个节点内所有样本属于同一类,不纯度为零;类别混杂时不纯度更高。回归可以比较分裂前后的平方误差。候选分裂越多,越容易在训练集找到偶然的好切口,因此深度和叶节点最小样本数是重要的复杂度旋钮。
树的分裂规则带有轴对齐假设。若真实边界是斜线,树可能需要很多小矩形才能逼近;随机森林可以用多棵树平均来降低方差,但不能自动修复泄漏或错误标签。理解分裂评分,能帮助你判断“这棵树为什么这样切”,而不是把库输出当成黑箱。
| 指标 | 公式直觉 | 节点纯时 | 使用提醒 |
|---|---|---|---|
| 基尼 | 1-Σpₖ² | 为 0 | 对大类混合较敏感 |
| 熵 | -Σpₖlog pₖ | 为 0 | 需约定 0log0=0 |
| 误分类率 | 1-max(pₖ) | 为 0 | 分裂优化通常不如前两者平滑 |
| 信息增益 | 父节点不纯度减子节点加权 | 越大越好 | 注意子节点样本权重 |
def gini(labels):
if not labels:
return 0.0
total = len(labels)
counts = {label: labels.count(label) for label in set(labels)}
return 1 - sum((count / total) ** 2 for count in counts.values())
def weighted_gini(left, right):
total = len(left) + len(right)
return (len(left) * gini(left) + len(right) * gini(right)) / total
def gain(parent, left, right):
return gini(parent) - weighted_gini(left, right)
parent = [0, 0, 0, 1, 1, 1]
candidate_a = ([0, 0, 0], [1, 1, 1])
candidate_b = ([0, 0, 1], [0, 1, 1])
print(gain(parent, *candidate_a), gain(parent, *candidate_b))
assert gain(parent, *candidate_a) > gain(parent, *candidate_b)代码中的 labels.count 适合教学的小数组,生产实现会使用计数器或向量化操作。重要的是观察父节点和子节点的加权关系:不能只看左节点变纯了多少而忽略右节点。类别权重、样本权重和缺失值处理也会改变评分,配置必须与评估报告一起保存。
树不是一次性学习一条全局公式,而是递归地把空间分割;每个额外分裂都应通过验证数据证明有价值。
易错:把树的阈值当成自然规律
节点上的“阅读分钟<3.5”是当前训练数据和损失下的切分点,不是世界的客观边界。换一批样本、改变最小叶大小或轻微处理缺失值,阈值都可能变化。解释时报告支持该规则的样本数、验证稳定性和适用范围,避免把偶然切口写成因果结论。
对标签 [0,0,0,1,1,1],比较把样本分成 [0,0,0] / [1,1,1] 和 [0,0,1] / [0,1,1] 的基尼增益。说明为什么要加权。
4.6.2 随机森林与梯度提升
随机森林训练许多棵彼此有差异的树,再对回归结果取平均或对分类结果投票/平均概率。差异来自自助采样、随机抽取特征和树结构;如果所有树都看见完全相同的数据与特征,它们会高度相关,平均不能有效降低方差。森林通常对表格数据强健,但模型体积、推理延迟和概率校准要提前评估。
梯度提升以序列方式训练树:新树尝试修正当前模型的残差或损失梯度。学习率、树数量、单棵树深度和子采样控制每一步修正的幅度。小学习率配合更多树可能更平滑,但训练成本增加;深树能表达复杂交互,也更容易过拟合。早停应依据验证集,不要用测试集挑树的数量。
集成的核心不是“树越多越好”,而是让错误具有互补性。若数据泄漏、标签错误或某个群组完全缺失,几十棵树只会重复同一错误。比较集成模型时,应与单树、线性基线同一切分和指标,报告每个群组的性能、特征处理和资源成本。
| 方面 | 随机森林 | 梯度提升 | 实验问题 |
|---|---|---|---|
| 训练关系 | 树可并行、相互独立近似 | 树按顺序修正残差 | 预算与并行能力如何 |
| 主要旋钮 | 树数、特征采样、叶大小 | 学习率、树数、深度 | 哪个旋钮造成收益 |
| 方差控制 | 靠随机化与平均 | 靠小步更新与正则 | 多种子是否稳定 |
| 概率输出 | 可能需校准 | 也不保证天然校准 | 可靠性图是否合格 |
| 解释 | 平均重要性/局部路径 | 累计修正路径 | 解释是否超出相关性 |
from random import Random
def bootstrap(rows, rng):
return [rows[rng.randrange(len(rows))] for _ in rows]
def majority(labels):
counts = {label: labels.count(label) for label in set(labels)}
return max(counts, key=counts.get)
rows = [{"x": x, "y": int(x >= 5)} for x in range(10)]
rng = Random(11)
votes = {row["x"]: [] for row in rows}
for tree_id in range(7):
sample = bootstrap(rows, rng)
seen = {row["x"] for row in sample}
for row in rows:
if row["x"] not in seen: # 简化版 out-of-bag 评估
guess = int(row["x"] + (tree_id % 2) >= 5)
votes[row["x"]].append(guess)
for x, guesses in votes.items():
if guesses:
print(x, majority(guesses), guesses)自助采样会让一部分样本没有进入某棵树的训练集,这些 out-of-bag 样本可以作为近似验证证据,但它仍然要遵守群组和时间边界。示例故意使用一个简单的假树预测器,只展示“未见样本投票”的数据结构;不要把它当作完整随机森林实现。使用库时,仍需核对 bootstrap、max_features、类别权重和随机状态。
集成模型的资源账本
记录树的数量、最大深度、模型文件大小、单条/批量推理延迟、峰值内存和冷启动时间。一个只提升 0.3 个百分点但让延迟翻倍的模型,未必值得上线。若模型用于大模型数据准备,还要记录特征生成和向量化成本,不能只看最终预测函数。
三棵树在四个样本上的预测分别为:A=[1,1,0,0]、B=[1,0,0,0]、C=[1,1,1,0]。真实标签为 [1,0,1,0]。计算单树准确率和多数投票准确率,并讨论错误是否互补。
4.6.3 特征重要性、解释与常见陷阱
树模型常见的特征重要性是分裂带来的不纯度下降总和。它容易偏爱取值多、可切分点多的连续特征,也会在高度相关的特征之间分摊或随机分配重要性。置换重要性则打乱某列后观察验证指标下降,更接近“这列对当前评估有多有用”,但仍会受到相关特征、切分方式和随机噪声影响。
局部解释要区分模型内部规则与输入扰动实验。沿树路径读取“先按 A,再按 B”可以说明模型如何到达叶节点;把某个特征改动后重新预测,是反事实样例,但改动可能制造不现实的输入。对类别组合、时间字段和聚合特征,反事实必须遵守数据生成约束。解释应附带样本支持、区间和不可推断的边界。
常见陷阱包括:把 ID 列当特征、把未来统计量放进输入、让缺失值编码暗示标签、在测试集上选择树深度、用整体重要性替代群组公平性、把概率未校准的输出当风险百分比。解决方法不是禁用树模型,而是把特征审计、分组评估、校准和错误样例纳入工作流。
| 方法 | 测量对象 | 优点 | 主要风险 |
|---|---|---|---|
| 不纯度重要性 | 训练时分裂贡献 | 快速、可直接读取 | 偏爱高基数/连续特征 |
| 置换重要性 | 打乱一列后的验证下降 | 绑定真实评估指标 | 相关特征会互相掩盖 |
| 单样本路径 | 该样本经过的规则 | 容易讲清局部过程 | 不代表全局规律 |
| 部分依赖 | 平均改变某特征的预测 | 看总体趋势 | 可能产生不现实组合 |
| 反事实 | 输入改变后的结果 | 接近行动问题 | 需满足可行性约束 |
from random import Random
def accuracy(actual, predicted):
return sum(a == p for a, p in zip(actual, predicted)) / len(actual)
def predictor(row):
return int(row["minutes"] > 20 or row["exercises"] >= 3)
rows = [
{"minutes": 10, "exercises": 0}, {"minutes": 30, "exercises": 0},
{"minutes": 12, "exercises": 4}, {"minutes": 40, "exercises": 5},
]
truth = [0, 1, 1, 1]
base = accuracy(truth, [predictor(row) for row in rows])
rng = Random(3)
for name in ("minutes", "exercises"):
shuffled = list(rows)
values = [row[name] for row in shuffled]
rng.shuffle(values)
for row, value in zip(shuffled, values):
row[name] = value
score = accuracy(truth, [predictor(row) for row in shuffled])
print(name, {"drop": round(base - score, 3), "permuted_score": score})置换重要性必须在未参与训练的评估数据上计算,并固定随机种子或重复多次取均值。若两列高度相关,打乱其中一列后另一列仍能补偿,重要性会被低估;可以做成组置换、消融整组特征或报告相关性。对线上应用,重要性还要和缺失率、延迟及获取成本一起讨论。
树模型上线前的五项审计
一是确认没有 ID、未来字段和标签派生列;二是限制深度、叶子大小并用时间/群组验证;三是报告校准、每群组混淆矩阵和最坏样本;四是比较不纯度、置换和消融结果,解释其差异;五是记录模型文件、特征版本、阈值和回滚条件。树模型的“可解释”不是免检通行证。
排行榜第一列是 user_id,第二列是“订单完成后 24 小时内是否退款”,第三列是阅读分钟。请指出至少两个问题,并写出替代审计。
user_id 可能让模型记住实体,退款后字段直接泄漏标签;两列都应从特征中移除或改为预测时点之前的历史聚合。随后按时间/群组重做切分,比较清洗前后分数、混淆矩阵和错误样例。| 能力 | 最低证据 | 迁移到大模型项目的用法 |
|---|---|---|
| 读懂分裂 | 能计算一次不纯度增益 | 理解路由、门控和规则切分 |
| 比较集成 | 能说明平均与逐步修正差异 | 选择重排器或质量模型 |
| 解释特征 | 知道重要性不是因果 | 审阅检索特征和提示变量 |
| 控制风险 | 能列出深度、泄漏、校准检查 | 上线前建立模型卡与回滚条件 |
用反例理解树模型的边界
如果特征空间中有一个高基数 ID,树很容易切出只包含少数样本的叶子,训练准确率会很高但没有泛化意义。若某列是标签生成后的状态,树会把泄漏转换成一条看似合理的规则。若类别极不平衡,树也可能通过多数类获得漂亮的准确率。审阅树模型前先做字段审计和类别基线,能避免把数据问题错当算法优势。
单棵树的可视化要控制深度。把一棵几十层的树完整画出来通常只会制造噪声;可以展示前两三层、一个代表样本的路径和叶节点支持数。解释路径时写出每条规则的比较方向、阈值、训练样本数和验证命中率,避免只截一张彩色图。若树很多,优先用局部路径、置换重要性和错误样例,而不是声称整个森林有一条统一规则。
集成模型的稳定性也需要评估。改变随机种子、训练窗口或少量样本后,重要性排行榜可能变化;这不必然说明模型无用,却说明解释应使用区间和频率。对关键决策,最好保留一个简单、可审计的替代模型或规则基线,发生漂移时可以快速比较和回滚。
| 层次 | 证据 | 读者能知道什么 | 仍然不知道什么 |
|---|---|---|---|
| 全局性能 | 基线/模型指标 | 整体是否有增益 | 某个样本为何错 |
| 结构 | 深度、叶数、支持数 | 规则复杂度 | 是否因果 |
| 局部路径 | 样本经过的阈值 | 该样本的模型理由 | 改动是否可行 |
| 重要性 | 置换/消融/稳定性 | 特征对当前评估的贡献 | 现实世界干预效果 |
| 错误样例 | FP/FN 与上下文 | 失败模式 | 未来一定复现 |
def path_for(row):
path = []
if row["minutes"] < 15:
path.append("minutes<15")
if row["exercises"] < 2:
path.append("exercises<2")
prediction = 0
else:
path.append("exercises>=2")
prediction = 1
else:
path.append("minutes>=15")
prediction = 1
return {"path": path, "prediction": prediction}
row = {"id": "u-9", "minutes": 12, "exercises": 3}
result = path_for(row)
print(result)
assert result["prediction"] == 1
assert result["path"] == ["minutes<15", "exercises>=2"]
# 练习:加入叶节点支持数,并在支持数过小时标记低置信路径审计提醒我们,解释对象是“模型在这条输入上的计算过程”,不是“用户真正为什么收藏”。如果需要因果解释,应另行设计干预实验。树模型很适合作为可解释基线和表格数据强模型,但任何上线声明都要附带切分、校准、群组表现和回滚条件。
在路径输出中加入叶节点样本数、训练正例率和验证命中率。设计规则:叶节点样本少于 20 时,不直接自动行动,而是进入人工复核。
本节小结:强模型也需要简单的参照物
决策树、随机森林和梯度提升能表达复杂交互,但它们的优势必须在同一切分、同一指标和同一成本下与线性模型、规则基线比较。树深、叶大小、树数、学习率和子采样都是复杂度旋钮;每次只改变一组旋钮,保存验证曲线和错误样例,才能知道收益来自哪里。
解释树模型时同时看结构、路径、重要性和稳定性。重要性描述模型在当前数据上的利用,不是因果排名;局部反事实要满足真实可行约束;高基数 ID、未来字段和少数类不平衡会制造虚假高分。保留一个可审计的简单基线和回滚版本,是上线风险控制的一部分。
| 门槛 | 示例要求 | 证据 |
|---|---|---|
| 数据安全 | 无 ID/未来列,群组交集为 0 | 字段审计、切分摘要 |
| 泛化 | 时间/群组折稳定,守护指标通过 | 多折分数与方差 |
| 解释 | 路径与重要性可回到原始字段 | 局部案例、置换/消融 |
| 资源 | 延迟、内存和模型大小在预算内 | 基准日志 |
| 回滚 | 保留上一版本与简单基线 | 版本目录、演练记录 |
release = {
"data_audit": {"id_columns": 0, "future_columns": 0, "group_overlap": 0},
"validation": {"mean_f1": 0.74, "std_f1": 0.02, "guard_recall": 0.76},
"resources": {"latency_ms": 18, "memory_mb": 120},
"budget": {"max_latency_ms": 30, "max_memory_mb": 256},
}
assert all(value == 0 for value in release["data_audit"].values())
assert release["validation"]["guard_recall"] >= 0.70
assert release["resources"]["latency_ms"] <= release["budget"]["max_latency_ms"]
assert release["resources"]["memory_mb"] <= release["budget"]["max_memory_mb"]
print("tree release checks passed")集成模型 F1 比线性基线高 1.5 个百分点,但延迟是 4 倍、特征重要性在不同种子变化很大。请给出采用、暂缓或回退的条件。
树模型的选择要匹配数据与解释需求
如果表格特征包含明显的非线性、缺失模式和交互,树模型可以作为强基线;如果你需要连续、平滑且容易外推的关系,线性模型可能更合适。森林和提升树的差别不只是性能,还是训练并行方式、资源账本、概率校准和解释路径的差别。模型卡中应写出为什么选择这一族,而不是只贴排行榜。
树的分裂过程可以用来设计单元测试:单位矩阵式的简单输入、边界阈值、缺失值、极端值、重复 ID 和未来字段都要有预期行为。测试让“能运行”与“结果合理”分开。升级后先跑结构与路径回归,再跑独立评估集,最后才更新线上版本。
| 样本 | 目的 | 预期 |
|---|---|---|
| 阈值恰好等于切点 | 检查 < 与 >= | 边界规则固定并记录 |
| 缺失特征 | 检查填补/默认方向 | 不静默丢列 |
| 极端高基数 ID | 发现实体记忆 | 拒绝或不进特征 |
| 新群组 | 检查泛化 | 按群组报告与回退 |
| 近重复文本 | 检查泄漏 | 跨集合数量为 0 |
选择一个阈值边界,写出输入、预期路径、预期预测和失败时的错误信息。再说明这条测试如何帮助下一章的神经网络数据管道。
补充:先审计路径,再解释重要性
特征重要性只说明模型在当前数据上用到了什么,不能说明现实世界中什么最值得干预。先查看若干真实样本的路径和叶节点支持数,再用置换或消融检验整体影响;若两种证据冲突,报告冲突并检查相关特征、缺失模式和样本权重。解释的可信度来自证据链,不来自图表颜色。
树模型发布后还要监控结构和数据。特征取值分布、叶节点命中率、正负类比例、预测概率和错误样例变化,都可能在整体指标下降前发出信号。保存旧版本和简单基线,才能在漂移或资源异常时快速回退。
树模型的强大来自分裂与集成,可靠性来自边界与回归测试。先确认每条路径使用的字段在预测时可用,再评估深度、树数和学习率;发布后继续监控叶节点命中、概率分布和资源。能快速回退的模型,才是能被长期维护的模型。把“为什么这条规则存在”写成路径记录,能帮助新成员理解模型,也能在特征含义变化时及时发现旧规则失效。
树模型适合做基线、排序器和表格预测器,却不应被描述成自动发现因果规律的工具。对关键决策保留简单替代方案,定期用时间窗口和新群组复测;当重要性、路径或错误结构明显变化时,先暂停自动行动并重新审计数据。
树的分裂可以手算,停止规则必须说清
决策树每次选择一个特征和阈值,把样本分成更“纯”的子节点。分类树常用基尼不纯度或熵衡量混杂程度,回归树常用平方误差下降;分裂带来的收益要与叶节点样本量、深度和计算成本一起考虑。一个看似完美的叶节点可能只有一条样本,剪枝、最小叶大小和最大深度正是把记忆训练样本限制在可泛化范围内的控制杆。
比较树模型时保持切分和预处理一致。随机森林通过对样本和特征做随机化来降低单棵树的方差,梯度提升则逐棵修正当前模型的残差;二者都可能在高维表格数据上很强,却仍然会学习泄漏字段、重复实体和标签噪声。调参时优先固定随机种子、保存候选清单和验证曲线,避免只保留“跑出来最好的”那一次。
| 控制项 | 影响 | 适合观察的信号 | 代价 |
|---|---|---|---|
| max_depth | 限制路径长度 | 训练/验证差距 | 过小会欠拟合 |
| min_samples_leaf | 防止孤立叶 | 叶节点支持数 | 细粒度规则减少 |
| n_estimators | 增加集成平均 | 验证曲线、内存 | 训练与存储成本 |
| learning_rate | 控制提升步长 | 随树数的收敛 | 小步长需要更多树 |
解释树模型时把“全局重要性”和“单样本路径”分开:前者回答整体使用了哪些字段,后者回答某个样本为什么走到这个叶节点。两者都不等于因果关系。对关键动作,保留一个简单规则或线性基线作为回退,并用时间窗口、新群组和极端值做回归测试。
07 · UNSUPERVISED LEARNING
没有标签时:聚类、距离与异常
无监督学习不是“没有标准答案就随便分组”。聚类需要定义相似性、尺度和用途;异常检测需要定义“异常于谁、在什么时间窗口异常”。本节用 KMeans 解释中心、分配和更新,再连接到异常与半监督场景。
4.7.1 KMeans:中心、分配与迭代
KMeans 在没有标签时尝试把样本分成 k 个簇。它维护 k 个中心,先把每个样本分配给最近的中心,再用每个簇的均值更新中心,反复执行直到分配不再变化或达到迭代上限。目标通常是最小化样本到所属中心的平方距离之和。这个目标定义了“相似”,不是自然界自动给出的真相。
初始化很重要。随机选择中心可能落在同一个区域,导致局部最优;KMeans++ 通过倾向选择远离已有中心的点改善起点。不同随机种子得到不同结果时,应增加重复次数并比较目标值、簇大小和下游用途。不要只把二维散点图中看起来漂亮的一次结果当作结论。
KMeans 隐含了几个假设:数值特征可计算欧氏距离、簇大致是球形且尺度相近、均值能代表中心、每个样本属于一个簇。文本 Embedding、长尾计数和类别变量未必满足这些假设。若业务需要密度簇、不同形状或软归属,应比较其他方法并说明选择理由。
| 步骤 | 计算 | 需要保存 | 典型问题 |
|---|---|---|---|
| 初始化 | 选择 k 个中心 | 种子、初始化方法 | 中心挤在一起 |
| 分配 | 比较到各中心的距离 | 距离度量、并列规则 | 尺度支配距离 |
| 更新 | 计算每簇均值 | 簇大小、中心轨迹 | 空簇、离群点 |
| 停止 | 中心变化/目标变化 | 迭代数、容差 | 局部最优或早停 |
from math import dist
from random import Random
def mean_point(points):
return tuple(sum(point[i] for point in points) / len(points) for i in range(len(points[0])))
def kmeans(points, k=2, steps=10, seed=4):
if k < 1 or k > len(points):
raise ValueError("k 必须在样本数量范围内")
rng = Random(seed)
centers = [tuple(point) for point in rng.sample(points, k)]
assignments = []
for _ in range(steps):
assignments = [min(range(k), key=lambda index: dist(point, centers[index])) for point in points]
new_centers = []
for index, center in enumerate(centers):
cluster = [point for point, label in zip(points, assignments) if label == index]
new_centers.append(mean_point(cluster) if cluster else center)
if new_centers == centers:
break
centers = new_centers
return assignments, centers
points = [(1, 1), (1, 2), (2, 1), (9, 9), (10, 9), (9, 10)]
labels, centers = kmeans(points)
print(labels, centers)
assert len(labels) == len(points)示例使用标准库的 math.dist,让每一步可追踪。正式实现要处理空簇、非有限值、特征缩放和收敛容差,并返回目标值与迭代轨迹。对高维 Embedding,距离计算可能昂贵,初始化和随机种子更要记录。聚类标签 0、1、2 本身没有顺序,不能写成“簇 2 比簇 1 更高级”。
易错:把簇编号当作类别标签
KMeans 的编号只是实现顺序,重新运行后同一组样本可能被命名为另一个编号。比较两次聚类时,应使用簇中心、样本集合的重叠或匈牙利匹配,而不是直接比较数字。若需要业务命名,先查看每簇的代表样本和统计摘要,再由人明确命名。
用点 (0,0)、(1,0)、(8,8)、(9,8),初始中心取 (0,0)、(9,8)。手算一次分配和更新,说明为什么更新后的中心仍然落在数据范围内。
(0.5,0) 与 (8.5,8)。均值是簇内样本的重心,因此在凸包内,但离群点会拉动中心。4.7.2 距离、缩放与聚类数量
聚类结果首先由距离决定。若一个特征范围是 0 到 1,另一个是 0 到 10000,欧氏距离会几乎只看后者;标准化或稳健缩放可以让轴贡献更可比,但也会改变簇结构。缩放参数必须在适当的数据边界内估计,若聚类用于未来样本,应只用训练窗口拟合并冻结转换。
选择 k 没有万能答案。肘部法观察簇内平方和随 k 增加的下降速度,轮廓系数比较样本与本簇/最近邻簇的相对距离,稳定性分析则重复不同种子或重采样,检查样本是否反复分到一起。指标只能说明几何拟合,业务还要问每簇是否可解释、是否足够大、是否能支持行动。
簇大小和边界同样重要。一个簇只有 1% 样本,可能是有价值的少数模式,也可能只是噪声;把小簇强行合并会丢失异常信号,把它当作新用户群又可能造成过度叙事。输出每簇的数量、中心、特征分位数、代表样本和跨时间稳定性,才能从“图上分开”走向可用分组。
| 证据 | 回答的问题 | 优点 | 局限 |
|---|---|---|---|
| 簇内平方和 | 簇内是否紧凑 | 目标直接、易计算 | k 越大通常越小 |
| 肘部图 | 增加 k 的收益何时变小 | 帮助缩小范围 | 肘部可能不明显 |
| 轮廓系数 | 样本相对本簇是否更近 | 兼顾分离与紧凑 | 偏好某些形状/尺度 |
| 稳定性 | 重跑后分组是否一致 | 检验可重复性 | 需定义一致性指标 |
| 业务可行动性 | 分组能否支持决策 | 连接实际用途 | 不能替代几何检查 |
from math import dist
def within_sum(points, labels, centers):
return sum(dist(point, centers[label]) ** 2 for point, label in zip(points, labels))
def cluster_sizes(labels):
return {label: labels.count(label) for label in sorted(set(labels))}
candidate_results = [
{"k": 2, "inertia": 12.4, "silhouette": 0.58, "stable": 0.91},
{"k": 3, "inertia": 7.1, "silhouette": 0.49, "stable": 0.64},
{"k": 4, "inertia": 4.9, "silhouette": 0.43, "stable": 0.51},
]
best = max(candidate_results, key=lambda item: (item["silhouette"], item["stable"]))
print({"candidate": best, "all": candidate_results})
assert best["k"] == 2
# 练习:加入业务约束,排除样本数小于 10 的簇不要把多个指标机械加权成一个神秘总分。可以先用几何指标筛选候选,再用代表样本、领域专家和下游任务验证。若聚类用于推荐或数据标注,最好做小规模在线/人工实验,确认分组会带来可观察收益。聚类图是探索工具,簇命名和行动规则仍需要独立证据。
聚类报告的一页纸
写清输入字段与缩放;列出距离和初始化;报告候选 k 的簇内误差、轮廓、稳定性和簇大小;每簇展示中心、分位数与代表样本;指出不能解释的维度和潜在偏差;最后说明聚类结果将用于什么行动、谁负责复核、如何在新数据上分配样本。这样才能避免“聚类完成=发现了人群”的误读。
有两列特征:阅读分钟范围 0–120、收藏次数范围 0–3。分别用原始值和标准化值聚类,预测哪个结果更受阅读分钟支配,并设计一个比较表。
4.7.3 异常检测与半监督直觉
异常检测关注“与参考群体显著不同”的样本,不一定有明确的正类标签。它可以用距离、密度、孤立程度或重构误差定义异常分数,再根据验证窗口和人工容量选择阈值。异常是相对于时间、群组和业务上下文而言的:周末流量高不一定异常,某个小群组中的罕见行为也不一定危险。
KMeans 可以提供一种粗略直觉:离所属中心很远的点可能是候选异常,但它只适合与簇形状假设相符的场景。对长尾数据,均值和平方距离容易被极端值拉动;稳健统计、局部密度或按群组建模可能更合适。异常分数不是概率,阈值需要人工核验和成本分析。
半监督学习利用少量标签和大量未标注数据。先聚类、再抽样标注代表样本,可以帮助构建标签集;伪标签则把高置信预测当作临时标签,但错误会被放大,必须设置置信门槛、抽检和版本回滚。无监督结构不能替代真实标签,尤其在安全、医疗和财务场景。
| 方法 | 异常直觉 | 适合的证据 | 风险 |
|---|---|---|---|
| 中心距离 | 离典型中心远 | 簇内紧凑、尺度稳定 | 多簇/非球形失效 |
| 局部密度 | 邻域很稀疏 | 有足够近邻 | 高维距离集中 |
| 孤立程度 | 很少切分即可隔离 | 样本量较大 | 参数/随机性敏感 |
| 重构误差 | 难以被表示重构 | 有稳定表示模型 | 模型本身可能过拟合 |
| 规则+人工 | 违反明确约束 | 业务规则可靠 | 规则覆盖有限 |
def percentile(values, fraction):
ordered = sorted(values)
index = min(len(ordered) - 1, round((len(ordered) - 1) * fraction))
return ordered[index]
def distance_to_center(point, center):
return sum((a - b) ** 2 for a, b in zip(point, center)) ** 0.5
normal = [(1, 1), (1, 2), (2, 1), (2, 2), (3, 2)]
center = (sum(row[0] for row in normal) / len(normal), sum(row[1] for row in normal) / len(normal))
scores = [distance_to_center(point, center) for point in normal]
threshold = percentile(scores, 0.95)
new_points = [(2, 1), (9, 9)]
for point in new_points:
score = distance_to_center(point, center)
print(point, {"score": round(score, 2), "anomaly": score > threshold})
# 练习:改成按群组分别计算阈值,比较误报异常检测最重要的输出不是“异常/正常”二元标签,而是可排序的分数、阈值来源、触发原因和人工反馈。保存原始输入与时间窗口,避免数据变化后无法解释旧告警。若告警会触发自动动作,应先设置灰度期、人工复核和回滚条件,并单独监控误报、漏报和告警量。
半监督的边界
未标注样本可以帮助学习表示和覆盖分布,却不能自动告诉你哪一种行为是风险。伪标签只应在验证过的高置信区域使用,最好把伪标签版本、阈值、抽检结果和真实反馈分开记录。不要把模型自己生成的标签当作新的独立证据。
设计一条异常告警记录,至少包括对象 ID、时间窗口、分数、阈值、参考群组、触发特征和人工结果。说明为什么不能只保存布尔值。
| 能力 | 最低证据 | 迁移到大模型项目的用法 |
|---|---|---|
| 解释聚类循环 | 能手算分配与中心更新 | 理解 Embedding 分群和路由 |
| 选择距离/缩放 | 能说明尺度如何改变结果 | 设计语义向量检索指标 |
| 评估 k | 结合几何、稳定性和用途 | 确定文档主题或用户分组 |
| 管理异常 | 输出分数、阈值和复核记录 | 构建幻觉/延迟告警流程 |
聚类之后要做的不是命名,而是验证用途
得到簇以后,先查看每簇的样本数、中心和分位数,再抽取边界样本与代表样本。若两个簇只在一个未标准化的字段上分开,可能只是尺度造成的假象;若一个簇完全由某个用户或文档组成,可能是实体记忆。将聚类结果与时间、群组和原始数据并排检查,能发现这种结构性问题。
聚类稳定性可以从多个层面衡量。重复随机种子后,比较样本两两是否同簇;改变少量样本后,观察中心和簇大小;按时间窗口运行后,检查簇的代表样本是否稳定。稳定并不等于有用,不稳定也不一定无价值,但二者都应在报告中显式呈现。对生产分群,新增样本还要有“离所有中心都很远”的拒绝或人工复核路径。
无监督结果容易被人类赋予故事。看到一簇高阅读量样本,不代表它是“高潜用户”;需要验证这个分组是否能预测后续行为、支持不同内容或降低成本。若聚类只是为了探索,明确标注为假设生成;若要自动行动,必须增加下游 A/B 或人工评审证据。
| 项目 | 应报告 | 异常信号 | 后续动作 |
|---|---|---|---|
| 簇大小 | 数量、占比、时间变化 | 极小簇或突然消失 | 检查离群/数据缺失 |
| 中心摘要 | 均值、分位数、单位 | 超出训练范围 | 检查缩放/空簇 |
| 代表样本 | 中心近邻和边界样本 | 内容完全重复 | 去重或改粒度 |
| 稳定性 | 种子/重采样一致性 | 分组频繁翻转 | 增加数据或降复杂度 |
| 下游用途 | 行动、指标、复核人 | 只有故事没有结果 | 做验证实验 |
from itertools import combinations
def same_cluster_rate(left, right):
if len(left) != len(right):
raise ValueError("标签长度不一致")
pairs = list(combinations(range(len(left)), 2))
if not pairs:
return 1.0
same_left = {(i, j) for i, j in pairs if left[i] == left[j]}
same_right = {(i, j) for i, j in pairs if right[i] == right[j]}
agreements = sum((i, j) in same_left for i, j in same_right) + sum((i, j) not in same_left for i, j in pairs if (i, j) not in same_right)
return agreements / len(pairs)
run_a = [0, 0, 1, 1, 2, 2]
run_b = [1, 1, 0, 0, 2, 2]
print("pair agreement:", same_cluster_rate(run_a, run_b))
assert same_cluster_rate(run_a, run_b) == 1.0
# 练习:加入一个样本翻簇,观察稳定性下降示例把簇编号变化视为同一分组结构,使用两两同簇关系而不是直接比较编号。真实项目可以使用调整兰德指数、互信息等工具,但仍需说明是否允许簇编号置换。稳定性指标只是证据之一,最终是否保留聚类要看它能否支持明确、可复核的下游动作。
不要写“簇 1 是高潜用户”,改写成一个可验证的假设,包含输入窗口、后续行为、比较组和成功门槛。
本节小结:没有标签不等于没有验收标准
无监督学习仍然需要输入契约、距离假设、随机状态、稳定性证据和下游用途。KMeans 的簇编号没有顺序,k 也不是由算法自动揭示的真理;肘部、轮廓、稳定性、代表样本和业务行动应共同决定是否保留一个分组。若结果只带来好看的图和故事,而没有可验证的用途,就应把它标记为探索性发现。
异常分数要附带参考群体、时间窗口、阈值、触发特征和人工结果。伪标签和少量标注可以帮助扩展覆盖,但不能把模型自己的猜测当作独立真相。上线告警时先设置灰度、抽检和回滚,监控告警量、误报、漏报和群组差异。
| 场景 | 交付对象 | 必须附带 |
|---|---|---|
| 探索分群 | 簇摘要与代表样本 | 距离、缩放、k、稳定性 |
| 推荐路由 | 簇 ID/中心与行动规则 | 下游指标、拒绝路径、版本 |
| 异常告警 | 分数、阈值、上下文 | 人工反馈、成本、时间窗 |
| 半监督标注 | 候选样本与置信 | 抽检比例、伪标签版本 |
manifest = {
"method": "kmeans",
"k": 3,
"distance": "euclidean",
"scaling": "train_window_standardized",
"seed_runs": 10,
"stability": 0.86,
"smallest_cluster_rate": 0.04,
"downstream_test": "收藏率/人工复核",
"status": "exploratory",
}
if manifest["stability"] < 0.8:
manifest["status"] = "needs_more_data"
print(manifest)
assert manifest["distance"] and manifest["scaling"]设计 exploratory → reviewed → pilot → production 四个状态,每次状态转换需要什么证据,哪些情况必须回退。
从簇到行动,中间还缺一层人工语义
算法只看到向量、距离和目标函数,业务人员需要看到代表样本、字段分布和可执行的差异。把每簇的中心近邻、边界样本、时间变化和缺失率放在同一页,邀请领域人员给出候选命名,再用后续行为验证命名。命名过程和验证结果都要留痕,避免以后把一次主观标签当成算法事实。
异常检测同样需要参考群体。全局阈值可能把高流量群组全部标成异常,也可能漏掉低频群组的真实风险。按群组或时间窗口计算分数分布,再统一成本和容量约束,通常比一个固定阈值更诚实。若分数没有稳定的排名意义,就不要直接自动处罚。
| 环节 | 问题 | 证据 |
|---|---|---|
| 几何 | 相似性如何定义 | 距离、缩放、目标值 |
| 稳定性 | 重跑是否一致 | 种子、重采样、时间窗口 |
| 语义 | 每簇/异常代表什么 | 代表样本与领域复核 |
| 行动 | 结果改变什么流程 | 下游指标、成本、回滚 |
从一个聚类或异常任务中选择需要人工复核的样本类型,至少覆盖中心样本、边界样本、极端分数样本和随机对照,并说明各自的作用。
补充:没有标签时,记录“为什么相信”
无监督结果仍然需要一条可审计的证据链:相似性如何定义、输入是否缩放、随机初始化是否稳定、每个簇有多少样本、代表样本是否可解释、下游行动是否真的改善。若只是为了探索,明确标注假设;若要自动行动,增加人工复核、灰度和回滚。一个漂亮的分组图不是免检通行证。
异常检测应优先输出分数和上下文,再由策略层决定阈值。按群组和时间窗口维护参考分布,能避免全局阈值把正常的季节性峰值误报为异常。把标签成熟延迟、人工结果和成本一起记录,下一轮才能区分模型退化、数据漂移和反馈尚未到达。
无监督结果需要持续重算参考分布。数据窗口、缩放参数、中心和阈值一旦变化,旧的簇编号或异常分数就不应直接与新结果比较。把每次运行的摘要和代表样本存档,才能判断变化是数据真的变了,还是算法随机性造成的。若分组用于推荐或资源分配,还要保存旧簇到新簇的映射和迁移比例,防止编号变化被误认为用户群体突然改变。
异常和半监督流程都需要“拒绝不确定”的出口:低置信伪标签进入人工抽样,远离所有中心的新样本单独处理,标签延迟的记录保留 pending。把不确定性显式化,通常比强行归入某个簇更安全,也为后续有监督学习积累高价值标注。
无监督结果要经过稳定性与用途检验
聚类没有天然的“正确答案”,因此不能只凭一张二维散点图决定簇数。轮廓系数、簇内平方和、不同随机种子的分配一致性可以作为内部证据,但它们都不能替代业务核验。抽取每个簇的中心样本、边界样本和异常样本,给簇写出可读的描述,再检查分组是否真的改变了推荐、运营或人工排查策略。
除了 KMeans,还应知道密度方法适合形状不规则、密度差异明显的数据,但对距离、最小样本数和噪声标记敏感。方法选择要由数据生成过程和行动目标驱动:若需要固定数量的运营分组,质心方法便于解释;若要发现稀疏区域,密度或距离分数更合适。无论选择哪种算法,都要保存缩放参数、随机种子、窗口和代表样本。
| 层次 | 示例证据 | 要回答的问题 | 不能单独证明 |
|---|---|---|---|
| 数学 | 轮廓系数、簇内误差 | 分组是否相对紧凑 | 业务一定有意义 |
| 稳定 | 多种子、时间窗口、重采样 | 编号和边界是否稳定 | 未来一定不变 |
| 业务 | 代表样本、人工命名、行动结果 | 能否支持决策 | 产生因果效果 |
| 安全 | 异常率、拒答率、回滚 | 不确定时是否可控 | 没有误报 |
当没有标签时,把“发现结构”和“证明结构”分开写:聚类只能提出假设,后续可以用人工标注、时间复测或小规模实验验证假设。对大模型 Embedding 的聚类尤其要记录模型版本、文本清洗和向量归一化,否则下一次重新嵌入后,簇的变化无法解释。
08 · DIMENSIONALITY
降维与特征工程:在高维空间里保留什么
降维不是把图画得好看,而是在明确损失的前提下压缩表示。PCA 用方差最大的正交方向重表达数据;可视化、重构误差和下游任务分别回答不同问题。本节还讨论高维距离、稀疏性和特征工程的边界。
4.8.1 PCA:协方差、投影与主成分
主成分分析(PCA)把一组可能相关的数值特征重新表达成一组互相正交的方向。第一主成分寻找数据方差最大的方向,第二主成分在与第一方向正交的条件下寻找剩余方差最大的方向,依此类推。投影到前几个方向可以压缩维度,但必然丢失一部分信息;“保留 95% 方差”是一个重构标准,不等于保留了任务所需的 95% 语义。
PCA 前通常要中心化,是否标准化取决于各列单位和任务假设。若一列以秒计、另一列以比例计,不标准化时大尺度列会主导方差;若单位本身有意义,盲目标准化也可能抹掉重要的绝对量。均值和标准差只能在训练边界内拟合,验证与测试只应用同一变换,防止泄漏。
从线性代数角度看,PCA 对中心化数据的协方差矩阵做特征分解,或直接做奇异值分解。特征向量给出新坐标轴,特征值表示沿该轴的方差。主成分的正负号可以整体翻转而不改变子空间,因此不要把“第一主成分为正”解释成固定的因果方向。加载量、解释方差和下游任务要一起看。
| 对象 | 含义 | 要保存什么 | 常见误读 |
|---|---|---|---|
| 均值向量 | 训练数据中心 | 每列均值、版本 | 用全量数据计算 |
| 协方差矩阵 | 特征共同变化 | 维度、估计方式 | 相关性就是因果 |
| 主成分 | 正交投影方向 | 载荷、符号约定 | 方向有固定语义 |
| 特征值 | 该方向方差 | 解释方差比 | 方差等于任务信息 |
| 得分 | 样本在新轴的坐标 | 转换参数、形状 | 二维图代表全部关系 |
import math
def center(rows):
means = [sum(row[i] for row in rows) / len(rows) for i in range(len(rows[0]))]
return [[value - means[i] for i, value in enumerate(row)] for row in rows], means
def dot(left, right):
return sum(a * b for a, b in zip(left, right))
def power_iteration(rows, steps=30):
vector = [1.0] * len(rows[0])
for _ in range(steps):
covariance_times = [
sum(dot(column(row, axis), row) for row in rows)
for axis in range(len(vector))
]
norm = math.sqrt(dot(covariance_times, covariance_times)) or 1.0
vector = [value / norm for value in covariance_times]
return vector
def column(row, axis):
return row[axis]
rows, means = center([[1.0, 1.2], [2.0, 2.1], [3.0, 2.9], [4.0, 4.2]])
direction = power_iteration(rows)
scores = [dot(row, direction) for row in rows]
print({"means": means, "direction": direction, "scores": scores})
# 教学提示:正式 PCA 应使用成熟实现并核对中心化和 solver 配置手写的幂迭代只为了展示“反复把数据投影到一个方向并归一化”的想法,代码并没有完整处理协方差矩阵、正交化和数值稳定性。正式使用时可以调用 sklearn.decomposition.PCA,但要保存 components_、mean_、explained_variance_ratio_ 和随机状态。任何降维结果都要用重构误差和下游指标验收。
主成分是新坐标轴,不是自动生成的业务标签;载荷需要结合单位、相关性和任务解释。
易错:把主成分数量当成越少越好
降到二维便于画图,但可能丢掉区分少数类的方向;保留全部主成分则没有压缩收益。应在候选维度上同时报告解释方差、重构误差、下游验证指标、推理成本和可视化用途,并明确选择的是“展示维度”还是“模型输入维度”。
两个特征“阅读分钟”和“练习次数”高度正相关,第一主成分载荷都为正。用通俗语言解释这意味着什么,并写出不能据此推出的结论。
4.8.2 可视化、重构误差与信息保留
二维或三维降维图适合提出问题:是否存在明显群组、离群点或时间漂移?它不适合单独证明类别可分、语义距离保持或模型公平。投影会压扁高维方向,点重叠也可能只是因为关键差异落在被丢弃的轴上。报告图时应标出样本数、标准化方式、主成分数量、随机状态和颜色编码规则。
重构是一个更明确的验收。把低维坐标映回原空间,与中心化前的输入比较,可得到平均平方重构误差;不同特征单位时,误差需要按标准化空间或业务权重解释。低重构误差说明几何上保留了较多方差,不保证分类、检索或生成质量提高。下游任务必须在独立验证集上复测。
可视化还要防止标签泄漏和样本重复。若先用全量数据拟合 PCA,再把图用于挑选测试样本,测试信息已经影响探索决策;若同一文档的多个切片被画成一团,视觉上的“簇”可能只是文档 ID。探索阶段可以看全量,但任何影响模型选择的结论都要回到训练边界和独立评估。
| 证据 | 回答 | 适合的使用 | 不应过度解读 |
|---|---|---|---|
| 解释方差 | 保留了多少总体方差 | 选择压缩候选维度 | 任务信息比例 |
| 重构误差 | 还原原特征有多准 | 检查有损压缩 | 预测性能 |
| 二维图 | 投影后结构如何 | 探索、沟通假设 | 原空间全部邻居 |
| 下游指标 | 任务是否受益 | 最终模型选择 | 解释主成分因果 |
| 稳定性 | 重跑后方向/邻居是否一致 | 评估可重复性 | 保证未来稳定 |
def mse(actual, reconstructed):
values = [
(left - right) ** 2
for row_a, row_b in zip(actual, reconstructed)
for left, right in zip(row_a, row_b)
]
return sum(values) / len(values)
original = [[2.0, 2.1], [3.0, 3.2], [5.0, 4.8]]
reconstructed = [[2.1, 2.0], [3.1, 3.1], [4.9, 4.7]]
error = mse(original, reconstructed)
print({"mse": round(error, 4)})
assert error >= 0
# 练习:分别在原始尺度和标准化尺度计算误差并解释差异如果降维用于检索,除了重构误差还要测邻居保持率:原空间 top-k 邻居中有多少仍出现在低维 top-k。若用于分类,要比较不降维、不同维度和随机投影的验证指标;若用于可视化,只需说明它是探索图而非生产特征。把用途先写清楚,才能选择合适的验收标准。
图表说明的最低格式
标题写任务和数据窗口,副标题写变换与维度;坐标轴写“第 1 主成分”而不是模糊的“X”;图例说明颜色来自标签还是聚类;注释给出样本数与异常处理;正文同时提供一张表列出解释方差、重构误差和下游指标。这样图形才不会脱离证据单独讲故事。
降维图显示三类样本分得很开,但下游分类准确率从 0.91 降到 0.76。请给出两个可能原因和一个决定是否保留降维的报告结构。
4.8.3 维度灾难与特征工程
维度增加时,空间体积增长、样本变得稀疏,近邻距离可能集中,模型需要更多数据才能覆盖组合。一个在二维很直观的网格,在几百维几乎没有足够样本填满。高维并不一定坏,Embedding 和稀疏文本本来就需要高维表示;关键是距离、正则化、样本规模和任务是否匹配。
特征工程可以通过合并、聚合、交互、时间窗口和领域约束,把原始字段变成更稳定的表示。添加交互项可能让线性模型表达非线性,但也会迅速增加列数;多项式特征更容易过拟合。每次新增一组特征都要做消融、泄漏审计和多折稳定性检查,不要把“列更多”当作进步。
降维与特征选择目的不同。PCA 生成新的连续方向,可能难以回译到单个业务字段;选择保留原始列,解释和上线映射更直接,但可能错过多个弱特征的组合。可以同时比较两条路线:原始特征+正则化,和标准化+PCA+简单模型。若性能相近,优先选择更容易维护和审计的表示。
| 信号 | 可能原因 | 实验 | 修复方向 |
|---|---|---|---|
| 近邻距离接近 | 维度高、尺度未处理 | 距离分布/标准化对照 | 缩放、降维或改度量 |
| 训练高、验证低 | 特征过多、样本不足 | 特征消融、正则化曲线 | 选择/压缩/增加数据 |
| 解释困难 | 组合方向不可回译 | 载荷、置换、局部案例 | 保留原始特征或分组 |
| 线上变慢 | 维度和转换成本高 | 延迟、内存基准 | 缓存、压缩、批处理 |
| 时间漂移 | 特征含过时模式 | 滚动窗口表现 | 更新窗口、监控漂移 |
def make_features(row, mode):
base = [row["minutes"], row["exercises"]]
if mode == "base":
return base
if mode == "interaction":
return base + [row["minutes"] * row["exercises"]]
if mode == "ratio":
return base + [row["minutes"] / max(row["sessions"], 1)]
raise ValueError("未知特征方案")
rows = [
{"minutes": 20, "exercises": 2, "sessions": 4},
{"minutes": 40, "exercises": 3, "sessions": 5},
]
for mode in ("base", "interaction", "ratio"):
values = [make_features(row, mode) for row in rows]
print(mode, {"dimension": len(values[0]), "first": values[0]})
# 练习:把每个方案接到同一评估器,比较验证指标而非训练误差特征工程的可复现性依赖版本化:保存字段列表、聚合窗口、单位、缺失策略、随机种子和代码版本。线上推理必须复用同一转换顺序,不能让训练脚本和服务各自复制一份逻辑。对文本和 Embedding,还要记录 tokenizer、词表、截断长度和归一化方式;这些都属于“特征接口”。
用“最小充分表示”思考
先问完成任务最低需要哪些稳定事实,再逐步加入能被验证的特征。一个较小但边界清楚的表示,通常比一堆无法追溯的派生列更适合长期维护。若某特征只有在测试集上才显著提升,优先怀疑泄漏、偶然相关或时间漂移,而不是把它写进最终模型。
比较“原始列”“原始列+交互项”“PCA 保留 95% 方差”三种方案。为每种方案列出性能、解释、延迟和泄漏风险四项证据,最后给出选择条件。
| 能力 | 最低证据 | 迁移到大模型项目的用法 |
|---|---|---|
| 解释 PCA | 说明中心化、投影和方差 | 理解表示压缩与低维可视化 |
| 验收降维 | 同时看重构、下游和稳定性 | 评估 Embedding 压缩与召回 |
| 诊断高维 | 能指出距离集中/数据稀疏 | 选择向量索引和相似度 |
| 管理特征 | 保存版本、窗口和转换契约 | 确保训练与推理表示一致 |
降维前后都要保留原始索引
降维矩阵只有数值坐标,若丢失样本 ID、时间、标签和原始字段,就无法回到具体案例解释一个点。转换时把行索引与坐标一起保存,抽样检查中心附近、边界和离群点的原始文本或业务记录。图表颜色、形状和透明度也应来自明确字段,不能手工涂色后再编故事。
PCA 的组件方向可能因为标准化、样本窗口和数值精度而改变。比较两个版本时,不要直接比较组件向量的符号和顺序;可以比较子空间、解释方差、投影后的邻居和下游指标。若组件在多个时间窗口都稳定,才更适合进入可维护的特征管道;否则把它限定为探索性工具。
高维压缩还要考虑资源和错误传播。降低维度可以减少存储、检索和矩阵乘法成本,却可能让少数重要样本的距离发生变化;保留更多维度会增加延迟和内存,也可能放大噪声。设定压缩预算时,同时记录维度、文件大小、批量/单条延迟、召回保持率和任务分数,避免只优化一个数字。
| 版本字段 | 示例 | 为什么重要 | 比较方法 |
|---|---|---|---|
| 数据窗口 | 2026-07 | 方差和分布会变 | 滚动窗口报告 |
| 预处理 | 中心化+标准化 | 决定主方向 | 保存均值/尺度 |
| 维度 | 原始 128→32 | 影响成本和信息 | 重构/召回/下游 |
| 组件/随机状态 | solver、seed | 支持复现 | 子空间/邻居稳定 |
| 行索引 | sample_id | 可回溯原始样本 | 抽样核对 |
def audit_projection(records, coordinates):
if len(records) != len(coordinates):
raise ValueError("原始记录和坐标行数不一致")
seen = set()
output = []
for record, coordinate in zip(records, coordinates):
sample_id = record["id"]
if sample_id in seen:
raise ValueError(f"重复 sample_id: {sample_id}")
if len(coordinate) != 2:
raise ValueError("本报告要求二维坐标")
seen.add(sample_id)
output.append({"id": sample_id, "x": coordinate[0], "y": coordinate[1], "label": record.get("label")})
return output
records = [{"id": "a", "label": "教程"}, {"id": "b", "label": "项目"}]
coords = [(0.2, 0.1), (-0.4, 0.6)]
audited = audit_projection(records, coords)
print(audited)
assert [row["id"] for row in audited] == ["a", "b"]投影审计输出可以与图表同版本保存,保证图片上的点能回到原始样本。若图形用于模型选择,还应把训练/验证边界写入元数据;若只是教学展示,可以使用合成数据并明确标注。任何自制图形都应说明数据是否真实、变换是否有损和结论能否外推。
为一个向量检索系统设计压缩验收条件:一项约束存储/延迟,一项约束检索质量。说明为什么两项必须同时满足。
本节小结:压缩的代价必须可量化
PCA 和其他降维方法改变了坐标与距离,因而改变了后续模型看到的信息。你要先决定用途:为了画图,可以接受有损投影;为了降低检索成本,要测邻居保持率和召回;为了训练预测模型,要在独立验证集比较下游指标。解释方差只回答总体方差,不回答业务任务是否保留。
高维特征工程要把新增信息、泄漏风险、计算成本和解释难度一起写入实验表。原始列+正则化、标准化+PCA、领域聚合和随机投影都可以成为候选,但每次只能改变一组因素。保留样本 ID、时间和版本,才能从二维图或压缩向量回到原始证据。
| 用途 | 主指标 | 守护指标 | 拒绝条件 |
|---|---|---|---|
| 可视化 | 结构可读性 | 样本可回溯 | 颜色/标签泄漏 |
| 检索压缩 | Recall@k、延迟 | 索引大小、邻居稳定 | 质量下降超门槛 |
| 预测输入 | 验证集任务指标 | 群组/时间表现 | 少数类或未来窗口恶化 |
| 存储交换 | 重构误差、大小 | 数值范围、版本 | 无法恢复或无法审计 |
candidates = [
{"name": "raw", "dimension": 768, "recall": 0.91, "latency": 40, "explain": 0.9},
{"name": "pca_192", "dimension": 192, "recall": 0.90, "latency": 18, "explain": 0.5},
{"name": "pca_64", "dimension": 64, "recall": 0.84, "latency": 10, "explain": 0.4},
]
eligible = [row for row in candidates if row["recall"] >= 0.89 and row["latency"] <= 25]
chosen = min(eligible, key=lambda row: row["dimension"])
print({"chosen": chosen["name"], "eligible": eligible})
assert chosen["name"] == "pca_192"当一个二维图很漂亮,但样本 ID 无法回溯、下游召回下降 12%、组件只在一个月份稳定时,你会如何写拒绝理由?
降维报告要同时服务三类读者
算法读者关心中心化、组件、求解器和重构误差;业务读者关心样本是否仍能区分、哪些信息被压缩;工程读者关心维度、内存、延迟和版本兼容。用一张指标表和一组可回溯样本连接三类读者,比只放一张二维散点图更有效。任何压缩收益都要写出牺牲了什么。
在大模型项目中,PCA 可以用于探索 Embedding 的主方向或降低索引成本,但不要把主成分自动当作“语义维度”。固定 tokenizer、Embedding 模型、归一化、数据窗口和随机状态,比较压缩前后的召回与错误样例,才能判断压缩是否值得。
| 读者 | 必须看到 | 常见缺失 |
|---|---|---|
| 算法 | 均值、组件、方差、误差 | 只给图片 |
| 业务 | 下游指标、代表样本、限制 | 把方差当价值 |
| 工程 | 维度、延迟、版本、回滚 | 无法复现转换 |
为“768 维 Embedding 压到 192 维”的方案写三句话,分别面向算法、业务和工程读者;每句话包含一个可检查数字或限制。
补充:降维前先写清楚“保留什么”
PCA 和其他降维方法都会丢失信息;真正的问题不是“能不能压缩”,而是“哪些关系必须保留”。为可视化保留局部结构,为检索保留近邻,为预测保留下游判别方向,为传输保留可接受的重构误差。不同用途可以选择不同维度,不能用一个漂亮的二维图替代所有验收。
把原始 ID、时间、标签、预处理参数和组件版本与坐标一起保存。比较版本时看子空间、邻居、下游指标、文件大小和延迟,不要直接比较主成分正负号。这样当 Embedding 模型、数据窗口或 tokenizer 变化时,压缩结果仍然可以被追溯。
降维不是删除维度越多越先进,而是在成本、误差和任务价值之间做选择。把压缩前后的查询、分类或重构证据放在同一张表,标出数据窗口和版本;当收益不足或稳定性下降时,保留原始表示并把低维结果限定为探索工具。对外展示图形时,还要明确颜色是否使用标签、点是否代表独立样本,以及投影是否在训练边界内拟合,避免视觉证据越过评估边界。
当高维距离出现集中、近邻不稳定或特征组合爆炸时,先区分是数据量不足、尺度不一致、噪声过多还是任务本来需要高维表达。降维、特征选择、正则化和增加样本是不同的解法,不能仅凭二维图选择其中一个。每次变化都保留原始索引和失败样例,方便回到真正的任务指标。
降维之前先处理尺度、稀疏性与评估边界
PCA 依赖协方差结构,尺度较大的特征会支配主成分;因此标准化是否必要,要由单位和任务决定,而不是机械地套模板。文本向量通常是高维稀疏矩阵,直接转换成稠密矩阵可能带来巨大的内存开销;可考虑适合稀疏输入的截断 SVD,并把转换器只在训练数据上拟合。降维参数、词表和归一化都属于会学习信息的步骤,必须遵守同样的切分边界。
“解释了 95% 方差”只说明保留了输入方差,不保证下游分类、检索或回归同样保真。选择维度时至少同时看重构误差、近邻保持率、下游指标、文件大小和延迟;若二维投影用于展示,还要注明颜色是否来自标签、点是否代表独立样本、投影是否跨越时间边界。可视化是探索工具,不应替代独立测试。
| 目标 | 主要指标 | 需要保留 | 失败信号 |
|---|---|---|---|
| 压缩存储 | 文件大小、重构误差 | 能接受的数值误差 | 解压后关键字段失真 |
| 近邻检索 | Recall@k、邻居重叠 | 局部距离关系 | 相似样本被拆散 |
| 下游预测 | 验证/测试指标 | 判别信息 | 降维后性能明显下降 |
| 可视化 | 稳定性、标注一致性 | 可解释投影与图例 | 颜色制造虚假分离 |
实际工程中常保留两条路径:原始特征用于最终评估和审计,低维表示用于探索、缓存或轻量模型。两条路径共用同一份样本 ID 和版本清单,才能在发现低维图形异常时回到原始数据定位原因。对 Embedding 压缩也要复测相似度、答案质量和检索延迟,不能只看向量维数变小。
09 · REPRODUCIBLE WORKFLOW
端到端实验:把一次训练变成可复现的工作流
最后把前面的知识串起来:定义数据契约,按边界切分,使用 Pipeline 封装预处理和模型,记录随机种子、版本、指标与失败样例,完成一个小型文本/表格预测项目。结尾提供复盘清单,并为下一章神经网络做好接口准备。
4.9.1 Pipeline、实验记录与可复现性
端到端工作流的第一原则是让训练和推理使用同一套变换。标准化、缺失值填补、类别编码、特征选择和模型应组成一个可序列化的 Pipeline,而不是散落在 notebook 的不同单元格里。scikit-learn 的 Pipeline 把若干转换器和最终预测器按顺序连接,并在交叉验证时让每一折独立拟合转换参数,从结构上减少泄漏机会。
可复现不等于只设置一个随机种子。还要锁定数据快照、代码版本、依赖版本、特征字典、切分规则、模型配置、硬件与线程数,并保存训练日志、验证结果、最终测试结果和失败样例。随机种子只能让某些随机步骤重复,不能让已经改变的数据或库版本回到过去。实验记录应足以让另一个人从原始快照重建同一张指标表。
一个实验配置要区分“输入事实”和“决策选择”。输入事实包括样本数、时间范围、标签分布和缺失率;决策选择包括模型、超参数、阈值和特征方案。不要把测试集分数写进配置再据此选择下一轮;把每轮配置与结果追加保存,最终用一个冻结版本生成报告。这样失败实验也有价值,因为它说明了哪些假设没有成立。
| 层 | 输入/输出 | 何时拟合 | 审计内容 |
|---|---|---|---|
| 读取与校验 | 原始行→合法样本 | 每次运行都检查 | schema、ID、时间 |
| 填补/编码 | 原始字段→数值矩阵 | 只在训练折拟合 | 统计量、未知值策略 |
| 缩放/降维 | 矩阵→模型空间 | 只在训练折拟合 | 均值、组件、维度 |
| 预测器 | 特征→分数/数值 | 训练折拟合 | 参数、版本、随机状态 |
| 阈值/报告 | 分数→行动/指标 | 验证集选择 | 阈值、成本、样例 |
class Standardizer:
def fit(self, values):
self.mean = sum(values) / len(values)
variance = sum((value - self.mean) ** 2 for value in values) / len(values)
self.scale = variance ** 0.5 or 1.0
return self
def transform(self, values):
if not hasattr(self, "mean"):
raise RuntimeError("必须先 fit 训练数据")
return [(value - self.mean) / self.scale for value in values]
class MeanRegressor:
def fit(self, values, labels):
self.value = sum(labels) / len(labels)
return self
def predict(self, values):
return [self.value] * len(values)
train_x = [10.0, 12.0, 14.0]
train_y = [1.0, 2.0, 3.0]
valid_x = [13.0, 16.0]
scaler = Standardizer().fit(train_x)
model = MeanRegressor().fit(scaler.transform(train_x), train_y)
print(model.predict(scaler.transform(valid_x)))
assert scaler.mean == 12.0示例把转换器和预测器拆成两个职责:转换器只学习训练统计量,预测器只消费已经转换的特征。真实 Pipeline 还要实现 fit、transform、predict 的一致接口,并处理列名、缺失值和持久化。若一个转换步骤读取了标签或全量数据,放进 Pipeline 也不能自动使它正确,仍要检查其实现。
实验日志不是流水账
每轮至少保存:实验 ID、父版本、数据快照、切分摘要、特征方案、模型与超参数、随机状态、训练时间、验证指标、阈值、失败原因和下一步。指标表旁边放一两个典型错误样例,能把数字连接到具体行为。对大模型相关任务,再加 tokenizer/Embedding 版本、上下文长度和调用成本。
为一次二分类实验设计 JSON 记录字段,要求既能复现训练,也能解释为什么选择当前阈值。列出至少 12 个键,并说明哪些字段来自事实、哪些字段来自决策。
4.9.2 一个从数据到报告的迷你项目
下面用“预测一篇学习资料是否会被收藏”做一个小项目。每行代表一次展示,特征包括标题长度、正文长度、用户过去七天阅读分钟和资料类别;标签是展示后七天内是否收藏。示例数据是人为构造的,目的在于展示职责和报告结构,不代表真实用户规律。真正项目应替换为经过授权、去标识和审计的数据。
项目分成五步:建立数据契约;按用户和时间切分;在训练边界内构造数值特征;用简单分类器输出概率;在验证集选择阈值并在测试集冻结报告。每一步都保存中间统计,出现异常时可以定位是读取、标签、切分、模型还是阈值出了问题。不要把所有逻辑写在一个循环里,至少拆出“读取样本”“构造特征”“训练预测”“评估报告”四个函数。
报告不仅给出 F1。还要列出正类比例、基线、混淆矩阵、PR-AUC 或阈值表、每个群组的表现、最差样本、训练/推理耗时和已知限制。若模型要服务推荐,覆盖率和用户体验同样重要;若模型只做候选排序,可以把阈值改成 top-k 容量。指标随行动变化,代码结构也应允许策略层独立调整。
| 阶段 | 输入 | 输出 | 验收问题 |
|---|---|---|---|
| 读取 | CSV/JSON 快照 | 合法样本列表 | ID 是否唯一、字段是否齐全 |
| 构造特征 | 样本与历史事件 | 训练矩阵 | 是否只使用预测时点前信息 |
| 切分 | 矩阵与标签 | train/valid/test | 用户/时间是否交叉 |
| 训练 | 训练矩阵 | 概率/分数 | 基线是否被超过 |
| 报告 | 冻结预测 | 指标和样例 | 是否能复跑、能解释成本 |
from collections import defaultdict
from math import exp
def read_rows():
return [
{"id": "a1", "user": "u1", "title_len": 18, "minutes_7d": 80, "label": 1},
{"id": "a2", "user": "u1", "title_len": 8, "minutes_7d": 12, "label": 0},
{"id": "b1", "user": "u2", "title_len": 25, "minutes_7d": 66, "label": 1},
{"id": "b2", "user": "u2", "title_len": 6, "minutes_7d": 5, "label": 0},
{"id": "c1", "user": "u3", "title_len": 14, "minutes_7d": 40, "label": 0},
{"id": "c2", "user": "u3", "title_len": 22, "minutes_7d": 95, "label": 1},
]
def make_features(row):
return [row["title_len"] / 25, row["minutes_7d"] / 100]
def score(features, weights=(2.4, 1.7), bias=-2.0):
value = bias + sum(x * w for x, w in zip(features, weights))
return 1 / (1 + exp(-value))
def report(rows, threshold):
counts = defaultdict(int)
for row in rows:
probability = score(make_features(row))
prediction = int(probability >= threshold)
counts[(row["label"], prediction)] += 1
tp, fp = counts[(1, 1)], counts[(0, 1)]
fn, tn = counts[(1, 0)], counts[(0, 0)]
precision = tp / (tp + fp) if tp + fp else 0.0
recall = tp / (tp + fn) if tp + fn else 0.0
return {"tp": tp, "fp": fp, "fn": fn, "tn": tn, "precision": precision, "recall": recall}
rows = read_rows()
assert len({row["id"] for row in rows}) == len(rows)
print(report(rows, threshold=0.5))
# 练习:按用户群组切分,再只在验证集选择 threshold示例中的权重是手工设置的,方便读者立即运行;真实项目应替换成训练器,并把训练用户与测试用户隔离。报告函数只负责把冻结概率翻译成混淆矩阵,阈值是参数而不是写死的事实。将数据读取、特征构造、评分和报告拆开后,可以单独测试每个职责,也能在换成 scikit-learn Pipeline 时保留同一份验收逻辑。
项目作业:学习资料收藏预测器
完成一个可复现的小项目:给定一份脱敏的展示记录,预测用户是否会收藏,并写出 README。不要追求复杂模型,重点是边界、指标和解释。
评分重点不是代码长短,而是职责是否清楚:至少拆出“读取样本”“构造特征”“训练/预测”“生成报告”四个函数;重复 ID 用断言拒绝;关键词或类别编码应明确大小写、未知值和缺失策略;完成后检查保存的 JSON 是否能被再次读取。提交时附上一个失败样例和一个你决定不使用的特征。
项目复盘的四个角度
数据角度:标签是否成熟、群组是否泄漏、特征是否可用;模型角度:是否超过基线、复杂度是否必要、概率是否校准;决策角度:阈值是否符合容量与成本、错误由谁处理;工程角度:能否重跑、延迟和版本是否记录、异常如何回滚。复盘不是给项目找一个漂亮结论,而是把下一轮最值得做的实验写清楚。
为迷你项目写报告目录:背景、数据、切分、基线、模型、指标、阈值、错误样例、资源、限制和下一步。要求每一节都能引用一个数字或样例,而不是只写形容词。
4.9.3 章末复盘:从机器学习走向神经网络
本章的主线可以压缩成一句话:先把要预测的对象和可用信息写清楚,再用诚实的切分保护证据,用简单基线建立参照,用合适指标衡量错误,最后才增加模型容量。线性模型、树模型、聚类和 PCA 不是互相替代的“排行榜”,而是带有不同假设的工具箱。选择工具时,先看数据生成过程、任务代价和维护边界。
进入神经网络前,建议能独立回答这些问题:一行样本代表什么?标签在什么时候成熟?为什么不能随机切分?预处理参数在哪一份数据上拟合?accuracy 与 recall 分别掩盖什么?训练分数高而验证分数低说明什么?阈值改变的是模型还是行动?聚类编号为什么没有顺序?PCA 的高方差方向为什么不等于最有用的任务方向?Pipeline 怎样避免每一折泄漏?
神经网络会把“特征权重”扩展为多层参数,把线性组合、激活函数、损失和梯度连接起来,但不会取消本章的纪律。数据切分、标签质量、验证边界、错误成本、版本记录和失败样例仍然决定实验是否可信。下一章将从单个神经元、前向传播和损失函数开始,把本章的线性模型直觉继续向深度学习延伸。
| 本章概念 | 神经网络对应物 | 保持不变的纪律 | 需要新增的知识 |
|---|---|---|---|
| 线性组合 | 线性层/神经元 | 形状、单位、权重解释 | 激活函数与多层组合 |
| 损失指标 | 训练目标 | 指标与业务代价分离 | 反向传播与梯度 |
| 正则化 | 权重衰减、dropout、早停 | 验证集选择复杂度 | 训练动态与优化器 |
| Pipeline | 数据集/变换/训练循环 | 训练-推理一致 | 批次、设备与检查点 |
| 错误样例 | 可解释性与调试 | 保存失败证据 | 梯度/激活诊断 |
review = {
"task": "预测未来七天是否收藏",
"sample_unit": "一次展示",
"prediction_time": "展示时刻",
"split": "按用户分组并保持时间顺序",
"baseline": "多数类与固定规则",
"main_metric": "precision_at_recall_0.7",
"leakage_checks": ["未来字段", "重复用户", "全量预处理"],
"next_topic": "神经元、前向传播、损失与反向传播",
}
required = {"task", "sample_unit", "prediction_time", "split", "baseline", "main_metric"}
missing = required - review.keys()
if missing:
raise ValueError(f"复盘缺少: {sorted(missing)}")
print("chapter review passed")复盘表的作用是把章节知识转成项目接口。你可以将它保存为 JSON,与模型文件和评估报告放在同一版本目录;下一章训练神经网络时继续复用这些字段。若无法写出任务、切分、指标和泄漏检查,先不要急着增加层数或显卡预算。
全章易错总表
把测试集调成“看起来最好”;把用户 ID 当特征;先全量标准化再切分;用 accuracy 掩盖少数类;把概率当真实发生率;把相关性说成因果;把树的阈值写成自然规律;把聚类编号当类别等级;把二维 PCA 图当完整语义地图;只保存冠军分数而不保存失败实验。遇到这些表述时,回到本章的任务契约、数据边界和错误成本。
不查资料,用一页纸回答十个问题:任务类型、样本粒度、测试集职责、时间切分理由、一个泄漏例子、MAE 与 RMSE 差异、precision 与 recall 的取舍、过拟合信号、聚类数量证据、PCA 的局限。回答后再回到对应小节补漏。
| 能力 | 最低证据 | 下一章的连接 |
|---|---|---|
| 任务与数据 | 能写任务卡、特征字典和切分规则 | 构造 Dataset 与 DataLoader |
| 模型与指标 | 能训练基线并解释错误 | 选择神经网络损失和输出层 |
| 泛化与调参 | 能用验证/交叉验证控制复杂度 | 理解训练曲线、早停和权重衰减 |
| 工程与复盘 | 能保存 Pipeline、版本和报告 | 管理检查点、随机种子与实验追踪 |
把“可复现”拆成五个可检查的层次
第一层是数据可复现:原始快照、脱敏方式、行数、哈希和读取顺序固定;第二层是边界可复现:切分、分层、群组、时间窗口和标签成熟规则固定;第三层是计算可复现:特征转换、模型配置、随机种子、依赖版本和硬件记录;第四层是评估可复现:指标实现、正类定义、阈值、分桶和成本假设固定;第五层是叙事可复现:报告引用的数字、图表和错误样例都能追到运行产物。缺少任何一层,都可能出现“代码一样但结论不同”。
文件组织可以帮助维持这些层次。一个实验目录至少包含 config.json、data_summary.json、features.json、metrics.json、predictions.csv、errors.jsonl、README.md 和模型文件。测试集预测和线上反馈要设置只读权限,避免不小心被下一轮训练读取。目录名称使用实验 ID 和版本,不要用“最终版2_真的最终版”。
复现脚本应从命令行一次完成:读取配置,校验数据,生成切分,拟合 Pipeline,保存预测和报告。若必须手工修改 notebook,至少把最终参数导出成配置并让脚本重新跑一遍。运行失败时保存错误堆栈和输入摘要,不要只截图终端。对大模型调用,还要记录模型名称、温度、系统提示词、工具版本和调用成本,因为同一提示在不同版本可能产生不同输出。
| 产物 | 内容 | 用途 | 读写边界 |
|---|---|---|---|
| config.json | 模型、超参数、种子、阈值 | 重跑与审阅 | 训练前冻结候选 |
| data_summary.json | 窗口、行数、比例、缺失 | 比较数据变化 | 只由读取脚本生成 |
| predictions.csv | ID、分数、概率、预测 | 阈值和错误分析 | 测试集只读 |
| metrics.json | 整体/分组/分桶指标 | 报告与监控 | 带指标版本 |
| errors.jsonl | 代表 FP/FN、上下文 | 定性复盘 | 脱敏、可追溯 |
| README.md | 运行命令、限制、下一步 | 交接与复现 | 与代码同版本 |
import json
from pathlib import Path
def write_manifest(path, config, summary, metrics):
required_config = {"experiment_id", "data_snapshot", "seed", "model"}
missing = required_config - config.keys()
if missing:
raise ValueError(f"配置缺少: {sorted(missing)}")
manifest = {"config": config, "data_summary": summary, "metrics": metrics}
Path(path).write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
def read_manifest(path):
manifest = json.loads(Path(path).read_text(encoding="utf-8"))
if not {"config", "data_summary", "metrics"} <= manifest.keys():
raise ValueError("实验清单结构不完整")
return manifest
config = {"experiment_id": "ml-2026-08-27-01", "data_snapshot": "demo-v1", "seed": 11, "model": "logistic"}
summary = {"train": 80, "valid": 20, "test": 20}
metrics = {"valid_f1": 0.67, "test_f1": 0.64}
write_manifest("manifest-demo.json", config, summary, metrics)
loaded = read_manifest("manifest-demo.json")
print(loaded["config"]["experiment_id"])
# 练习:把路径改为临时目录,并为 manifest 加入 code_version示例展示了“保存后再次读取”的最低闭环。真实项目不要把个人隐私、密钥或完整原始文本直接写进日志;错误样例应做脱敏和权限控制。若同一实验要跨机器运行,使用相对路径、环境锁定文件和明确的时间格式,避免路径、时区和浮点差异造成难以解释的变化。
为你的项目设计版本目录结构,指定哪一个文件保存模型、哪一个文件保存指标、哪一个文件保存错误样例,以及上线失败时如何回退到上一版本。
下一步:带着契约进入神经网络
完成本章后,不要只记住算法名称。请保留一份自己的任务卡、切分摘要、基线报告和复盘清单;下一章会把线性层、损失和梯度接到同一条实验链上。每次更换模型,都先问数据边界和指标是否仍然成立。
如果你能从一份小数据快照重跑出同一份 JSON 报告,能指出一个泄漏风险并解释一次 FP/FN,那么机器学习基础已经真正落地。端到端项目的完成标准是证据闭环:数据可以追溯,代码可以重跑,指标可以复算,失败样例可以讨论,版本可以回滚。