一次多分类行为风控的实战复盘。业务已脱敏,代码为示意重写。没有屠榜,只有把一件「看着简单、做起来处处是坑」的事做扎实。
先抛结论
如果你也在做 SaaS / App 的用户行为风控------识别爬虫、机器人、刷量、账号共享------那这篇里最想让你记住三句话:
- 上任何硬规则前,先用 3 行 pandas 看它的触发率。 我拍脑袋定的一条规则,差点把整个模型干崩。
- 别用一次高分骗自己,报「均值 ± 标准差」。 我一度以为自己有 83 分,交叉验证后发现真实水平是 75、还抖 ±5。
- "加了特征" ≠ "模型用了特征"。 我加了一组特征,结果一字没变------它们被模型直接无视了。
下面展开。
一、问题长什么样
一个 SaaS 平台,要给每个用户账号的一小时行为打标签,六选一:
| 标签 | 含义 |
|---|---|
| 0 | 正常 |
| 1 | 内容爬取(遍历式抓页面) |
| 2 | 机器人式重复操作 |
| 3 | 定向刷量 |
| 4 | 异常时段集中操作 |
| 5 | 账号多端共享 / 盗用 |
数据是三类异构日志:操作事件(点了什么资源、什么动作)、页面事件(访问了哪些 URL)、登录事件(什么设备、什么 IP)。字段数不一样、语义不一样。
评价指标是 Macro F1 ------六类等权平均。这一条几乎决定了一切:
占 88% 的正常用户,和占 1% 的某类异常,在评分里一样重。你把大类做到 0.99、把某个小类做成 0,天花板就是 0.8 出头。
所以这不是一个"把主流情况做好"的问题,而是一个"每个小角落都不能塌"的问题。这也是它比想象中难的根源。
二、我的整体思路:判别归判别,解释归解释
最后落地的结构很朴素:
多源行为日志
│
├─ 判别层:结构化特征 + 一个 LightGBM ← 唯一影响分数
│
└─ 解释层:大模型生成一段人话描述 ← 不参与判分,只做可解释
一个我很坚持的原则:大模型不参与判分。 判别交给梯度提升树(表格数据上 GBDT 依然是又快又稳的答案),大模型只负责把「结论 + 关键数据」翻译成人能读的描述。
好处很实在:分数的确定性握在自己手里,可解释的交付也满足了,还不用让大模型的推理延迟拖累主链路。风控系统里,"可解释"和"实时判别"最好物理分开,别让一个模块既背 KPI 又背话术。
三、特征工程:这才是真正的胜负手
模型我只用了一个 LightGBM,没有任何花哨的 stacking。真正拉开差距的是特征。 我按 (用户, 小时窗口) 聚合出约 40 维,四类,挑最有代表性的说。
会「短间隔 + 大量不同页面 + 没有真实消费」的,是爬虫
爬虫的指纹很清晰:疯狂翻不同 URL、路径规整、跳转间隔极短、但从不产生真实的业务动作。于是有了"10 秒滑窗内最大事件数"这种突发特征:
python
def burst_max(ts, window_sec=10):
"""任意 10 秒窗口内的最大事件数------机器/爬虫的突发信号"""
arr = ts.sort_values().values
best = 1
for i in range(len(arr)):
diffs = (arr[i:] - arr[i]) / np.timedelta64(1, "s")
best = max(best, int((diffs <= window_sec).sum()))
return best
「账号共享」单看一个窗口根本发现不了
这是最有意思的一类。共享账号在任意单个小时窗口里,看起来都很正常 。它的破绽在跨窗口:一个用户跨越多个时间段,到底用了几个不同设备、切换了几次、最常用的那个设备占比多低。
python
# 最常用设备的占比:正常用户接近 1,共享账号明显偏低
dev_share = 最常用设备的操作数 / 该用户总操作数
# 跨窗口设备切换次数:每个窗口取首个设备,数相邻窗口变了几次
switches = 相邻窗口设备不同的次数之和
一个反直觉的点: 区分共享账号,最好用的不是 "设备多"这一个特征。因为爬虫、刷量这些活跃的坏账号,设备也多。真正的判别力来自组合 ------"设备多,但内容 / 分组很集中"才是共享。好特征常常是几个平庸特征的组合,而不是某个天才单点。
这条经验可以直接迁移到你的风控:跨会话、跨时间的聚合特征,往往比单次请求的特征值钱得多。
四、类别不平衡:我踩的最大一个坑
九成正常、某些异常类整个数据集就几十个样本。怎么办?
先说对的做法:代价敏感
对表格 + GBDT,我不太用 SMOTE 这类合成过采样,更喜欢直接给样本加权重 ,公式很朴素:权重 = 总样本 / (类别数 × 该类样本数),让稀有类在损失里"喊得更大声"。一行 sample_weight 搞定,不引入合成数据的分布风险。
再说我差点翻车的做法:硬规则
面对一个死活做不上去的小类,我的第一反应和所有人一样:加规则啊------"设备数超过某个值,直接判成共享不就完了?"
我试了。整体分数当场跳水。
复盘原因:我拍脑袋定的那个阈值,在真实数据分布下触发率接近 100%。等于把一大片正常样本无差别改判,大类被误伤,等权 Macro F1 立刻崩。
后来我给所有硬规则加了一道安全阀------先算触发率,超过上限就自动禁用并告警:
python
def apply_rule(pred, col, threshold, safety_max=0.30):
mask = col >= threshold
if mask.mean() > safety_max: # 触发率太高
print(f"[硬规则] 触发率 {mask.mean():.0%},超安全线,自动禁用")
return pred, False # 拒绝生效
return np.where(mask, TARGET, pred), True
这条教训值得裱起来:任何阈值、任何规则上线前,先花 3 行 pandas 看它在真实数据上会命中多少比例。一个 99% 触发的"规则",不是规则,是事故。
五、验证:别让一次高分骗了你
模型部分乏善可陈------一个 LightGBM,常规参数,early stopping。真正想强调的是验证的诚实。
我一开始看到某次跑出 83 分,差点就信了。冷静下来做了 5 折分层交叉验证,真相是:
ini
5 折得分:[75.6, 83.7, 72.9, 76.7, 69.3]
均值 ± 标准差:75.6 ± 4.8
那个漂亮的 83,只是运气最好的一折。真实水位是 75 上下,单折能抖 ±5。
抖动哪来的?小类每折只有 4~5 个样本,判错一两个,F1 就剧烈波动------这是样本量的锅,不是过拟合。
结论很简单:汇报一个「均值 ± 标准差」,远比汇报一个精心挑选的单次高分诚实。 前者是你方案的真实水位,后者是你的自我安慰。
顺带一个低级但极常见的坑:交叉验证用的参数,要和最终上线的参数一致。 我一度用 A 参数做 CV、B 参数出结果------那你验证的根本不是你要交付的那个模型。
六、可解释描述:大模型 + 一定要有的兜底
交付要求每条判定附一段 ≥50 字、结合具体数据 的自然语言描述(不能是通用模板)。我用一个开源中文大模型来干这件事,核心就一句话:prompt 里塞满具体数字,并明令禁止模板腔。
但工程上最关键的不是 prompt,是兜底 。大模型会超时、会输出跑偏、服务会挂。所以我永远先用规则生成一版托底,大模型成功且达标才覆盖:
python
def describe(rows, use_llm=True):
out = rule_based(rows) # 规则版:确定性兜底,永远先有
if use_llm and service_alive():
for i, row in rows:
text = llm(row)
if text and len(text) >= 50: # 达标才覆盖
out[i] = text
return out
再加一个"保底长度"函数,不够 50 字就反复补充上下文。凡是硬性交付约束,一定要有代码级兜底,别指望模型每次都乖。
七、坑集(精华浓缩版)
- 硬规则前先看分布------99% 触发率 = 灾难。3 行 pandas 能救命。
- 加了特征却纹丝不动 ------我加的一组新特征和已有特征高度共线,被 GBDT 的列采样直接无视。务必用特征重要度 / 消融实验确认"模型真的用了它"。
- CV 参数 ≠ 上线参数------等于验证了一个你不打算交付的模型。
- 别信文档字段,信真实数据 ------文档标注的字段编码和实测对不上,一切以
head出来的为准。 - 可解释和判别要解耦------让大模型只做解释、不背判别 KPI,系统才稳。
八、写在最后
这个问题真正的胜负手,一个模型参数都排不上号,而是这三件事:
- 特征工程:尤其跨会话聚合和组合特征,小类的区分力几乎全靠它。
- 对不平衡的敬畏:等权指标下,代价敏感 + 拒绝拍脑袋规则,比堆模型重要得多。
- 诚实的验证:用均值 ± 标准差认识自己的真实水位,而不是留着那个好看的单次高分自我陶醉。
模型可以很朴素,把数据吃透、把不平衡处理稳、把验证做诚实,分数自然就稳了。风控这行,稳,比高更重要。