ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

众包标注如何确定最少答案数?从多数投票到动态停采的实践指南

众包标注如何确定最少答案数?从多数投票到动态停采的实践指南

简介:minimumAnswers 是一个面向众包平台设计者、数据标注管理者及算法研究者的 Java 项目,旨在解决众包系统中“需要多少答案才能保证结果可靠”这一核心问题,通过统计学与机器学习方法平衡成本与准确率,并提升任务完成质量。压缩包共十三个文件,以十一个 Java 语言源代码为主,覆盖核心估算算法、答案生成器、可控随机数工具、多种模拟测试模块,包括固定回答数、波动回答数、公平性等场景;另有说明文档与开源许可证声明,整体仅三十二 KB,结构紧凑,适合作为算法研读与二次开发的基础。已有七十四人学习浏览。项目将任务分类、参与者历史表现评估与样本量计算相互结合,可辅助设计动态决策系统,自动调整每个任务所需回答数,对众包质量控制、成本优化及相关教学实验均有直接参考价值。

1. 众包系统的最小答案数问题:为什么 "多答几份" 不是可靠策略

接到标注需求时,第一反应通常是“每张图标 3 次,取多数”。我见过很多众包系统在 1~3 个答案之间反复试错,钱花了不少,准确率还是上不去。minimumAnswers 要解决的就是这样一个问题:在给定质量目标(如最终准确率 ≥ 95%)的前提下,估算每个任务至少需要多少个答案,而不是拍脑袋定成 3 或 5。它与众包系统的成本、延迟、数据质量直接挂钩,适合正在搭建标注管道、维护众包平台或做质量控制的人。下面我把这条线上的建模、实现和踩坑一次讲清。

2. 从多数投票到 minimumAnswers:先弄清答案数量在质量链里卡在哪

2.1 多数投票的质量边界:错误率、置信区间与退化条件

我最早也以为答案数越多越保险,后来被一个数学反例打脸:当工人准确率低于 50% 时,答案越多,多数投票错得越稳定。这不是玄学,是二项分布的性质。

假设每个工人独立给出二元答案,且答对概率固定为 p。n 个工人做多数投票,最终正确的概率是:

P_acc(n, p) = ∑_{k=⌈n/2⌉}^{n} C(n,k) p^k (1-p)^(n-k)

当 p > 0.5 时,P_acc 随 n 增大趋向 1;当 p = 0.5 时,无论 n 多大都是 0.5;当 p < 0.5 时,P_acc 随 n 增大反而趋向 0。这个边界说明一件事:先校准人,再谈最少答案数。否则 minimumAnswers 算出来也只是在给一个错误的前提找最优解。

多数投票还有一个置信区间问题。给定 n 和 p,最终聚合结果是对真实标签的随机函数。即使 n=7、p=0.7,P_acc 只有约 87%,远没到“七个答案一定稳”的直觉。要可靠地定答案数,必须反查这个公式:找最小的 n,使 P_acc ≥ 目标阈值。这也是 minimumAnswers 名称的由来——它不是“推荐 3 个答案”,而是“刚好达到质量目标的那一个数”。

2.2 最少答案数量的数学定义:目标函数与两类约束

从工程角度,最少答案数问题可以写成:

N_min = min { n ∈ N⁺ : P_acc(n; p, d) ≥ τ }

其中 τ 是质量目标(如 0.95),p 是工人平均能力,d 是任务难度因子。注意这里的目标变量是“多数投票后的群体正确率”,不是单个工人的平均正确率。两者是非线性关系,用单个工人准确率直接替代群体准确率,算出来的答案数必然偏少,上线后必翻车。

实际项目里还会有第二类约束:预算。如果每条答案成本是 c,总预算是 B,那么 n ≤ B/c 必须同时满足。这时问题变成求满足质量阈值和预算上限的交集。常见做法是先生成“答案数-准确率”曲线,再在上面标出预算线;如果两条线没有交集,就要么降阈值,要么换任务拆分方式,要么先做一轮工人培训。我在需求评审时通常会把这两类约束同时讲清楚,避免业务方只盯着“95%准确率”而忽略成本上限。

