我把一个「九成正常、一成作恶」的风控难题做稳了,聊聊踩过的坑

一次多分类行为风控的实战复盘。业务已脱敏,代码为示意重写。没有屠榜,只有把一件「看着简单、做起来处处是坑」的事做扎实。

先抛结论

如果你也在做 SaaS / App 的用户行为风控------识别爬虫、机器人、刷量、账号共享------那这篇里最想让你记住三句话:

  1. 上任何硬规则前,先用 3 行 pandas 看它的触发率。 我拍脑袋定的一条规则,差点把整个模型干崩。
  2. 别用一次高分骗自己,报「均值 ± 标准差」。 我一度以为自己有 83 分,交叉验证后发现真实水平是 75、还抖 ±5。
  3. "加了特征" ≠ "模型用了特征"。 我加了一组特征,结果一字没变------它们被模型直接无视了。

下面展开。

一、问题长什么样

一个 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 字就反复补充上下文。凡是硬性交付约束,一定要有代码级兜底,别指望模型每次都乖。

七、坑集(精华浓缩版)

  1. 硬规则前先看分布------99% 触发率 = 灾难。3 行 pandas 能救命。
  2. 加了特征却纹丝不动 ------我加的一组新特征和已有特征高度共线,被 GBDT 的列采样直接无视。务必用特征重要度 / 消融实验确认"模型真的用了它"。
  3. CV 参数 ≠ 上线参数------等于验证了一个你不打算交付的模型。
  4. 别信文档字段,信真实数据 ------文档标注的字段编码和实测对不上,一切以 head 出来的为准。
  5. 可解释和判别要解耦------让大模型只做解释、不背判别 KPI,系统才稳。

八、写在最后

这个问题真正的胜负手,一个模型参数都排不上号,而是这三件事:

  • 特征工程:尤其跨会话聚合和组合特征,小类的区分力几乎全靠它。
  • 对不平衡的敬畏:等权指标下,代价敏感 + 拒绝拍脑袋规则,比堆模型重要得多。
  • 诚实的验证:用均值 ± 标准差认识自己的真实水位,而不是留着那个好看的单次高分自我陶醉。

模型可以很朴素,把数据吃透、把不平衡处理稳、把验证做诚实,分数自然就稳了。风控这行,稳,比高更重要。

相关推荐
她说彩礼65万3 小时前
Asp.net core返回类型总结
后端·asp.net
用户6757049885023 小时前
手摸手教你玩 Jenkins,一次搞懂 CI/CD!(第二章:发布之sshPublisher)
后端·jenkins
用户6757049885023 小时前
手摸手教你玩 Jenkins,一次搞懂 CI/CD!(第一章:部署)
后端·jenkins
一路向北North4 小时前
Spring Security OAuth2.0(20):完善环境配置
java·后端·spring
从零开始的代码生活_4 小时前
C++ string 详解:常用接口、字符串算法与深拷贝实现
开发语言·c++·后端·学习·算法
65岁退休Coder4 小时前
LangChain v1.3.4 笔记 - 01 模型的连接/输出、消息、提示词模板
后端
笃行3505 小时前
数据库优化深度科普向|外连接消除底层原理,国产数据库优化器设计思路拆解
后端
苍何5 小时前
耗时 7 天,我们开源了腾讯 WorkBuddy 实战蓝皮书(建议收藏)
前端·后端
青山木6 小时前
Redis 高可用的最后一公里:Cluster 分片、Gossip 与故障转移全流程
数据库·redis·后端·缓存