F1为何变高:先固定类别集合
同一批预测,macro-F1可以是0.474,也可以是0.316:只要把一个没有样本、也没有预测的类别纳入统计,并把它的未定义分数记为0。模型没有变化,评测问题却变了。比较F1之前,先固定类别集合、平均方式和缺席类政策,再把计数聚合到同一个范围。
Read this article in English。本文讨论单标签多分类,每个样本恰有一个真实类别和一个预测类别;数值来自手工构造的例子与Python标准库核验,没有训练模型或调用scikit-learn。
先用十条预测看清差别
设测试集里有9条A、1条B,分类器把10条全部预测成A。这里的A、B只是匿名教学标签,不能代表实际业务成本。若只看准确率,9/10得到0.9;但B一条也没识别出来。我们真正要决定的是:每个样本有同等权重,还是每个类别有同等权重?这是评测目标,不是哪个平均选项更高级。
把每个类别轮流视作正类。A的TP为9、FP为1、FN为0,因此F1为18/19;B的TP、FP为0,FN为1,因此F1为0。本文使用F1=2TP/(2TP+FP+FN),直接从计数计算,省去先算precision、recall时额外的零分母分支。
| 类别 | TP / FP / FN | 真实样本数 | F1 |
|---|---|---|---|
| A | 9 / 1 / 0 | 9 | 18/19 ≈ 0.947 |
| B | 0 / 0 / 1 | 1 | 0 |
macro先逐类计算再等权平均:(18/19+0)/2=9/19≈0.474。weighted按真实样本数加权,得到(9×18/19+1×0)/10=81/95≈0.853。micro则先汇总各类TP、FP、FN再算,得到18/(18+1+1)=0.9。这些数都算对了,回答的侧重点不同。scikit-learn 1.7.2的F1接口说明给出了这些平均方式与类别选择规则。
不存在的类别,为什么也会改变macro
现在预先规定任务有A、B、C三类,而这批数据中既没有真实C,也没有预测C。C的TP、FP、FN全为0,其F1没有定义。若用0代替,macro变成(18/19+0+0)/3=6/19≈0.316。若从平均中排除C,结果回到0.474;若记为1,则是37/57≈0.649。仅改变政策,报表就会出现相当大的变化。
这不意味着应当总是把缺席类记成0。固定任务类别的报表若采用这个政策,需要同时展示support,提醒读者C没有获得评测证据;0在这里是报表约定,不能解释成“已证明模型不会识别C”。固定类别能稳定报表结构,却不能补出缺失样本的证据。若排除未定义类,则应同时报告被排除的类别和有效类别数,否则不同周、不同切片的macro可能悄悄采用了不同分母。
另一个容易混淆的情况是:没有真实C,但模型误报了一条C。这时C有FP,分母不再为0,F1是确定的0,不应因为support为0就被当作未定义值排除。上面两个情况必须分开:缺少正样本,不一定等于缺少所有相关计数。
接口默认的类别集合与显式labels配置也要区别开来。不能在一个实验里由当前数据推断类别,另一个实验里固定三类,然后把两个macro当成同一指标比较。本文示例选择显式固定类别;遇到不在配置内的真实或预测标签直接报错,不静默丢弃。这是教学实现的校验策略,不是在复刻库的全部参数行为。
micro何时等于准确率
在本文限定的单标签、全类别、无样本权重场景中,设共有N条预测,其中K条正确。正确预测各贡献一个TP;每个错误同时给预测类贡献一个FP、给真实类贡献一个FN。所以汇总TP=K,FP=FN=N−K,micro-F1=2K/[2K+2(N−K)]=K/N,恰好等于准确率。
这个推导还告诉我们边界在哪里:例如只报告A时,B→C的错误可能完全不进入所选类别的计数。若只选部分类别,某些错误的两端可能不再同时计入;多标签中一条样本可以贡献多个标签判断,也不能直接套用上述计数关系。不要把“micro总等于准确率”写进通用工具或评测规范。先确认样本、标签和聚合范围,再决定哪一种accuracy定义有可比性。
weighted虽然在本例接近micro,但它不是micro的别名。它先做每个类别的非线性比值,再按support加权。类别间错误流向改变时,单看样本占比无法把weighted直接换算成准确率。需要逐类结果来解释整体分数,不能只保留一个小数。
跨批次汇总,保留计数再算分数
把另一组数据分成两批:第一批两条A都预测正确,第二批一条B误判为A。两批micro分别为1和0,直接平均得到0.5。合并三条样本后有两条正确,micro应为2/3。此处按批大小加权micro可恢复结果,因为在刚才的限定场景中它等于准确率;这个特殊性质不应迁移成“所有F1都能按批大小加权”。
稳妥的实现是让各批次或各工作进程返回固定类别顺序的TP、FP、FN和support,先逐项相加,再统一算逐类F1和所需平均。不要在每个批次先应用缺席类别政策,再平均批次macro:某类可能只在部分批次出现,而每批分母和权重已经变了。
用19项断言核验统计口径
下面代码使用Python标准库,实际在Python 3.15.0运行,19项断言通过。它验证上述分数、未定义类政策、仅有预测、没有真实样本的类别、跨批次聚合、全对预测,以及输入为空、长度不齐、未知类别和类别重复的拒绝路径。absent=None表示排除未定义类;它不是给scikit-learn传None的调用示例。
from math import isclose
import sys
def report(truth, pred, labels, absent=0):
if not truth or len(truth) != len(pred):
raise ValueError('nonempty aligned inputs required')
if not labels or len(set(labels)) != len(labels):
raise ValueError('unique fixed labels required')
if absent not in (0, 1, None):
raise ValueError('absent must be 0, 1 or None')
allowed = set(labels)
if not set(truth + pred) <= allowed:
raise ValueError('unknown label')
counts = []
for c in labels:
tp = sum(t == c and p == c for t,p in zip(truth,pred))
fp = sum(t != c and p == c for t,p in zip(truth,pred))
fn = sum(t == c and p != c for t,p in zip(truth,pred))
counts.append((tp,fp,fn,truth.count(c)))
scores = [2*t/(2*t+f+n) if 2*t+f+n else absent
for t,f,n,s in counts]
defined = [x for x in scores if x is not None]
macro = sum(defined)/len(defined) if defined else None
weighted = sum((x or 0)*s for x,(_,_,_,s) in zip(scores,counts))/len(truth)
tp = sum(x[0] for x in counts)
fp = sum(x[1] for x in counts)
fn = sum(x[2] for x in counts)
micro = 2*tp/(2*tp+fp+fn)
return counts,scores,macro,weighted,micro
checks=0
def check(ok):
global checks
assert ok
checks+=1
truth=['A']*9+['B']; pred=['A']*10
r=report(truth,pred,['A','B'])
check(r[0]==[(9,1,0,9),(0,0,1,1)])
check(isclose(r[1][0],18/19) and r[1][1]==0)
check(isclose(r[2],9/19))
check(isclose(r[3],81/95))
check(isclose(r[4],.9))
r3=report(truth,pred,['A','B','C'])
check(isclose(r3[2],6/19))
check(isclose(r3[3],r[3]) and isclose(r3[4],r[4]))
check(isclose(report(truth,pred,['A','B','C'],None)[2],r[2]))
check(isclose(report(truth,pred,['A','B','C'],1)[2],37/57))
# A zero-support class predicted once still has an FP; it is defined.
a=report(['A'],['C'],['A','B','C'],None)
check(a[1]==[0,None,0] and a[2]==0)
s1=report(['A','A'],['A','A'],['A','B'])
s2=report(['B'],['A'],['A','B'])
pool=report(['A','A','B'],['A','A','A'],['A','B'])
check((s1[4]+s2[4])/2==.5)
check(isclose(pool[4],2/3))
check(isclose(pool[4],sum(t==p for t,p in zip(['A','A','B'],['A','A','A']))/3))
check(isclose(report(truth,truth,['A','B'])[2],1))
for args in [([],[],['A']),(['A'],[],['A']),(['A'],['X'],['A']),(['A'],['A'],['A','A']),(['A'],['A'],[])]:
try: report(*args)
except ValueError: check(True)
else: raise AssertionError('invalid input accepted')
print(checks,'checks passed; Python',sys.version.split()[0])
print('AB macro, weighted, micro:',r[2:])
print('ABC macro:',r3[2],'; shard micro mean .5 vs pooled',pool[4])
这段实现为便于审计而逐类扫描,时间复杂度约为O(NC),不用于宣称吞吐性能。数据量大时可在一次遍历里累计计数,保持相同定义。代码不处理多标签、样本权重或部分标签评分,也没有验证某个框架版本的执行结果。库的参数政策与本文的算术核验是两层证据。
比较模型前,先比较统计口径
一个可复查结果至少要绑定:数据快照与过滤条件、标签映射和固定顺序、单标签或多标签设定、缺席类政策、平均方式、逐类support与计数,以及聚合范围。若模型输出概率,还要固定产生类别预测的规则;比较时改变阈值或标签映射,不能仅归因于模型变好。
如果关心稀有类别,把逐类召回、精确率和support放在macro旁边,查看错分究竟流向哪里;如果按线上样本量估算表现,也要先确认测试集分布与实际使用相符。这里没有足够证据推荐某个业务阈值,更没有从这十条合成数据得出任何模型质量结论。
本例适合做评测管线的回归样本:固定输入和期望计数,防止重构时把macro换成weighted、自动删除缺席类或先平均批分数。它检验的是统计口径保持一致,不能替代真实数据上的模型验证。RAG评测中,类似的口径问题可继续看召回率的分母与NDCG理想排序,但不要把分类F1直接当成检索排名质量指标。
资料采用F1接口说明及1.7版本记录:1.7.2发布于2025年9月,早于原计划文章日期。本文于2026年10月25日补发,文章日期保留原计划的2026年10月23日23:30。
支持
如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。
二维码可点击放大。更多支持方式见支持页面。