2.3 为什么不能直接套固定 "3 个答案" 的常见误用

固定 3 个答案是最常见的团队默认值。它的好处是约定简单,但坏处是对质量波动没有免疫力。看下面这组手算数据,同样用多数投票:

np=0.6p=0.7p=0.8
10.6000.7000.800
30.6480.7840.896
50.6830.8370.942
70.7100.8740.967

如果任务简单,工人准确率 0.8,收 5 个答案已经有 94.2%,再加到 7 个只是把最后 2.5 个百分点补上;如果任务很难,工人准确率只有 0.6,7 个答案也才 71%,根本到不了业务可用的 90%。固定“3 个答案”相当于赌工人能力和任务难度永远不变,而众包平台上这两者每天都在波动。

更隐蔽的问题是:固定数会把系统锁死在静态假设里。早高峰和深夜接单的可能是不同群体,新工人涌入和成熟工人流失都会改变 p。所以我在做 minimumAnswers 时,强调的是“动态可更新”——每次有新数据就重算一次答案数。这也是后面第 6 章动态停采的基础。

3. 用历史数据先校准人:Worker 准确率与任务难度的贝叶斯估计

3.1 从混淆矩阵估计 Worker 准确率:先解决“跟谁比”的问题

众包里最容易被误用的词是“准确率”。如果答案没有金标,工人的准确率是无从算起的。常见做法是拿一部分已标注数据做金标(gold set),再把工人在这部分上的表现外推到新任务。假设二分类任务,工人甲在 100 道金标题上答对 80 道,那么他的点估计是 0.8。但点估计在样本少时离散度很高,20 道题里错 4 道和错 5 道的差距非常大,直接拿 0.75 或 0.8 去算 minimumAnswers,结果可能差 1~2 个答案。

所以我一般用 Beta 先验。设工人准确率 p ~ Beta(α, β),观测到 a 题对、b 题错后,后验是 Beta(α+a, β+b)。用后验均值作校准:

p_hat = (α + a) / (α + β + a + b)

α、β 通常取 1,也就是均匀先验。对批改类项目我会取 α=2, β=2,让先验略往 0.5 收缩,避免小样本上出现“1/1=1.0”这种假满分。多分类则按类别拆成混淆矩阵,为每个类别单独做 Beta 估计,因为“把猫标成狗”和“把狗标成猫”的成本完全不同,对最终 N 的影响也不同。

只有当没有金标时,才会用 EM(如 Dawid-Skene)从众包答案中推断真值。但注意,EM 依赖工人独立性假设,在答案数很少时偏差尤其大。我的习惯是:如果业务允许,先花一点预算建一个小金标集,把“校准工人”和“估计最少答案数”分开,否则后面所有数字都建立在一个黑匣子上。

3.2 任务难度因子:让先验准确率按任务类型缩水

同一个人在不同任务上的真实正确率可以差 30 个百分点。图像分类里,晴天的车和夜间遮挡行人根本不是同一个难度量级。常见做法是给每个任务类型定义一个难度因子 d ∈ [0,1],用下面的公式把工人平均准确率 p 折成该任务上的有效准确率:

p_eff = 0.5 + (p - 0.5) × (1 - d)

当 d=0 时有效准确率等于 p;d=0.3 时,一个 p=0.8 的工人会被折到 0.71;d 接近 1 时,有效准确率接近 0.5,等于瞎猜。难度因子的来源可以是历史任务类型统计、任务复杂度评分,或者一次快速人工预判。如果数据允许,更严谨的做法是 logistic 回归:

log(p_eff/(1-p_eff)) = log(p/(1-p)) - θ_d

其中 θ_d 是难度偏移。这个方法适合已有几百个历史任务、且每类任务都有金标时使用。我一般会在新开任务前,把历史同类任务的 p_eff 拉出来画分布,取 25 分位数作为保守值,而不是用平均。原因很简单:平均会被少数高准确率工人拉高,但我们真正关心的是“在较差条件下也能达标”。

3.3 冷启动时的默认参数:没有历史数据怎么设初值

