01 / 本课学习路线
本课学习路线
阅读与推演约 116 分钟,练习约 80 分钟,进阶练习另需约 20 分钟
02 / 学习目标与先修自测
学完本课你能做什么,以及开始前需要会什么
阈值题和树题共用一件事:把「判定规则」写成确定的比较——分数不小于阈值判正、特征不大于阈值走左、票数并列取小。
| 学完后能做的事 | 正文位置 | 检查方式 |
|---|---|---|
| 拿到 10 条预测和一个阈值,不运行代码填出四格并算出精确率、召回率、F1 | 第 04 节 | 自查第 2 条、代码自测 |
| 解释阈值下移时召回率为什么不会降、精确率为什么常会降 | 第 04 节 | 自查第 3 条 |
| 说出同分样本为什么必须一起跨过阈值,并列 F1 为什么取更大阈值 | 第 05 节 | 自查第 4 条、练习 2 |
| 用基尼对比解释切分好坏,用训练集与验证集成绩说明树太深的代价 | 第 06 节 | 自查第 5 条 |
| 说出平票处理和阈值相等时的走向规则 | 第 07 节 | 自查第 6 条、练习 3 |
| 通过 AI005 与 AI004 | 第 05、07、08 节 | 自查第 1 条 |
先修自测:下面 5 题请先自己写答案,再展开对照。答不出的按括号里的位置补看再回来。本课假定你已完成模块 2 的「复合排序与并列规则」和「哈希表:计数、去重与查询」。
| 题号 | 题目 | 补看位置 |
|---|---|---|
| 自测 1 | pairs = [(0.7, 1), (0.9, 1), (0.8, 0)],按分数从高到低排序怎么写? | 复合排序与并列规则 |
| 自测 2 | votes = {2: 1, 1: 1},票数相同要选类别小的:min(votes, key=...) 的键怎么写? | 哈希表:计数、去重与查询第 04 节 |
| 自测 3 | tp / (tp + fp) 在 tp + fp == 0 时会怎样?怎么按题目要求返回 0? | 条件与分支 |
| 自测 4 | 从 1 号节点出发,while nodes[cur][0] != "L" 每次把 cur 换成左或右孩子——为什么不用递归? | 栈与队列第 03 节 |
| 自测 5 | 整段按空白切开后,第 i 条样本的分数和标签在 data 的哪两个位置? | 输入输出规则、数据范围与精度 |
展开先修自测答案
自测 1:pairs.sort(key=lambda p: -p[0]),得到 [(0.9, 1), (0.8, 0), (0.7, 1)]——AI005 扫描的起点。
自测 2:min(votes, key=lambda lab: (-votes[lab], lab))——先按票数降序,再按类别升序,得到 1。AI004 的表决规则。
自测 3:抛出除以零的运行错误。按题目要求写显式分支:tp / (tp + fp) if tp + fp else 0。
自测 4:Python 递归默认最多约 1000 层,题目允许的树可能是几千层的链;循环只占常数栈空间。
自测 5:data[1 + 2 * i] 是分数、data[2 + 2 * i] 是标签(下标 0 是 n)。
03 / 概念与术语
四格、三个比值、候选阈值、节点与表决
本课的词都能落到代码的某一行。下表第三列给出它们在第 04 节参考实现或第 08 节完整程序里的位置。
| 术语 | 含义 | 代码里的位置 |
|---|---|---|
四格:真正例(TP)、假正例(FP)、假负例(FN)、真负例(TN) | 预测正 / 负 × 真实正 / 负的四种组合;分数不小于阈值判正 | confusion_matrix 的四个分支 |
精确率(precision) | 预测为正的里有多少真是正:TP / (TP + FP) | tp / (tp + fp) if tp + fp else 0 |
召回率(recall) | 真是正的里找到了多少:TP / (TP + FN) | tp / (tp + fn) if tp + fn else 0 |
| F1 | 2TP / (2TP + FP + FN),等价于精确率与召回率的调和平均;分母为 0 时按 0 | 2 * tp / denom if denom else 0 |
| 候选阈值 | AI005 规定阈值只能取某个样本分数;排序后每个不同分数都是一个候选 | while i < n: 外层每轮一个候选 |
| 一起跨过阈值 | 分数相同的样本在同一个阈值下判定相同,必须一起计入再结算 | 内层 while pairs[i][0] == t |
| 内部节点 / 叶子 | 内部节点存「特征、阈值、左右孩子」;叶子存类别;相等走左 | cur = left if x[feature] <= threshold else right |
| 多数表决 | 每棵树一票,票数最多者胜,平票取数值更小的类别 | min(votes, key=lambda lab: (-votes[lab], lab)) |
| 宏平均 / 微平均 | 宏平均 = k 个类别 F1 的算术平均(没出现的类也算);微平均 = 把各类 TP、FP、FN 加总后算一个 F1 | f1_sum / k、2 * tps / denom |
04 / 四格与阈值
混淆矩阵统计与阈值选择
拿 10 条预测、阈值定在 0.60,逐条填四格,再看阈值移动时两个指标怎么此消彼长。
| 分数 | 真实标签 | 判定 | 分类结果 |
|---|---|---|---|
| 0.95 | 1 | 预测正 | TP |
| 0.90 | 1 | 预测正 | TP |
| 0.85 | 0 | 预测正 | FP |
| 0.70 | 1 | 预测正 | TP |
| 0.65 | 0 | 预测正 | FP |
| 0.60 | 1 | 预测正 | TP |
| 0.45 | 1 | 预测负 | FN |
| 0.40 | 0 | 预测负 | TN |
| 0.30 | 0 | 预测负 | TN |
| 0.10 | 0 | 预测负 | TN |
TP=4、FP=2、FN=1、TN=3。精确率 = 4/6 = 0.667,召回率 = 4/5 = 0.8,F1 = 2×4/(2×4+2+1) = 0.727——它等价于 P、R 的调和平均 2PR/(P+R)。
| 阈值 | TP | FP | FN | TN | 精确率 | 召回率 | F1 |
|---|---|---|---|---|---|---|---|
| 0.95 | 1 | 0 | 4 | 5 | 1.000 | 0.200 | 0.333 |
| 0.60 | 4 | 2 | 1 | 3 | 0.667 | 0.800 | 0.727 |
| 0.45 | 5 | 2 | 0 | 3 | 0.714 | 1.000 | 0.833 |
阈值下移只会把更多样本划成「正」:召回率只涨不跌,新增的假正例(FP)可能使精确率下降。P=1.0、R=0.2 那行算术平均还有 0.6,F1 只有 0.333——结果受较小的一方限制。
一次线性扫描计算全部候选阈值:AI005 的核心片段
Pythonpairs.sort(key=lambda p: -p[0]) # 分数从高到低
total_pos = sum(label for _, label in pairs)
tp = fp = 0
best_f1, best_t = -1.0, pairs[0][0]
i = 0
while i < n:
t = pairs[i][0]
while i < n and pairs[i][0] == t: # 并列分数一起跨过阈值
if pairs[i][1] == 1:
tp += 1
else:
fp += 1
i += 1
fn = total_pos - tp
denom = 2 * tp + fp + fn
f1 = 0.0 if denom == 0 else 2 * tp / denom
if f1 > best_f1 + 1e-12: # 并列不更新,保留先遇到的更大阈值
best_f1, best_t = f1, t排序后阈值每下一档,新跨进来的样本只会从「预测负」变「预测正」——TP、FP 只增不减,累计计数就行。相同分数在内层一次处理完再结算,保证同分样本一起跨过阈值。排序 O(n log n)、扫描 O(n);对每个阈值重数一遍是 O(n²),n=50000 就是 25 亿次,必然超时。
混淆矩阵与精确率、召回率、F1 的参考实现
Pythondef confusion_matrix(rows, threshold):
# rows 是 (分数, 真实标签) 的列表;分数不小于阈值判为正
tp = fp = fn = tn = 0
for score, label in rows:
predicted = 1 if score >= threshold else 0
if predicted == 1 and label == 1:
tp += 1
elif predicted == 1 and label == 0:
fp += 1
elif predicted == 0 and label == 1:
fn += 1
else:
tn += 1
return tp, fp, fn, tn
def precision_recall_f1(rows, threshold):
tp, fp, fn, _ = confusion_matrix(rows, threshold)
precision = tp / (tp + fp) if tp + fp else 0 # 三个分母各写一个显式分支
recall = tp / (tp + fn) if tp + fn else 0
denom = 2 * tp + fp + fn
f1 = 2 * tp / denom if denom else 0
return precision, recall, f1
rows = [
(0.95, 1), (0.90, 1), (0.85, 0), (0.70, 1), (0.65, 0),
(0.60, 1), (0.45, 1), (0.40, 0), (0.30, 0), (0.10, 0),
]
assert confusion_matrix(rows, 0.60) == (4, 2, 1, 3) # 与讲解的四格表一致
assert confusion_matrix(rows, 0.95) == (1, 0, 4, 5)
assert confusion_matrix(rows, 0.45) == (5, 2, 0, 3)
p, r, f = precision_recall_f1(rows, 0.60)
assert abs(p - 4 / 6) < 1e-9 and abs(r - 0.8) < 1e-9 and abs(f - 0.727272) < 1e-6
assert precision_recall_f1(rows, 1.5) == (0, 0, 0) # 三个分母分别为 0、5、5;后两项因 TP=0 得到 0
assert precision_recall_f1([(0.9, 0), (0.1, 0)], 0.95) == (0, 0, 0) # 无正样本且无预测正例,三个分母才都为 0confusion_matrix 一趟统计四格,precision_recall_f1 直接复用它。三个分母各写一个显式分支:一个正例都没判出来时(TP+FP=0)精确率取 0,没有正样本时(TP+FN=0)召回率取 0,F1 的分母 2TP+FP+FN 为 0 时同样取 0——不要依赖异常捕获。断言里的三组四格与上面两张表逐项对应。
补充学习(选学)类别不平衡的三类处理约 5 分钟重采样、类别加权、调整阈值与指标,以及为什么看精确率-召回率曲线
990 : 10 的数据里,模型只要把所有样本都判为正常就能得到 99% 的准确率。不做任何处理时,训练结果通常会偏向多数类。工程上有三类应对方式,可以单独用,也可以组合用。
| 做法 | 具体操作 | 代价与注意 |
|---|---|---|
| 重采样 | 把少数类复制若干份(上采样),或从多数类中抽掉一部分(下采样),使两类数量接近 | 上采样容易让模型记住被复制的少数样本;下采样会丢掉多数类里的信息 |
| 类别加权 | 在损失函数里给少数类更大的权重,判错一条少数类样本付出更高代价 | 权重要按业务上两种错误的代价来定,不是越大越好 |
| 调整阈值与评估指标 | 把判正的阈值下移;评估时用混淆矩阵、精确率、召回率和 F1 代替准确率 | 阈值和指标的选择取决于漏检与误报各自的代价,参见上一条补充学习 |
前两类改的是训练过程,第三类改的是判定与评估。本课的 AI005 练的正是第三类:在全部候选阈值里选出 F1 最高的那一个。
极端不平衡时还有一条经验:优先看精确率-召回率曲线(PR 曲线),而不只看受试者工作特征曲线(ROC 曲线)。负样本数量极大时,ROC 曲线里假正例率的分母很大,曲线看起来会很好;PR 曲线的精确率直接被假正例拉低,对少数类的表现更敏感。
补充学习(选学)什么时候召回率优先,什么时候精确率优先约 4 分钟比较召回率优先与精确率优先的应用场景
阈值往哪边调,取决于两种错误各自的代价,这是业务决策。召回率优先:漏掉一个的代价大——内容安全漏一条违规、体检漏一个病灶都代价很高,可降低阈值以减少漏检,再通过人工复核处理新增候选。精确率优先:误报一个的代价大——推荐系统推送不相关内容、营销信息发给不相关用户都属于此类,可提高阈值以减少假正例。F1 给两边同等权重,真实系统更常盯「召回率 ≥ 某条线时精确率最高」这类带约束的目标。
补充学习(选学)AUC:综合评估不同阈值下的排序能力约 5 分钟4 个样本、4 对比较,算出 AUC = 0.75
AUC 是 ROC(受试者工作特征)曲线下面积,可用于衡量模型对正负样本的排序能力:随机选择一个正样本和一个负样本,正样本分数更高的概率(分数相同的一对按一半计)。小例:正样本分数 0.9、0.6,负样本 0.7、0.2,正负两两配对 4 对——(0.9,0.7) 正胜、(0.9,0.2) 正胜、(0.6,0.7) 负胜、(0.6,0.2) 正胜,AUC = 3/4 = 0.75。完美排序 1.0,随机猜 0.5。它只看相对顺序、不依赖具体阈值,适合比较排序能力本身;部署分类系统时仍需根据业务目标选择具体阈值。
05 / AI005 手动计算
两个题面示例:逐阈值算 F1
AI005「分类阈值优化器」:第一行 n,接下来 n 行「分数 标签」;阈值只能取某个样本分数,分数不小于阈值判正;F1 = 2TP / (2TP + FP + FN)(分母为 0 按 0);输出 F1 最大的阈值(并列取更大的阈值)与该 F1,阈值四位小数、F1 六位小数。
| 阈值 | 判正的样本 | TP | FP | FN = 2 − TP | F1 |
|---|---|---|---|---|---|
| 0.9 | 0.9 | 1 | 0 | 1 | 2/(2+0+1) = 0.666667 |
| 0.8 | 0.9、0.8 | 1 | 1 | 1 | 2/(2+1+1) = 0.500000 |
| 0.7 | 0.9、0.8、0.7 | 2 | 1 | 0 | 4/(4+1+0) = 0.800000 |
| 0.4 | 全部 | 2 | 2 | 0 | 4/(4+2+0) = 0.666667 |
最优是 0.7、F1 0.800000,输出 0.7000 0.800000。阈值每下一档只多判正一条样本:TP 或 FP 加 1,其余不变——所以从高到低扫一遍、维护累计 TP 与 FP 就够,假负例用「正样本总数 − TP」得到。
| 阈值 | 判正的样本 | TP | FP | FN | F1 |
|---|---|---|---|---|---|
| 0.8 | 两条 0.8 | 1 | 1 | 1 | 0.500000 |
| 0.5 | 两条 0.8、0.5 | 2 | 1 | 0 | 0.800000 |
| 0.2 | 全部 | 2 | 2 | 0 | 0.666667 |
输出 0.5000 0.800000。阈值取 0.8 时两条 0.8 的样本都判正——不存在「只判正其中一条」的阈值。若把并列分数逐条结算,3 / 0.8 1 / 0.8 0 / 0.2 0 会在只计入 (0.8, 1) 时得到 F1 = 1.000000 并输出 0.8000 1.000000;正确答案是 0.8000 0.666667(第 09 节错误表)。
| 阈值 | TP | FP | FN | F1 |
|---|---|---|---|---|
| 0.9 | 1 | 0 | 1 | 0.666667 |
| 0.8 | 1 | 1 | 1 | 0.500000 |
| 0.7 | 1 | 2 | 1 | 0.400000 |
| 0.6 | 2 | 2 | 0 | 0.666667 |
0.9 与 0.6 并列 0.666667,按规则输出 0.9000 0.666667。从高到低扫描时 0.9 先遇到,所以更新条件写成「严格大于才更新」(f1 > best_f1 + 1e-12)就自动保留了更大的阈值;写成「大于等于」会输出 0.6000。
06 / 决策树遍历与表决
依据特征切分规则构建决策树
还是网关日志:根据词元(token)数和等待秒数判断是否触发告警。5 个节点的小树就是一个完整模型——这正是 AI004 的输入格式。
| 节点 | 类型 | 规则 | 去向 / 结论 |
|---|---|---|---|
| 1 | 内部 | 等待秒 ≤ 5 ? | 是 → 2 否 → 3 |
| 2 | 叶子 | — | 类别 0(正常) |
| 3 | 内部 | token ≤ 400 ? | 是 → 4 否 → 5 |
| 4 | 叶子 | — | 类别 0(排队抖动) |
| 5 | 叶子 | — | 类别 1(过载告警) |
对于 (token=600, 等待秒=8) 的样本,遍历路径如下:8 ≤ 5 不成立去节点 3;600 ≤ 400 不成立去节点 5;叶子输出类别 1。阈值相等按 ≤ 走左。
| 候选切分 | 左组 → 基尼 | 右组 → 基尼 | 加权基尼 |
|---|---|---|---|
| token ≤ 400 | [0, 0, 1] → 0.444 | [0, 1, 1] → 0.444 | 0.444 |
| token ≤ 250 | [0, 0] → 0 | [1, 0, 1, 1] → 0.375 | 0.250 |
6 条样本 token=[100,200,300,500,600,700]、标签=[0,0,1,0,1,1]。基尼 = 1 − Σp²,越小越纯:0.250 < 0.444,token ≤ 250 这个切分胜出。生成整棵树 = 在每个节点重复这个选择。
同一批数据上,树切得越深、训练集分数越高,新样本上的分数却可能反过来往下掉——这就是过拟合。下面用一组 8 条训练样本演示:特征还是词元(token)数,标签 0 表示正常、1 表示告警,其中 token=200 那条是标错的噪声点(周围的样本标签都是 0)。另有 2 条没有参与训练的验证样本 token=180、220,真实标签都是 0。
| 模型 | 切分规则 | 训练集(8 条) | 验证集(2 条) |
|---|---|---|---|
| 浅树(只切一刀) | token ≤ 275 → 0,否则 1 | 7 / 8 | 2 / 2 |
| 深树(为噪声点单开叶子) | token ≤ 175 → 0;175 < token ≤ 225 → 1;225 < token ≤ 275 → 0;否则 1 | 8 / 8 | 0 / 2 |
训练集 8 条:(100,0) (150,0) (200,1) (250,0) (300,1) (350,1) (400,1) (450,1)。浅树只在 token=200 这条噪声上判错,得 7/8;深树多切两刀,专门给噪声点开了一个 (175, 225] 的叶子,训练集拿到满分 8/8。代价出现在验证集:180 和 220 都掉进那个只装着一条噪声的叶子,被判成 1,2 条全错。训练分从 7/8 涨到 8/8,验证分却从 2/2 跌到 0/2——多出来的那一分是记住噪声换来的。
两种控制树复杂度的手段
限制最大深度:直接规定树最多切几层。上例把最大深度限成 1,就只能得到那棵浅树,专给噪声开叶子的两刀切不出来。要求叶子最少样本数:规定一个叶子至少要覆盖多少条训练样本。上例把这个下限定成 2,(175, 225] 那个只装 1 条样本的叶子就不成立,切分会被拒绝。两者都是在拒绝为个别噪声样本单开分支;深度和叶子样本数下限本身也是超参数,用验证集比较着定,不用训练集。
森林、袋外估计与切分标准
随机森林用有放回抽样得到的数据训练多棵划分不同的决策树,再投票抵消各自的随机波动——bagging(自助聚合)主要降低的是方差。OOB(袋外)估计按样本聚合:每条样本只交给「没见过它」的树投票,汇总算分。挑切分的标准是「加权不纯度下降多(纯度上升多)者胜」。需要理解自助聚合、袋外估计和切分标准三者各自的作用。
补充学习(选学)基尼指数与信息熵:两种切分指标约 4 分钟同一组切分的基尼与熵数字对照
熵 = −Σp·log₂p 是另一种衡量混乱程度的指标。同一组数据:阈值 400 的加权熵 0.918、阈值 250 的加权熵 0.541——和基尼给出同一个排序。多数数据上两者选出同一个切分,而基尼不用算对数、计算更省。信息增益 = 切分前的熵 − 切分后的加权熵,动作还是同一个:挑让下一层更纯的那个切分。
补充学习(选学)树模型不受缩放影响,还便于解释约 4 分钟呼应模块 6 · 第 3 课(特征归一化、KNN 与 K-Means)的缩放问题
模块 6 · 第 3 课 讲过:距离模型必须先缩放;树类模型则对缩放不敏感——每个节点只拿一个特征和阈值比大小,token 是 0 到 1000 还是 0 到 1,只是阈值跟着换算,样本分组保持不变。比大小还带来可解释性:任何预测都能顺着路径说出理由(等待秒 > 5 且 token > 400,判过载)——风控和医疗这类要向人解释的场景,树常因此入选。
补充学习(选学)随机森林与梯度提升的训练方式对比约 5 分钟并行投票降方差;串行拟合负梯度逐步纠偏
随机森林中的多棵树可以独立训练,再通过投票或平均汇总结果;这种集成方式主要用于降低方差。梯度提升中的后一个弱学习器依赖前一轮的结果,用来拟合当前损失对模型输出的负梯度;在平方误差下,可以直观理解为拟合残差。因此它的训练具有顺序依赖,不能像随机森林中的各棵树那样完全独立训练。
XGBoost、LightGBM 都属于梯度提升方法的工程实现。这里只说明两类方法的训练关系:随机森林并行、靠平均降方差;梯度提升串行、靠逐步纠错降偏差。两类方法的实际效果取决于数据与调参,没有哪一种在所有数据上都更准。
07 / AI004 与 AI016 手动计算
三棵树三条样本的遍历表,以及逐类计数
AI004「树模型推理引擎」:第一行 T d q;每棵树先给节点数 s,再给 s 行节点(编号从 1 起、1 号是根;叶子 L label;内部节点 N feature threshold left right,特征编号从 1 起);最后 q 行样本。每棵树按「特征 ≤ 阈值走左」到叶子投一票,多数表决,平票取数值更小的类别。
| 样本 | 树 1:特征 1 ≤ 5? | 树 2:特征 2 ≤ 0? | 树 3:特征 1 ≤ 2? | 票数 | 输出 |
|---|---|---|---|---|---|
| (1, −1) | 1 ≤ 5 → 左 → 0 | −1 ≤ 0 → 左 → 1 | 1 ≤ 2 → 左 → 0 | 0 票 2、1 票 1 | 0 |
| (6, 1) | 6 ≤ 5 否 → 右 → 1 | 1 ≤ 0 否 → 右 → 2 | 6 ≤ 2 否 → 右 → 2 | 2 票 2、1 票 1 | 2 |
| (3, 5) | 3 ≤ 5 → 左 → 0 | 5 ≤ 0 否 → 右 → 2 | 3 ≤ 2 否 → 右 → 2 | 2 票 2、0 票 1 | 2 |
输出 0 / 2 / 2。题库用例 1 1 2 / 3 / N 1 5 2 3 / L 0 / L 1 / 5 / 6:样本 5 与阈值相等走左得 0,样本 6 走右得 1;用例 2 1 1 / 1 / L 2 / 1 / L 1 / 0:两棵单叶树各投 2 与 1,平票取 1。特征编号从 1 起而列表下标从 0 起,读入时减 1。
AI016「宏平均与微平均」(进阶):第一行 n k,第二行 n 个真实标签,第三行 n 个预测标签。逐条样本:真实 = 预测时该类真正例加 1;否则预测类的假正例加 1、真实类的假负例加 1。每类输出精确率、召回率、F1(四位小数),最后一行宏平均与微平均 F1(六位小数)。
| 类别 | TP | FP(预测成它但错) | FN(真是它但没预测成) | 精确率 | 召回率 | F1 |
|---|---|---|---|---|---|---|
| 0 | 2 | 1(第 6 条 2→0) | 1(第 2 条 0→1) | 0.6667 | 0.6667 | 0.6667 |
| 1 | 2 | 1(第 2 条) | 1(第 8 条 1→2) | 0.6667 | 0.6667 | 0.6667 |
| 2 | 1 | 1(第 8 条) | 1(第 6 条) | 0.5000 | 0.5000 | 0.5000 |
宏平均 = (2/3 + 2/3 + 1/2)/3 = 0.611111(按未舍入的值算;用四位显示值相加会得 0.611133);微平均 = 2×5/(2×5 + 3 + 3) = 10/16 = 0.625000。每条判错的样本恰好贡献一个 FP 和一个 FN,所以 ΣFP = ΣFN 永远成立,微平均 F1 等于准确率 5/8。题库用例 4 4 / 0 1 0 1 / 0 1 1 0:类别 2、3 从没出现,仍各输出一行 0.0000 并计入 k=4 的宏平均,得 0.250000;只对出现过的类平均会得到 0.500000。
08 / 从函数到程序
参考实现与三份完整程序,每一步落在哪几行
第 04 节的 confusion_matrix 与 precision_recall_f1 负责四格与三个比值;AI005 在排序后的扫描里只维护累计 TP、FP;AI004 是读树、走树、计票三段。
| 步骤 | AI005 | AI004 | AI016 |
|---|---|---|---|
| 读入 | n,然后 data[1 + 2i]、data[2 + 2i] | T d q;每棵树 s 行节点;q 行样本 | n k;两行各 n 个标签 |
| 准备 | 按分数降序;total_pos | 节点存进 1 起的列表,特征减 1 | tp / fp / fn 三个长度 k 的数组 |
| 核心 | 外层每个不同分数、内层同分一起计入;结算 F1 | while 从根走到叶;相等走左 | 逐条样本按真实 = 预测分支计数 |
| 并列 | f1 > best_f1 + 1e-12 才更新 | (-votes[lab], lab) 取最小 | 无 |
| 输出 | f"{best_t:.4f} {best_f1:.6f}" | q 行整数 | k 行三个四位小数 + 一行两个六位小数 |
展开完整参考程序 1:AI005 分类阈值优化器
完整程序:AI005(标准输入 → 标准输出)
Pythonimport sys
def main():
data = sys.stdin.read().split()
if not data:
return
n = int(data[0])
pairs = []
for i in range(n):
pairs.append((float(data[1 + 2 * i]), int(data[2 + 2 * i])))
pairs.sort(key=lambda p: -p[0]) # 分数从高到低
total_pos = sum(label for _, label in pairs)
tp = fp = 0
best_f1, best_t = -1.0, pairs[0][0]
i = 0
while i < n:
t = pairs[i][0]
while i < n and pairs[i][0] == t: # 并列分数一起跨过阈值
if pairs[i][1] == 1:
tp += 1
else:
fp += 1
i += 1
fn = total_pos - tp
denom = 2 * tp + fp + fn
f1 = 0.0 if denom == 0 else 2 * tp / denom
if f1 > best_f1 + 1e-12: # 并列不更新,保留先遇到的更大阈值
best_f1, best_t = f1, t
print(f"{best_t:.4f} {best_f1:.6f}")
main()排序 O(n log n) 加一次线性扫描;每个候选阈值只做常数次加法。用两组题面示例和第 05 节的并列用例核对。
展开完整参考程序 2:AI004 树模型推理引擎
完整程序:AI004(标准输入 → 标准输出)
Pythonimport sys
def main():
data = sys.stdin.read().split()
if not data:
return
t, d, q = int(data[0]), int(data[1]), int(data[2])
pos = 3
trees = []
for _ in range(t):
s = int(data[pos]); pos += 1
nodes = [None] * (s + 1) # 节点编号从 1 开始,1 号是根
for i in range(1, s + 1):
kind = data[pos]; pos += 1
if kind == "L":
nodes[i] = ("L", int(data[pos])); pos += 1
else:
feature = int(data[pos]) - 1 # 题目特征编号从 1 起,列表下标从 0 起
threshold = float(data[pos + 1])
left, right = int(data[pos + 2]), int(data[pos + 3])
pos += 4
nodes[i] = ("N", feature, threshold, left, right)
trees.append(nodes)
out = []
for _ in range(q):
x = [float(v) for v in data[pos:pos + d]]; pos += d
votes = {}
for nodes in trees:
cur = 1
while nodes[cur][0] != "L": # 用循环从根走到叶,不用递归
_, feature, threshold, left, right = nodes[cur]
cur = left if x[feature] <= threshold else right # 相等走左
label = nodes[cur][1]
votes[label] = votes.get(label, 0) + 1
out.append(str(min(votes, key=lambda lab: (-votes[lab], lab)))) # 平票取小
print("\n".join(out))
main()题目允许所有树的节点总数到 5000,树可能是几千层的链——while 循环对任何深度都安全。用题面示例 2 → 0 / 2 / 2 和第 07 节的两组用例核对。
展开完整参考程序 3:AI016 宏平均与微平均(进阶练习)
完整程序:AI016(标准输入 → 标准输出)
Pythonimport sys
def fmt(num, den, digits):
# 精确四舍五入:分子分母都是整数,先放大 10^digits 倍再按余数判断进位——不经过浮点,0.03125 这类恰好在中点的值也能正确进位
if den == 0: # 分母为 0 的指标按 0 处理
return "0." + "0" * digits
q, r = divmod(num * 10 ** digits, den)
if 2 * r >= den:
q += 1
s = str(q).rjust(digits + 1, "0")
return s[:-digits] + "." + s[-digits:]
def main():
data = sys.stdin.read().split()
if not data:
return
n, k = int(data[0]), int(data[1])
truth = data[2:2 + n]
pred = data[2 + n:2 + 2 * n]
tp = [0] * k
fp = [0] * k
fn = [0] * k
for a, b in zip(truth, pred):
a, b = int(a), int(b)
if a == b:
tp[a] += 1
else:
fp[b] += 1 # 预测成 b,但真实不是 b
fn[a] += 1 # 真实是 a,却没预测成 a
out = []
macro_num, macro_den = 0, 1 # 宏平均 = Σ F1 / k,用整数分子分母累加,最后一次舍入
for c in range(k): # 没出现过的类别也要输出、也计入宏平均
denom = 2 * tp[c] + fp[c] + fn[c]
out.append(f"{fmt(tp[c], tp[c] + fp[c], 4)} {fmt(tp[c], tp[c] + fn[c], 4)} {fmt(2 * tp[c], denom, 4)}")
if denom: # F1 = 2TP / (2TP + FP + FN);分母为 0 时按 0,不参与相加
macro_num, macro_den = macro_num * denom + 2 * tp[c] * macro_den, macro_den * denom
tps, fps, fns = sum(tp), sum(fp), sum(fn)
out.append(f"{fmt(macro_num, macro_den * k, 6)} {fmt(2 * tps, 2 * tps + fps + fns, 6)}")
print("\n".join(out))
main()三个分母各写一个显式分支;没出现过的类别也输出一行、也计入宏平均。四舍五入用整数分子分母计算,不经过浮点——例如某类精确率恰好是 1/32 = 0.03125,题目要求四舍五入得 0.0313,而 f"{p:.4f}" 会按二进制存储值打出 0.0312。
09 / 边界、反例与复杂度
错误做法在题库用例上各输出什么
下面每一行都是一个具体的错误程序,给出输入、错误输出与正确输出;把参考程序改成对应写法就能复现。
| 错误做法 | 输入 | 错误输出 | 正确输出 | 判题结果 |
|---|---|---|---|---|
| AI005 同分逐条结算 | 3 / 0.8 1 / 0.8 0 / 0.2 0 | 0.8000 1.000000 | 0.8000 0.666667 | 答案错误(WA) |
| AI005 并列写成「大于等于就更新」 | 4 / 0.9 1 / 0.8 0 / 0.7 0 / 0.6 1 | 0.6000 0.666667 | 0.9000 0.666667 | 答案错误(WA) |
| AI005 按分数升序扫描 | 5 / 0.95 0 / 0.9 0 / 0.7 1 / 0.6 1 / 0.1 1 | 0.7000 1.000000 | 0.1000 0.750000 | 答案错误(WA) |
| AI005 对每个阈值重数四格 | n = 50000 的用例 | 约 25 亿次比较 | -10.0000 0.592501 | 超时(TLE) |
AI004 相等走右(<) | 1 1 2 / 3 / N 1 5 2 3 / L 0 / L 1 / 5 / 6 | 1 / 1 | 0 / 1 | 答案错误(WA) |
| AI004 平票取数值大的类别 | 2 1 1 / 1 / L 2 / 1 / L 1 / 0 | 2 | 1 | 答案错误(WA) |
| AI004 特征编号不减 1 | 题面示例 2 | 下标越界的运行错误 | 0 / 2 / 2 | 运行错误(RE) |
| AI016 宏平均只对出现过的类平均 | 4 4 / 0 1 0 1 / 0 1 1 0 | 末行 0.500000 0.500000 | 末行 0.250000 0.500000 | 答案错误(WA) |
| AI016 微平均写成各类 F1 的平均 | 题面示例 1 | 末行 0.611111 0.611111 | 末行 0.611111 0.625000 | 答案错误(WA) |
| AI016 假正例、假负例记反 | 5 3 / 0 1 2 2 1 / 0 0 0 0 1 | 首行 1.0000 0.2500 0.4000 | 首行 0.2500 1.0000 0.4000 | 答案错误(WA) |
AI005 的分母 2TP + FP + FN 在扫描里不会为 0——每个候选至少计入一条样本;显式分支仍按题面保留。「同分逐条结算」在题面示例 2 上碰巧输出正确,所以只靠题面示例验证不出它,表里换用构造的输入;超时一行按 n² 估算。
| 做法 | 时间 | 本课规模下 |
|---|---|---|
| AI005 排序 + 一次扫描 | O(n log n) | n=50000 约 80 万次比较,可通过 |
| AI005 每个阈值重数四格 | O(n²) | 25 亿次,必然超时 |
| AI004 每条样本走每棵树 | O(q · Σ各树深度) | 所有树的节点总数 ≤ 5000,每条样本在全部树上走过的节点数之和也不超过 5000:2000 × 5000 = 10⁷ 次比较以内 |
| AI016 逐条计数 | O(n + k) | 80000 条,可通过 |
10 / 渐进练习与参考答案
跟做 → 改一个条件 → 独立实现 → 迁移
每题先在纸上或文件里做完,再展开答案。
练习 1(跟做):在题面示例 1 后面加一条样本 0.3 1(正样本变成 3 条),按第 05 节表格的格式把五个候选阈值的 F1 重算一遍,给出输出。
展开练习 1 答案
0.9:TP 1、FP 0、FN 2 → 2/4 = 0.5;0.8:1、1、2 → 2/5 = 0.4;0.7:2、1、1 → 4/6 = 0.666667;0.4:2、2、1 → 4/7 = 0.571429;0.3:3、2、0 → 6/8 = 0.75。输出 0.3000 0.750000。多了一条低分正样本,最优阈值从 0.7 降到 0.3——召回率的收益盖过了两条假正例。
练习 2(改一个条件):四条样本分数全是 0.5,标签 1、0、1、0,输出是什么?为什么只有一个候选阈值?
展开练习 2 答案
只有一个不同分数,四条一起跨过阈值:TP 2、FP 2、FN 0 → 4/6 = 0.666667,输出 0.5000 0.666667。逐条结算的程序把同一个阈值算了四次:F1 依次 0.666667、0.5、0.8、0.666667,会错报 0.5000 0.800000;标签换成 1、1、0、0 时第二步就得到 1.000000,错得更明显。
练习 3(改一个条件):题库用例 2 1 1 / 1 / L 2 / 1 / L 1 / 0 改成四棵单叶树,类别依次 3、1、3、1,输出是什么?改成三棵 3、1、3 呢?
展开练习 3 答案
四棵:3 与 1 各 2 票,平票取数值更小的 1;三棵:3 得 2 票,输出 3。多数表决先比票数,票数相同才看类别大小。
练习 4(独立实现):完成「代码自测」的 confusion_matrix 与 precision_recall_f1,再加一条断言:阈值 0.95 时四格是 (1, 0, 4, 5)。
展开练习 4 答案
参考实现在第 04 节「混淆矩阵与精确率、召回率、F1 的参考实现」,其中已含 confusion_matrix(rows, 0.95) == (1, 0, 4, 5) 这条断言:只有 0.95 一条判正,它是正样本,所以 TP 1、FP 0;其余 4 条正样本都成了假负例。
练习 5(迁移):题库用例 4 3 / 0 0 1 1 / 0 2 1 2,逐类填出 TP、FP、FN,算出三行指标与末行两个平均。
展开练习 5 答案
第 2 条 0→2、第 4 条 1→2 判错。类别 0:TP 1、FP 0、FN 1 → 1.0000 0.5000 0.6667;类别 1:同样 1.0000 0.5000 0.6667;类别 2:TP 0、FP 2、FN 0 → 三个分母里 TP+FN = 0,精确率 0/2 = 0,全为 0.0000。宏平均 (0.6667+0.6667+0)/3 = 0.444444;微平均 2×2/(4+2+2) = 0.500000。
11 / 读题要求与复习自评
三道题的要求对照,以及完成本课之后怎么复习
提交前把下表过一遍;题目页的题面与样例是最终依据。
| 项目 | AI005 阈值优化器 | AI004 树模型推理 | AI016 宏平均与微平均 |
|---|---|---|---|
| 输入顺序 | n;n 行「分数 标签」 | T d q;每棵树 s + s 行;q 行样本 | n k;真实一行;预测一行 |
| 判定规则 | 分数 ≥ 阈值判正;阈值只取样本分数 | 特征 ≤ 阈值走左;叶子给类别 | 真实 = 预测记 TP,否则 FP 给预测类、FN 给真实类 |
| 并列规则 | 同分一起跨;F1 并列取更大阈值 | 平票取数值更小的类别 | 无 |
| 分母为 0 | F1 按 0 | 无 | 三个指标各按 0;没出现的类也计入宏平均 |
| 输出 | 一行:阈值四位 + F1 六位 | q 行整数 | k 行三个四位 + 一行两个六位 |
需要对照解法时,展开本页第 08 节的三份完整参考程序。复习与自评:本课的「学习完成检查」六条是自评,不改变题目的通过(AC)状态,本课算完成的条件以页面下方「学习完成检查」处的说明为准。复习时用三个问题自测:① 不看正文,说出四格各是什么、三个比值的分母各是什么;② 不看表格,把题面示例 1 的四个候选阈值 F1 重算一遍;③ 说出同分一起跨、并列取大阈值、相等走左、平票取小四条规则各对应代码的哪一行。答不出哪一条,就回到对应的节重读,再做第 10 节对应的练习。
12 / 练习
按顺序完成本课的任务
必做题已通过 0/2 道;进阶练习已通过 0/1 道
实现混淆矩阵与精确率、召回率、F1
代码自测自主练习练习重点:混淆矩阵(confusion_matrix)与精确率、召回率、F1(precision_recall_f1)两个函数,正确处理三个分母为 0 的情况;预计用时:15 分钟
完成标准:四组断言全部通过,期望值全部来自讲解的两张表
需要时查看提示
TP 表示实际标签为 1 且预测标签为 1,FP 表示实际标签为 0 但预测标签为 1。除零写显式分支返回 0,不依赖异常捕获语句(try/except)。参考实现在第 04 节。
自测代码(复制到你的代码文件中运行,检查输出是否一致)
rows = [
(0.95, 1), (0.90, 1), (0.85, 0), (0.70, 1), (0.65, 0),
(0.60, 1), (0.45, 1), (0.40, 0), (0.30, 0), (0.10, 0),
]
# 讲解中的 10 条预测表,阈值 0.60
assert confusion_matrix(rows, 0.60) == (4, 2, 1, 3)
# 阈值 0.45:召回率达到 1.0
p, r, f1 = precision_recall_f1(rows, 0.45)
assert abs(p - 5 / 7) < 1e-9 and abs(r - 1.0) < 1e-9 and abs(f1 - 5 / 6) < 1e-9
# 没有任何样本被预测为正:三个分母都是 0,按 0 处理
assert precision_recall_f1([(0.9, 0), (0.1, 0)], 0.95) == (0, 0, 0)AI005 · 分类阈值优化器
必做任务 1练习重点:分数从高到低扫,并列分数一起跨过阈值;F1 相同取更大的阈值;预计用时:30 分钟
完成标准:能解释为什么排序后一次线性扫描就够
需要时查看提示
排序后维护累计 TP / FP,同一个分数用内层循环一次处理完再结算;FN = 正样本总数 − TP。输出阈值 4 位小数、F1 保留 6 位。第 05 节有两个示例的逐阈值表。
AI004 · 树模型推理引擎
必做任务 2练习重点:循环遍历决策树 + 多数表决,平票选数值更小的类别;预计用时:35 分钟
完成标准:能说出阈值相等往哪走、平票怎么处理、为什么不用递归
需要时查看提示
节点编号从 1 开始、1 号是根;特征值不超过阈值(x[feature] <= threshold)时走左。树可能变成上千层的链,递归会超过 Python 默认 1000 层上限——用循环语句(while)从根走到叶。第 07 节有题面示例 2 的遍历表。
AI016 · 宏平均与微平均
进阶练习 1进阶练习练习重点:每类 P/R/F1 + 宏平均 / 微平均,分母为 0 的类按 0 处理;预计用时:20 分钟
完成标准:能说出类别失衡时宏平均和微平均谁对小类更敏感
需要时查看提示
宏平均要把从没出现过的类也计入 k 类平均;逐类 4 位小数、末行两个 6 位小数。微平均把所有类的 TP/FP/FN 加总后算一个 F1,大类主导。第 07 节有题面示例的逐类表。
提交结果
提交结果说明与处理方法
- WA
答案错误
AI005 先核对样例 2:相同分数必须一起跨过阈值、F1 并列取更大阈值;AI004 先查平票选小和 ≤ 走左。第 09 节的表给出了每种错误在题库用例上的输出
- PE
格式错误
AI005 输出一行两个数:阈值 4 位、F1 6 位、中间一个空格;AI004 恰好 q 行整数类别
- RE
运行错误
AI004 的深链树用递归会超过 1000 层上限;特征编号不减 1 会下标越界;AI005 每行「实数 + 整数」成对读,成对读取发生偏移就会越界
- TLE
超时
对每个阈值重数四格是 O(n²)、n=50000 就是 25 亿次;排序后一次线性扫描
- AC
通过
再测全正、全负、全部同分三种输入;想想召回率优先的业务该把阈值往哪边调
13 / 学习完成检查
本课学习完成检查
完成本课需要:必做题全部通过,并勾选本课的全部学习完成检查;进阶练习、基础加练与复习题单独统计,不影响完成状态。登录后,勾选记录会保存到账号,并更新课程总览的完成状态。