单条内容的检测结果,本质上是一个带噪声的观测值。拿它直接做决策,会踩两个坑:
- 一条 200 字的短评,
confidence = 0.91,但模型对短文本本来就不敏感,这个 0.91 的方差极大; - 一个正常作者偶尔写了一篇模板化的产品说明,被判成 AI,直接限流,投诉就来了。
风控的正确姿势是聚合:不看单条,看这个账号在一段时间内的整体表现。这篇讲怎么把逐条分数变成一个可决策的账号分数。
一、先明确要解决什么
设账号 u 在窗口内有 n 条内容,每条有一个 AI 概率 p_i ∈ [0,1]。要回答的问题是:
这个账号是「批量生产 AI 内容」的概率有多大?
注意问的不是「这 n 条里有几条是 AI」,而是「这个账号的行为模式是不是 AI 批量生产」。两者的差别在于:前者是计数,后者是推断。计数对样本量极度敏感------3 条里 3 条是 AI,和 300 条里 300 条是 AI,结论完全不同。
所以需要一个带样本量感知的模型。Beta-Binomial 正好合适。
二、模型:Beta 先验 + 加权观测
把每条内容的判定看作一次伯努利试验(AI / 非 AI),账号的「AI 倾向」记为 θ,先验取 Beta(a₀, b₀)。观测到数据后,后验是:
css
θ | data ~ Beta(a₀ + Σ wᵢ·yᵢ, b₀ + Σ wᵢ·(1 - yᵢ))
其中 yᵢ ∈ {0,1} 是第 i 条的硬标签,wᵢ 是权重。
a₀ = b₀ = 2 是一个温和的先验,等价于「先假设看过 4 条内容,2 条 AI、2 条人工」。它保证新账号不会因为一条内容就冲到 0.9。
硬标签怎么来? 检测结果给的是连续概率,需要先离散化:
python
def to_label(p: float, lo: float = 0.30, hi: float = 0.85) -> int | None:
if p >= hi:
return 1 # 判为 AI
if p <= lo:
return 0 # 判为人工
return None # 灰区,不参与统计
灰区直接丢弃,而不是硬塞成 0 或 1。这是整套逻辑里最重要的一条:不确定的样本比错误的样本更毒。把 0.55 强行当成「人工」,会把一个 AI 账号的分数拉低;当成「AI」,会冤枉正常用户。
权重 wᵢ 怎么定? 三个因子相乘:
wᵢ = 时效衰减 × 文本长度可信度 × 检测置信度
- 时效衰减:
exp(-Δt / τ),τ = 14天。一个月前的内容对「当前是否在批量生产」的参考价值很低。 - 长度可信度:
min(1, len/300)。200 字的短文本证据力弱,5000 字的长文证据力强。 - 检测置信度:
|p - 0.5| × 2。0.95 的判定比 0.55 的判定更可信。
三个因子都在 [0,1],乘起来天然有界。
三、决策:用后验均值,还是下界?
后验均值 E[θ] = a / (a + b) 是最直观的分数,但它有个问题:样本少的时候不可靠。一个账号只有 1 条内容、被判为 AI,后验均值是 (2+1)/(4+1) = 0.6,看起来已经很高了。
更好的做法是用后验的下分位数(比如 5% 分位),也就是「保守估计下,这个账号至少有多 AI」。样本少时下界会被拉得很低,样本多时才逼近均值------正好符合「证据不足不下结论」的原则。
Beta 分布的分位数没有闭式解,用 scipy.stats.beta.ppf 或者自己写二分。不想引入 scipy 的话,用 Wilson 下界近似也够用:
python
import math
def wilson_lower(pos: float, n: float, z: float = 1.96) -> float:
if n <= 0:
return 0.0
phat = pos / n
denom = 1 + z * z / n
center = phat + z * z / (2 * n)
margin = z * math.sqrt(phat * (1 - phat) / n + z * z / (4 * n * n))
return max(0.0, (center - margin) / denom)
pos = Σ wᵢ·yᵢ,n = Σ wᵢ。Wilson 下界的好处是:n 很小时结果被压到接近 0,n 变大后收敛到 phat。直觉上就是「样本不够,别下结论」。
四、冷启动:样本不够就不评分
再好的模型也救不了零样本。规则很朴素:
有效样本量 n |
处理 |
|---|---|
< 3 |
不评分,只记录 |
3 ~ 10 |
出分,但只用于观察,不触发任何动作 |
> 10 |
出分,可按阈值触发动作 |
「有效样本量」是加权后的 n,不是条数。10 条 100 字的短评,加权后可能只有 2.5,仍然算冷启动。
这条规则能挡掉大部分误伤投诉。真实的投诉场景里,绝大多数是「我这个号一共就发了 5 篇」。
五、完整实现
python
import math
from dataclasses import dataclass
from datetime import datetime, timezone
A0, B0 = 2.0, 2.0 # 先验
TAU_DAYS = 14.0 # 时间衰减常数
LO, HI = 0.30, 0.85 # 硬标签阈值
@dataclass
class Item:
confidence: float # 检测给出的 AI 概率
chars: int # 文本长度
created_at: datetime
def decay(created_at: datetime, now: datetime, tau_days: float = TAU_DAYS) -> float:
delta_days = max(0.0, (now - created_at).total_seconds() / 86400)
return math.exp(-delta_days / tau_days)
def item_weight(item: Item, now: datetime) -> float:
length_w = min(1.0, item.chars / 300)
conf_w = abs(item.confidence - 0.5) * 2
return decay(item.created_at, now) * length_w * conf_w
def score_account(items: list[Item], now: datetime | None = None) -> dict:
now = now or datetime.now(timezone.utc)
pos = neg = 0.0
used = 0
for it in items:
p = it.confidence
if LO < p < HI:
continue # 灰区不参与
w = item_weight(it, now)
if w <= 0:
continue
used += 1
if p >= HI:
pos += w
else:
neg += w
n_eff = pos + neg
a, b = A0 + pos, B0 + neg
mean = a / (a + b)
return {
"score": round(mean, 4), # 后验均值
"lower": round(wilson_lower(pos, n_eff), 4), # 保守下界
"n_eff": round(n_eff, 2),
"used": used,
"skipped": len(items) - used,
"cold_start": n_eff < 3,
}
def decide(r: dict) -> str:
if r["cold_start"]:
return "observe" # 只看不动
s = r["lower"] # 决策用下界,不用均值
if s >= 0.80:
return "block"
if s >= 0.60:
return "manual_review"
if s >= 0.40:
return "throttle"
return "normal"
跑一组对比,能直观看出样本量的作用:
python
now = datetime.now(timezone.utc)
# A:1 条高置信 AI
a = [Item(0.95, 800, now)]
# B:20 条,其中 18 条高置信 AI
b = [Item(0.93, 800, now) for _ in range(18)] + [Item(0.10, 800, now) for _ in range(2)]
for name, items in (("A", a), ("B", b)):
r = score_account(items, now)
print(name, r, decide(r))
输出大致是:A 的均值 0.6、下界 0,判定 observe;B 的均值 0.88、下界 0.78,判定 manual_review。这正是想要的效果------一条内容不足以定性,二十条才能定性。
六、阈值与动作
| 下界 | 动作 | 说明 |
|---|---|---|
< 0.40 |
正常 | 不干预 |
0.40 ~ 0.60 |
限流 | 降低分发权重,不通知用户 |
0.60 ~ 0.80 |
人工复核 | 进审核队列,24 小时内处理 |
≥ 0.80 |
处置 | 限制发布 / 封禁,需保留申诉入口 |
三个原则:
1. 能用轻动作就不用重动作。 限流是可逆的,封禁不是。风控的第一目标是降低损失,不是抓住所有人。
2. 高置信区间要窄。 ≥ 0.80 才自动处置,意味着只有「大量高置信 AI 内容 + 长文本」的账号会被自动处理。宁可漏,不可错------漏掉的成本是几条低质内容,错杀的成本是一次舆情。
3. 动作必须可解释。 给用户的通知不能只说「检测到 AI 内容」,要给依据:命中了几条、时间段、置信区间。用户能看懂,申诉才有意义;审核员能看懂,复核才有效率。
七、工程细节:别每次全量重算
如果每次评分都把账号的所有历史内容拉出来算一遍,成本会随数据量线性增长。实际做法是维护增量累加器:
sql
CREATE TABLE account_risk (
user_id BIGINT PRIMARY KEY,
pos DECIMAL(12,4) NOT NULL DEFAULT 0, -- Σ w·y
neg DECIMAL(12,4) NOT NULL DEFAULT 0, -- Σ w·(1-y)
updated_at DATETIME(3) NOT NULL,
version BIGINT NOT NULL DEFAULT 0
);
每来一条新结果,pos 或 neg 加上当次权重;同时用定时任务按 exp(-Δt/τ) 对所有行做整体衰减,比如每天凌晨乘一次 exp(-1/14)。这样查询是 O(1),衰减是批量操作。
version 列用于乐观锁,防止并发的两条结果互相覆盖。
还需要一个回填 能力:模型升级、阈值调整后,历史账号的分数需要重算。这时不能只靠增量累加器,要有「按时间窗口全量重算某个账号」的函数,并且能离线跑。前面那个 score_account(items) 就是为回填准备的------增量算日常,全量算回填,两套逻辑必须用同一个函数,否则分数会对不上。
八、小结
账号级评分的价值不在于「更准」,而在于把噪声平均掉:
- 单条分数是观测,账号分数是推断,两者不能混用;
- 用 Beta-Binomial 做聚合,先验保证冷启动不被极端值带偏;
- 灰区样本直接丢弃,不确定的样本比错误的样本更毒;
- 权重 = 时效 × 长度 × 置信度,三者相乘天然有界;
- 决策用下界不用均值,样本不足时自动趋于保守;
- 阈值分级,轻动作优先,重动作留申诉;
- 增量累加器负责日常,全量函数负责回填,两者共用同一份计算逻辑。
最后一句话:风控模型的上限由数据决定,下限由产品设计决定。阈值调得再精细,也不如给用户留一条能说清楚的申诉通道。
示例基于文识云(sbnew.top)的 AIGC 检测结果字段。评分模型与具体检测服务商无关,换成任何给出 0~1 概率的接口都能套用。