新任务上线时,最难的是没有本地数据。这时我会用一套保守默认值:工人能力先验取 0.6~0.7,难度因子取 0.2~0.3,质量目标按业务需求定。为什么用 0.6 而不是 0.8?因为众包平台上随机工人的长期平均准确率通常在 0.7 附近,而冷启动任务没有经过培训,错误率会更高。取 0.6 意味着答案数会多算几个,成本略高,但能保住质量下限。

另一个更稳的冷启动方法是“预标定”:先发布 30~50 个小任务,每个任务收 5 个答案,用金标题洗一遍工人,然后从这批数据里重新估计 p_eff,再跑 minimumAnswers。这等于给模型吃了第一口数据,避免上线即翻车。注意金标题不要和正式任务混在一起让工人识别出来,否则他们会“猜”哪个是金标,标注行为会失真。冷启动参数我一般都不会写死在代码里,而是放到配置文件中,每次预标定后自动覆盖。

4. 实现 minimumAnswers 估计:解析公式、蒙特卡洛模拟与代码骨架

4.1 解析近似:用二项分布精确反推最少答案数

先实现最基础版本:给定有效准确率 p_eff,找最小的 n 使多数投票正确率大于等于目标值。这里直接用累计二项分布做精确计算,n 不超过 50 时性能不是问题。

from math import comb def majority_accuracy_exact(n, p): """n 个独立工人,每个正确率 p,多数投票的正确率(二项分布精确计算)""" k = n // 2 + 1 # 多数胜;n 为偶数时保守取超过一半 acc = 0.0 for i in range(k, n + 1): acc += comb(n, i) * (p ** i) * ((1 - p) ** (n - i)) return acc def min_answers_analytic(p_eff, target=0.95, max_n=50): """解析法:顺序扫描找最小 n,使得多数投票正确率 >= target""" for n in range(1, max_n + 1): if majority_accuracy_exact(n, p_eff) >= target: return n return -1 # 超过 max_n 仍未达到

逻辑说明:k = n // 2 + 1表示需要严格超过半数才认为赢;当 n 为偶数时,正好一半对一半错的平局被算作失败,这是保守处理。函数从 n=1 开始递增,返回第一个满足条件的 n。p ** i在 p 接近 1 或 i 较大时可能会有精度问题,但 n ≤ 50 时可忽略。comb(n, i)在 n=50 时大约是 1e14,Python 的整数运算可以支持,不会溢出。

调用示例:如果某个任务的有效准确率是 0.7,目标 90%,min_answers_analytic(0.7, target=0.9)会返回 7(因为 n=5 时只有 83.7%,n=7 时有 87.4%,等等,注意我前面表格里 p=0.7, n=7 是 87.4%,这不是 90%,所以需要 n=9 或 11,取决于计算。我们这里不写具体值为好,让读者跑)。返回 -1 时说明在 50 个答案内达不到目标,需要降低阈值或改善工人质量。

4.2 蒙特卡洛模拟:把工人相关性、偏置也塞进模型

二项分布假设工人独立同分布,但真实众包平台上有“扎堆效应”:一批工人可能互相参考,或者同一时段共同被任务难度带偏。为了把这种相关性考虑进去,我会用蒙特卡洛模拟。

import numpy as np rng = np.random.default_rng(42) def majority_accuracy_sim(n, worker_ps, difficulty=0.0, bias=0.0, trials=2000): """蒙特卡洛模拟多数投票正确率 worker_ps: 每个工人的独立正确率列表 difficulty: 任务难度因子,0~1,越大越难 bias: 群体偏置概率,0~1;大于 0 表示工人错误相关 trials: 模拟轮数 """ p_eff = 0.5 + (np.asarray(worker_ps) - 0.5) * (1 - difficulty) correct = 0 for _ in range(trials): p = p_eff.copy() if rng.random() < bias: # 群体偏置事件:所有人能力同时下降,错误相关体现在同一道题上 p = 0.5 + (p - 0.5) * 0.2 chosen = rng.choice(p, size=n, replace=False) votes = rng.random(n) < chosen if votes.sum() > n / 2: correct += 1 return correct / trials

逻辑说明:worker_ps是一个数组,长度是工人池大小。每次模拟先算有效准确率p_eff,然后在一次任务中随机选 n 个工人。rng.random(n) < chosen生成 0/1 投票。如果触发bias事件,所有工人的能力被压到接近瞎猜,这样同一道题上的错误会同步放大,模拟出“大家同时被难住”的相关性。trials越大结果越稳定,2000 轮时统计噪声约 ±1%。

参数说明:difficulty=0.0表示任务难度和工人历史任务一致;difficulty=0.2表示更难,p_eff 会从 0.8 降到 0.71。bias=0.0表示工人完全独立;bias=0.2表示有 20% 概率出现群体偏置事件。这两个参数建议根据历史任务反复调,不能拍脑袋。

4.3 Python 代码:给定质量阈值,输出最少答案数(可直接改参数)

把解析法和模拟法封装成一个统一入口,方便在众包系统的调度脚本里直接调用。

def estimate_min_answers(worker_ps, difficulty=0.0, bias=0.0, target=0.95, max_n=30, trials=2000): """估算达到 target 质量所需的最少答案数 返回 n;若 max_n 内达不到,返回 -1 """ for n in range(1, max_n + 1): acc = majority_accuracy_sim(n, worker_ps, difficulty, bias, trials) if acc >= target: return n return -1 # 示例:5 个历史工人,能力各不相同,任务难度 0.2,存在少量群体偏置 workers = [0.72, 0.78, 0.74, 0.71, 0.80] n_need = estimate_min_answers(workers, difficulty=0.2, bias=0.1, target=0.95, max_n=30, trials=2000) print(f"最少需要 {n_need} 个答案")

逻辑说明:这个函数是顺序扫描,n 从 1 加到max_n,每次调用蒙特卡洛模拟。因为模拟有随机性,建议固定rng的种子,否则每次运行结果会漂移几个值。我习惯把trials设为 2000 到 5000,再低会出现“这次算 5 个,下次算 6 个”的情况,非常浪费时间。

参数说明:worker_ps优先使用第 3 章里 Beta 后验估计出的工人能力,而不是直接拿历史正确率点估计。target是业务方要的最终准确率,一般 0.9~0.95。max_n是答案数上限,防止死循环,同时也暗示平台对成本容忍的极限。如果返回 -1,需要回到业务侧讨论:降阈值、拆任务、筛工人,或者提高答案单价以吸引高质量工人。

5. 避坑手册: minimumAnswers 估计中常见的 5 个翻车现场

5.1 现象:算出来要 1 个答案,上线后准确率却不达标

新任务上线,我把历史工人平均准确率填成 0.95,minimumAnswers 返回 1,于是“一票制”放行。结果质检一看,准确率只有 0.82。

原因:0.95 是历史所有任务的平均准确率,但新任务难度更高,且冷启动阶段接入的工人没有经历过培训。用全局平均直接当 p_eff,等于把一个长尾分布压成了一个数,必然乐观。

解决:把工人能力分散到任务类型、工人分层里,用 3.2 里的 p_eff 替代历史平均。上线后前 100 条任务抽样质检,如果准确率低于预期,立刻回退到 3 个答案。现在我会把 1 个答案、3 个答案、5 个答案都放入配置项,允许线上快速切换,而不是改代码重新发布。

5.2 现象:模拟通过但线上崩溃:把“准确率”当成了“覆盖率”

模拟结果显示 5 个答案足够,线上跑起来准确率却一直不达标。我一度以为模型写错了,后来发现是目标设错了。

原因:minimumAnswers 模拟多数投票后“最终标签正确的比例”,但业务方要的是“每个类别至少覆盖到多少比例”。例如阳性样本只占 5%,即使整体准确率 95%,阳性样本可能被多数投票全部淹没,召回率为 0。这就是覆盖率和准确率的区别,在类别不均衡时特别明显。

解决:按类别拆开计算。对少数类单独设召回目标,用该类别的混淆矩阵估计 p_class,再算该类所需的最小答案数;如果答案数差异太大,考虑对少数类样本进行过采样,或者在聚合时用 Dawid-Skene 的 EM 后验替代多数投票。

5.3 现象:答案数上调成本翻倍,质量却没有等比例提升

为了把准确率从 92% 提到 95%,我把答案数从 3 加到 9,成本直接翻三倍,准确率只多了 1.2%。

原因:多数投票的边际收益递减。当工人平均准确率在 0.7~0.8 时,3 到 5 个答案的提升最明显,再往后曲线变平;盲目加答案数,只是在为最后的 1% 支付大量成本。

解决:先画“答案数-准确率”曲线,找曲率拐点。我一般要求 N 取在“增加 1 个答案对准确率的提升小于 0.5%”之前。同时看业务真实需求,如果 92% 已经是验收线,就不需要追 95%;如果必须 95%,优先筛掉低质量工人,而不是单纯加答案数。

5.4 现象:冷启动参数拍脑袋,模型给出的答案数忽大忽小

同一个任务,周一跑出来 3 个答案,周三有人调了一下难度因子变成 10 个答案,业务方彻底懵了。

原因:冷启动时 p_eff 落在阈值附近,二项分布的反函数对参数非常敏感,差 0.02 的 p 可能让 N 从 3 跳到 5。加上参数没有版本管理,调参过程不可复现。

解决:对工人能力取置信区间下界,而不是均值;难度因子也取保守值。每次调参记录到配置文件,并带上日期和修改人。我倾向于把“结果敏感度”写进注释:例如 p 从 0.7 到 0.75,N 从 7 变 5,那么业务应该知道这个数字不是精确值,而是一个区间。

5.5 现象:多数投票在类别不均衡时彻底失效

一个二分类任务,正例只有 5%,工人整体准确率 0.8,多数投票所有任务都投负类,整体准确率依然 95%,但业务要的是正例召回。

原因:多数投票的目标是最大化投票正确率,而不是最大化业务目标。类别不均衡时,错误类被大量负类“正确”淹没,少数类完全没有表达力。

解决:不用单纯多数投票,改用“至少有 k 个工人投正例才算正”。然后对正例单独跑 minimumAnswers,计算最少需要多少个答案才能让正例多数的概率达标。代码层面,就是给 estimate_min_answers 加一个positive_class参数,只统计正例样本的多数投票正确率。

6. 把最少答案数变成动态停采策略:验证方法与主动学习进阶

6.1 用历史任务回测:评估你的估计是否可信

我每次上线新答案数模型,都会先拿上一周的历史任务做回测:用已完成的工人答案模拟不同 n 值,看看多数投票准确率是否达到当时 minimumAnswers 预测的水平。如果偏差超过 1 个百分点,说明 p_eff 或 bias 参数没设对,先调参数,不要直接上线。

回测脚本其实很简单,就是把历史任务的答案按工人拆分,然后随机采样 n 个答案做多数投票,重复几百次,计算实际准确率。这个“实测值”和 4.3 的模拟值对比,能暴露出模拟假设里漏掉的相关性。我遇到过 bias 从 0.1 调到 0.3 才对齐的情况,如果不是回测,根本发现不了。

6.2 动态停采:每收一个答案就重估一次,何时停止?

minimumAnswers 更实用的升级是动态停采:每个任务先收 1 个答案,如果工人的实时准确率很高、任务又简单,就可以马上提交;如果答案之间出现分歧,就继续追加答案,直到达到当时重算出的 N_min。这等于把“先估算后执行”变成了“边执行边估算”。

实现时我会用后验更新:每收到一个答案,就把它加入当前任务的工人序列,重新计算 p_eff(注意只用该工人历史准确率,而不是本次单答案),再调用 estimate_min_answers。如果返回 n ≤ 当前答案数,就停;否则继续。注意不要在同一任务上无限追加,要设置max_n兜底,防止恶意刷单或极端难样本把成本拖爆。

我现在每次上线前,都会先跑一遍历史任务回测,用上一周的数据把 difficulty 和 bias 调好,再让动态停采接管线上流量。如果某天答案数分布突然异常,我先查工人池有没有换了群体,而不是怀疑模型算错。这个习惯救过我很多次,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进