决策引擎的迭代历史

在消费金融业务场景中,对用户的风控决策,会持续影响整个借贷流程。一个合理的授信额度,可以提高用户的下单率;一个准确的逾期风险评级,可以减轻贷后的催告压力,也能有效降低用户逾期率。

决策引擎作为风控场景中的运算中心,能够接收用户的一系列特征数据,经过诸如模型分析、规则决策等一系列复杂的运算之后,综合输出授信结果、授信额度以及风控评级等决策信息,为风控业务提供支持。

让我们假设一个简单的,只输出授信结果的决策流程:

1
2
3
4
5
6
7
8
if (用户处于白名单)
return 授信通过
if (用户存在欺诈风险)
return 授信拒绝
if (用户模型分A处于[0, 0.2] && 用户模型分B处于[0, 0.5))
return 授信通过
else
return 授信拒绝

在这个流程中,会使用用户唯一ID如手机号,来判断其是否处于白名单;接收用户录入的联系地址、职业等信息,判断其是否存在欺诈风险;同时使用身份证信息、人像信息等入模型,计算得到模型分,通过阈值判断做出授信通过或者授信拒绝的决策,再将其返回给业务以继续后续的业务处理:

如果授信通过,短信召回并对用户开放下单入口;如果授信拒绝,做信息通知,同时关闭下单入口。

基于此,决策时间的长短,也会一定程度上影响用户的留存率,试想如果一个决策执行了24小时,可能我们的用户早就在长时间的等待下流失,选择了别家下单。

因此,为了使风控决策能准确而快速的达成,这几年,我司的决策引擎持续迭代,经历了规则集、drools和自研风控引擎三个阶段,而本文会对这三个阶段逐一进行介绍,并对自研风控引擎进行重点演示。

第一代:规则集模式

我们依然以上文中提到的决策流程举例,对每一个if条件进行分析:

  • 用户处于白名单”,其输出为是或者否,因此可以将其定义为一个输出类型为Boolean的规则

  • 用户存在欺诈风险”,其输出为是或者否,但是它不再是一个单一条件,反而是一系列逻辑的整合,包括但不限于:

    • 用户的职业包含设定的不准入关键词
    • 用户的联系地址中包含设定的不准入关键词
    • 用户处于风控自有黑名单

    因其入参多样,故有不同的实现方式:一是将其整合为一个规则,规则内容为上述三个判断逻辑取或,输出Boolean类型的结果;二是上述三条判断逻辑各自作为一条规则,“用户存在欺诈风险”成为一个规则的集合,集合里的每个规则顺序执行,规则之间是“或”的关系,一旦存在一条规则为true,则规则集也输出true。

  • 用户模型分A处于[0, 0.2]”的处理相对复杂,它可以被单独定义为一个规则,输出结果为Boolean;也可以将“用户模型分A结果”定义为一个规则,这个规则输出结果为Double类型的分数,此时判断语句会被进一步翻译为:“规则结果|操作符(between)|比较值([0, 0.2])”。对“用户模型分B”的处理,也是一样。

初步分析已经完成,再重新来看后两个条件,是否能寻求一种方式来实现整体逻辑的统一。

考虑到“用户存在欺诈风险”这个判定条件,后续还可能增加新的判断标准,那么把它设计为规则集无疑是更优的处理,这样当新的标准增加时,只需要新增规则放入规则集即可,每个规则都有自己的单一职责,原来的规则也不会被影响。

再看“**用户模型分A处于[0, 0.2]**”,将其作为一个单独的规则来设计,直观来看是最合理的,输出类型也与前两个条件一致,是Boolean。但是现在考虑这样一种情况,决策流程试运行一段时间之后,分析发现模型分A处于[0.18, 0.2]范围内的用户逾期率整体更高一些,这时需要把阈值从[0, 0.2]改到[0, 0.18),就需要对规则进行重新修改和发布,如果需要频繁修改阈值,就需要频繁修改规则,不稳定的规则反而会增加系统运行的风险。这样的话,选择让“用户模型分A结果”成为一个规则,那么修改发生时,只需要更新比较值,看起来更加满足需求。

因此,上文的决策流程现在就变成:

1
2
3
4
5
6
7
8
if ([规则:用户处于白名单])
return 授信通过
if ([规则集:用户存在欺诈风险])
return 授信拒绝
if ([规则:用户模型分A结果]处于[0, 0.2] && [规则:用户模型分B结果]处于[0, 0.5))
return 授信通过
else
return 授信拒绝

此时,由于流程中既出现规则集类型又出现规则类型,开发环节要做的兼容变多了,考虑到规则集与规则是完美的包含关系,所以为了简化配置,我们最终将规则全部都包装一层,变成规则集,来完成配置的统一。

至此,第一版规则集方式的决策引擎,就达成了以下几条共识:

  • 参与比较的单元是规则集
  • 规则集的输出类型为Boolean或者Double
  • 规则集可以指定运行策略

在风控部门成立之初,其实是没有严格划分风控开发部和风控策略部的,研发也会参与进策略运营中,此时的业务量比较小,场景简单,需要的决策条件也相对比较简单。因此,直接对这些规则进行代码编写无疑是成本最低的方案。同时,为了支持条件阈值随时可修改,执行流程可以在后台页面中动态配置,这些配置信息被存放进数据库,使用时再做解析。

对于上文示例流程,经过第一步——规则的硬编码,最终会定义6个规则,这6个规则分组后,形成4组规则集:

而流程配置则直接使用Json,由用户通过前端页面编写完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
{
"states": [
{
"name": "WHITE_LIST",
"actions": [
{
"conditions": [
{
"ruleSetId": 1,
"operator": "EQ",
"value": true
}
],
"type": "ACCEPT",
"data": {
"credits": 1000
}
},
{
"conditions": [
],
"type": "CONTINUE",
"next": "ANTIFRAUD"
}
]
},
{
"name": "ANTIFRAUD",
"actions": [
{
"conditions": [
{
"ruleSetId": 2,
"operator": "EQ",
"value": true
}
],
"type": "REJECT",
"data": {
"credits": 0
}
},
{
"conditions": [
],
"type": "CONTINUE",
"next": "MODEL_SCORE"
}
]
},
{
"name": "MODEL_SCORE",
"actions": [
{
"conditions": [
{
"ruleSetId": 3,
"operator": "GE",
"value": 0
},
{
"ruleSetId": 3,
"operator": "LE",
"value": 0.2
},
{
"ruleSetId": 4,
"operator": "GE",
"value": 0
},
{
"ruleSetId": 4,
"operator": "LT",
"value": 0.5
}
],
"type": "ACCEPT",
"data": {
"credits": 3000
}
},
{
"conditions": [
],
"type": "REJECT",
"data": {
"credits": 0
}
}
]
}
]
}

在这个用户流程中,每个action包含多个condition,condition是最小单位的条件,多个condition之间做且(&&)运算,每个condition由规则集的ID、操作符和比较值三个部分组成,“用户处于白名单”这行伪代码最终被翻译为:

1
2
3
4
5
{
"ruleSetId": 1,
"operator": "EQ",
"value": true
}

在配置中,若action运算结束,结果为true,此action中配置的type会成为当前state的结果;若结果为false,会继续运行下一个action,直到action命中。其中,type分为终态类型与非终态类型:

type 类型 操作 含义
ACCEPT 终态 中断流程 授信通过
REJECT 终态 中断流程 授信拒绝
CONTINUE 非终态 流转到下一个state 下一执行节点为“next”对应的state

当然,type只能确定对用户授信通过或者拒绝,如果授信通过,应该给用户授信多少金额是由data决定的。每一个终态节点都应该输出必要的决策数据,例如白名单通过的用户,统一授信1000元的额度,模型分匹配的用户授信3000元额度,而被授信拒绝的用户,授信金额显然为0。

这样的设计,在业务发展的初期已经基本满足需求,但是这样的流程也存在显而易见的问题:

  • 如果需要增加新的规则,需要开发人员先进行编码,代码上线到生产之后,策略人员才可以配置决策流程,上线周期较长。
  • 流程中使用了ruleSetId参与配置,在流程配置阶段,无法直观感知ruleSetId对应的规则集的含义。
  • 示例中,当有两个模型分的筛选条件时,conditions中就需要包含4组condition了,可以预见,若条件配置变得复杂,condition的配置数量可能会爆炸增长。
  • 直接进行json配置的方式,缺少一定的校验,需要人为保证json配置的正确性,比如:
    • 路径需要是闭合的,不能出现每一个action都无法命中的情况
    • 终态的情况下,data需要正确配置
    • 非终态的情况下,next需要正确配置
    • ruleSet的输出类型需要和比较值的类型保持一致

这些问题,在初期的业务场景下,都是可以容忍的,但是随着业务规模的发展壮大,慢慢就会成为痛点。

第二代:Drools配置

随着业务场景的增多,最先令人难以忍受的问题出现了:condition的配置数量爆炸增长,可读性变差,多分支跳转配置易出错。
依然以上文提到的例子为例,当走到了模型分的判断逻辑时,如果对模型分A和模型分B更加精细化的划分:

对应的json配置会变成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
{
"name": "MODEL_SCORE",
"actions": [
{
"conditions": [
{
"ruleSetId": 3,
"operator": "GE",
"value": 0
},
{
"ruleSetId": 3,
"operator": "LT",
"value": 0.2
},
{
"ruleSetId": 4,
"operator": "GE",
"value": 0
},
{
"ruleSetId": 4,
"operator": "LT",
"value": 0.5
}
],
"type": "ACCEPT",
"data": {
"credits": 10000
}
},
{
"conditions": [
... // 忽略4个条件
],
"type": "ACCEPT",
"data": {
"credits": 8000
}
},
{
"conditions": [
... // 忽略4个条件
],
"type": "ACCEPT",
"data": {
"credits": 6000
}
},
{
"conditions": [
... // 忽略4个条件
],
"type": "ACCEPT",
"data": {
"credits": 5500
}
},
{
"conditions": [
... // 忽略4个条件
],
"type": "ACCEPT",
"data": {
"credits": 3000
}
},
{
"conditions": [
],
"type": "REJECT",
"data": {
"credits": 0
}
}
]
}

可以看到,这种配置非常的繁琐,重复度很高,在我们生产的实际应用中,甚至有配置近千行json的决策,可读性很差,修改发生时,配置出错的风险大大增加。在这种背景下,Drools被引入,期望能完成系统配置的升级。

Drools的基本语法为:

1
2
3
4
5
6
rule "规则名"
when
条件 - 规则的LHS(left hand side)
then
动作/结果 - 规则的RHS(right hand side)
end

对上面的模型分配置逻辑,我们切换成Drools配置,会变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import com.yqg.risk.core.drools.Data;
import java.lang.Math;

rule "ModelRule"
when
d: Data();
then
double modelScoreA = d.scoreRuleSet.get(3); //从规则集的结果中 获得模型分A
double modelScoreB = d.scoreRuleSet.get(4); //从规则集的结果中 获得模型分B
// 评级
if (modelScoreA >= 0 && modelScoreA < 0.2 && modelScoreB >= 0 && modelScoreB < 0.5) {
d.decision = "ACCEPT";
d.credit = 10000;
} else if (modelScoreA >= 0.2 && modelScoreA < 0.5 && modelScoreB >= 0 && modelScoreB < 0.5) {
d.decision = "ACCEPT";
d.credit = 8000;
} else if (modelScoreA >= 0.5 && modelScoreA < 0.75 && modelScoreB >= 0 && modelScoreB < 0.5) {
d.decision = "ACCEPT";
d.credit = 6000;
} else if (modelScoreA >= 0 && modelScoreA < 0.2 && modelScoreB >= 0.5 && modelScoreB <= 1) {
d.decision = "ACCEPT";
d.credit = 5500;
} else if (modelScoreA >= 0.2 && modelScoreA < 0.5 && modelScoreB >= 0.5 && modelScoreB <= 1) {
d.decision = "ACCEPT";
d.credit = 3000;
} else {
d.decision = "REJECT";
d.credit = 0;
}
end

其中Data类为预先定义好的Java类,Drools通过读取Data类的scoreRuleSet获取模型分,经过一系列的计算逻辑之后,对decision和credit完成赋值。

可以看到,相对于长Json的配置方式,Drools脚本的编写,可读性更好,灵活度也很高。直接书写代码的方式,不但可以同时进行多个变量之间的比较运算,还可以进行算术运算,决策方式更加丰富,也解决了配置复杂的问题。Drools的使用,在很长一段时间内缓解了策略人员的配置压力。

不过相应的,Drools代码的书写,对于不熟悉代码的策略人员来说,会有一定的使用门槛,并且依然没有解决规则集方式下的其他问题,最终只成为一个过渡方案。

第三代:自研风控引擎

近一两年,规则集和Drools方式的混合使用,已经无法满足我们的需求。

一方面,业务高速发展,数据源、特征和模型数量快速增长,出于系统监控、资源利用、问题分析等多方面的考虑,整个风控系统有微服务化拆分的要求;另一方面,风控部门也完全拆分为开发和策略两个团队,对于部分策略同学来说,直接编写Json或者Drools代码的成本很高;除此之外,快速的策略迭代需要,也无法容忍之前“规则开发-测试发布-配置修改”的长发布流程,尤其当规则上线出现问题时,只能通过发布回滚解决问题,为策略上线增加了更多的不稳定性。

为了解决这些问题,一个新的决策引擎系统,自然被提上开发日程,此系统在设计之初制定了如下目标:

  • 服务化。
  • 除了支持Boolean和Double的输出用以兼容老系统逻辑,还需要支持更多的输出类型。
  • 支持灵活的策略配置,取消规则硬编码。
  • 使用界面化配置方式,减少甚至避免策略人员的代码编写。
  • 能支持规则、规则集、决策表等多种决策方式。

一、变量定义

决策引擎的核心是组件,组件可以认为是对一个表达式或者多种复合的配置条件的封装,而变量作为表达式的构成要素之一,会在运行时根据用户数据绑定不同的变量值完成表达式的计算,同时,此计算结果也会直接赋值给组件预定义的输出变量,实现数据的存储。

在此系统中,变量分为全局变量和特征变量两类,其数据类型有四种:整数、小数、字符串和布尔:

全局变量,标为var,运行时变量值可以被修改,作为组件的入参或者出参使用,在组件中实现数据的传递。 例如,一个决策流中,前后配置了两个节点A、B,A节点配置输出全局变量var.score,B节点输入依赖var.score,那么运行时,A输出的var.score可以直接被B使用。

特征变量,标为feature,运行时变量一旦被赋值,后续无法再被修改。 特征值的初始化,是在运行时与特征引擎交互后获得的,在组件的运行过程中,该特征变量可读,但是不再支持修改。

二、组件分类

组件是决策引擎系统最重要的组成部分,丰富的组件类型是支撑复杂业务场景的基础,目前启用的组件包括规则、规则集、决策表、打分卡、模型、函数、决策流七种。

1. 规则

规则由表达式构成,一个规则即一个表达式。
我们将表达式定义为Expression ,其结构体为IValue op(operator) IValue,其中IValue为抽象单元,有三种实现:

  1. Parameter(参数,即全局变量或者特征变量,如feature.user_age)
  2. Expression
  3. RealValue(数据值,如自然数、布尔、字符串等)

因此Expression可以使用以上实现类进行组合,灵活配置用户所需的表达式,如

  • Parameter op RealValue (如 feature.user_age > 18)
  • Expression op Expression (如(feature.user_age > 18) && (feature.user_job ≠ ‘无业’)
  • Parameter op Parameter (如 feature.score1 > feature.score2)
  • RealValue op RealValue (如 20 > 18 )

目前,决策引擎对用户只开放了前两种表达式方式配置入口。

2. 规则集

规则集是一系列规则的集合,配有确定的执行策略。

受不同的策略影响,规则呈现不同的执行方式,例如,当配置了执行策略为“命中即中断”,规则会顺序执行,如果遇到第一个命中的规则,就中断当前规则集的执行,当前规则的输出也会成为规则集的最终输出。

1
2
3
4
{
"rules": [], // array
"strategy": "执行策略"
}

3. 决策表

分为一维决策表和二维决策表两类:

  • 一维决策表

对单一的输入变量进行表达式判定,表达式命中之后,输出该表达式对应的结果。

  • 二维决策表

即交叉决策表,输入X和Y两个维度的变量,并在两个维度上分别设置X和Y的取值范围,交叉得到最终的输出。

4. 打分卡

决策组件之一,支持多个变量作为输入,每个变量可以配置不同的阈值条件,形成分箱。通过条件分箱与得分汇总,辅以权重,输出最终得分。

以上图中的打分卡为例,输入变量为:feature.income和var.age,假设对于某个用户

feature.income = 25000,命中分箱3,得出第一个得分score_1 = 30 * 1.4 = 42

var. age = 45,命中分箱2,得到第二个得分score_2 = 10 * 0.8 = 8

则该用户最终得分 = 50

5. 模型

直接对接模型服务,配置其支持的模型ID,在决策时直接调用模型服务拿到输出结果。

6. 函数

在此系统中,由于一期暂未能支持更复杂的表达式配置(如Parameter op Parameter),故保留了允许用户自定义脚本的功能,新增函数组件,作为第二代与第三代之间的过渡组件。

函数组件允许用户使用Groovy自定义函数组件,所有的函数组件均使用_main(arg…)作为入口函数,除此之外,用户需要选择该函数的入参变量(全局变量/特征)来完成函数的赋值,选择函数的出参变量(仅全局变量)完成函数的输出值承接。

假如用户配置了如下函数,并且选择一个入参变量feature.loan_user_products,来完成运行时的数据绑定:

1
2
3
4
5
6
7
8
9
10
11
12
int _main(int products) {
for (int p : [8388608, 268435456]) {
if ((products & p) != 0) {
products = products - p;
}
}
if (products == 0) {
return 8;
}

return products;
}

在函数组件执行时,会在上述代码的基础上,补充函数入口的调用:_main(feature.loan_user_products),最终将完善后的脚本,转换为CompiledScript类,通过CompiledScript的eval方法,拿到执行结果。

核心代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public MethodResult visitMethod(String scriptText,
List<FuncArg> methodRealArgs,
Input input) {
// 将[加工后的脚本]编译成CompiledScript
CompiledScript script = loadScript(scriptText);

// 实参与形参绑定
Bindings binding = new SimpleBindings();
for (FuncArg arg : methodRealArgs) {
// fillScriptBindings(入参值,Bindings对象,“feature.loan_user_products”, 入参是否允许为空)
fillScriptBindings(input, binding, arg.getArgName(), arg.getNullable());
}

ScriptContext scriptContext = new SimpleScriptContext();
scriptContext.setBindings(binding, ScriptContext.GLOBAL_SCOPE);
PrintInfoCollector collector = new PrintInfoCollector();
scriptContext.setWriter(collector);

// 执行
Object o = script.eval(scriptContext);

// 收集print info并作为log返回
String log = CollectionUtils.isEmpty(collector.logs) ?
null :
StringUtils.join(collector.logs, "\
");
return new MethodResult()
.setRawResult(o)
.setLog(log);
}

7. 决策流

决策流是一种特殊的组件,它是组件类型之一,却可以使用任意的组件来完成配置,最终形成一个由多种节点组成,按照先后或者分支顺序连接起来的图。

节点的分类:

  • 起始节点

    入度为0,出度为1。

    为每一个决策流的入口节点。

  • 组件节点

    入度与出度均为1。

    • 允许配置除当前组件之外的其他组件。

    • 节点的运行逻辑,实际为节点中组件的运行逻辑;节点中组件的输出即为节点的输出。

    • 决策流的输入、前置节点的输出,均可以被当前节点所使用。

    • 如果决策流的输入或者前置节点的输出,与当前节点的输出重名,当前节点的输出会将之前的数据覆盖(空跑除外)

    • 可以设置中断、空跑等附加属性

      中断:当中断打开,并且组件存在输出,当前节点直接中断当前流以及上层流(如有)的执行

      空跑:当空跑打开,当前组件的输出不会覆盖组件输入值或者前置节点的输出值,当前节点的输出也不会被后置节点所见所使用。

  • 分支节点

    入度为1,出度为n,根据不同的条件,指向不同的节点。

    分为条件分支和A/B-TEST两种

    • 条件分支

      不同的路径上会设置不同的表达式,表达式的配置方式,与规则相同,使用IValue op IValue的方式配置Expression

    • A/B-TEST

      不同的路径会设置分流比例,所有的分流比例加和为100%。

      分流的计算方式,是对当前节点选定变量的运行时数据进行hash并将其映射落在0-1的范围内。

  • 赋值节点

    入度为1,出度为1。

    对特定的变量进行赋值,赋值来源为用户自定义或者从特征中读取。赋值后的变量值作为当前节点的输出,会覆盖决策流的同名输入或者前置节点的同名输出。

  • 终止节点

    入度为1,出度为0。

    为每一个决策流的终止节点,每个决策流只有一个终止节点。

回到最初的决策举例上,用决策流的方式,最终会被配置为:

其中,红色节点被配置为中断打开,若白名单命中或者反欺诈规则集命中时,会直接中断并退出当前流程执行。

三、决策流执行流程

自研的决策引擎被设计为一个单独的运算子系统,对外暴露组件的基础信息,包括组件的ID、版本、组件依赖的入参、组件最终的出参列表;同时接收业务侧指定组件ID和版本的执行请求,异步完成组件执行,并回调业务调用方。

在决策引擎中,不会存储用户信息,也不会查询用户相关的信息,这部分的工作由协作系统——特征引擎来完成。决策引擎中每个组件的运算单元由基础表达式构成,表达式依赖的特征,在运算时与特征引擎实时交互;同时,部分组件依赖模型执行,也会在运行时与模型服务实时交互。

因此决策引擎的系统间调用流程关系大致如下:

业务系统向决策引擎系统提交一笔执行请求,这笔请求在决策引擎落库后,会被记作一条trace记录,traceId会被实时返回给业务系统,业务系统后续可以通过traceId异步查询trace执行结果,并根据trace结果进行不同的业务逻辑处理。

trace状态分为以下几种:

1
2
3
4
5
6
7
8
INIT("I"),        // 落库后的初始化状态,可执行状态
RUN_WAITING("W"), // 特征值已完成收集,可执行状态
RUNNING("R"), // 正在执行中
SUCCEED("S"), // 执行成功,终态
FAILED("F"), // 执行失败,终态
BLOCKED("B"), // 等待特征值完成收集
EXCEPTION("X"), // 执行异常,需要人工介入
CANCELED("C") // 取消,无需执行,终态

trace的调度流程主要分两部分,trace获取与trace执行,前者负责沟通数据库,取出当前待执行trace清单;后者针对每一个待处理的trace完成执行并生成运行结果。

  1. trace获取

    按照trace的创建时间排序,批量获取状态为INIT或者RUN_WAITING的trace,并将trace的状态标记为RUNNING后,提交给TraceExecutor;如果没有符合条件的trace,数据库轮询休眠1s。

  2. trace执行

    以一条trace的执行为例,首先根据trace上的组件信息,按组件ID和版本从组件工厂中拿到唯一的组件,然后对组件传入必要的全局变量参数,执行组件自身的execute方法,输出执行结果并修改数据库的trace状态。

  • 决策流阶段分析

    决策流的节点类型之一为分支节点,分支节点代表出现了两条及以上的互斥路径,在实际执行时只会执行其中一条,因此在分支节点跑出结果之前,考虑到特征的获取时间、特征采集的资费问题、额外的特征采集可能对用户造成的影响(例如人行信息的采集会增加征信查询次数),后置的特征获取不可提前进行,只有当分支节点确定了后续执行路径,才能进行后置节点的特征获取。类似的,如果组件节点标记了中断,中断节点之后的节点是否真正需要执行,是取决于中断节点的输出情况的。所以在这两种情况下,当决策流每遇到中断或者条件分支,自然就会形成一个阶段,当前阶段执行结束后,再确定下一阶段的节点组成。

  • 阶段特征获取完成判断

    阶段确定之后,当前阶段包含的节点、节点依赖的特征与全局变量就可以确定下来,若当前阶段需要的特征缺失特征值,就需要先行准备特征数据。阶段性地、批量地执行特征值获取,可以有效减少和特征引擎的网络I/O次数,也减少由于特征缺失引发的执行中断的次数,加快决策流的整体执行。

  • 特征值获取与trace暂存

    与特征引擎进行交互,如果可以实时获取到特征值,则组件继续执行;如果特征引擎实时接口返回正在处理中,当前trace执行会被中断,trace被标记成BLOCKED状态写回数据库,直到收到特征引擎处理完成的回调,并修改trace状态为RUN_WAITING,等待下一轮trace获取。

  • 组件执行

    当阶段需要的特征值准备好之后,组件按照节点顺序开始执行。节点执行前,先检查当前节点的全局变量和特征数据是否都存在,如果所需的全局变量无法从当前流的入参中获得,也无法从前置节点的输出中获得,并且设置了不允许为空,当前流会执行中断,最终当前trace会被标记为执行失败;如果所需的特征设置了不允许为空,但是特征引擎返回的特征值为空,同样的,也会执行失败并中断流的执行。

    当前阶段顺利执行完成之后,并不会立刻进行下一阶段的特征获取,而是继续尝试执行接下来的节点,直到遇到一个缺失特征值的节点,再从缺失特征值的节点开始,进行下一阶段的特征获取。这样做的原因是,特征是全局不可修改的,前置节点获得的特征,如果足以满足后置节点的特征需求,那么无需进行更多的中断,就可以顺利执行到结束。

    如果节点是组件节点,当前执行就交给对应的组件类执行它自身的execute方法,其中,模型组件实时调用模型服务拿到用户的模型分,而其他组件都是本地执行。

四、系统问题

自研决策引擎系统在22年8月开始正式切全量,迄今已运行约半年时间。在这一段时间内,新系统也逐渐暴露出一些的问题。

1. 冗余组件配置问题

在策略人员使用过程中,为了简化配置流程,通常会抽出一些通用的组件形成一个单独的决策流,比如把所有首贷依赖的模型组件全部配置为一个小的决策流,这样,当其他组件需要使用某一个首贷模型分时,只需要引用这个子决策流即可。

如果存在两个决策流,各自都需要首贷模型分来做决策,可以配置为:

这样配置的优点是,使用者可以将注意力更加倾斜给后面的决策组件配置,当决策组件需要使用模型分时,直接在前置节点引入包含所有模型组件的一个公共决策流即可。如果需要新增一个模型,也只需要在“首贷依赖的模型”中直接新增,所有上层决策流(决策流1和决策流2)都无需做出修改。

但是缺点也很明显,执行决策流1时,由于决策表只依赖模型分A和B,其实只需要前置执行模型A和模型B就可以满足需求,但是现在完整执行了“首贷依赖的模型”这个决策流,那剩下的模型C-E都是额外多执行的组件。这种情况下,不止浪费了执行资源,同时也增加了整体的执行耗时。

显然,如果想要解决这个问题,就需要对用户配置的决策流进行自动简化,让决策流只执行其必须执行的组件。

还是以决策流1为例,原本使用的“首贷依赖的模型”,如果在执行时,先进行后置节点分析,得到“首贷依赖的模型”的必要输出:模型分A与模型分B,进而删除多余的3-5号节点,形成“首贷依赖的模型(副本A)”,并按副本A的结构来执行,那问题就迎刃而解。

理论如此,但是实际上,由于gateway节点的存在、决策流的多层嵌套等问题,如何能在运行时快速并准确的完成分析,目前还没有寻求到合适的算法来解决,这也是我们目前的一大难题。

2. 组件配置异常

组件可以划分为复杂组件和简单组件两类,复杂组件(决策流、规则集)在配置时支持使用其他组件;剩余的其他组件不具备此功能,全部为简单组件。
当简单组件配置时,组件表达式中的变量的集合,就自然成为当前组件的入参;当组件为复杂组件时,入参由子组件入参和出参综合计算得来:

  1. 规则集的入参
  • 等于所有规则的入参的集合。
  1. 决策流的入参
  • 若某一节点的入参,无法从前置节点的输出中获得,则此入参会成为决策流的入参。

以上述提到的决策流2举例,若其输入输出定义如下:

节点 输入 输出
1 - 规则:白名单 feature.mobile var.decision、var.credits
2 - 决策流:首贷依赖的模型 / var.modelScoreA、var.modelScoreB、var.modelScoreC、var.modelScoreD、var.modelScoreE
3 - 决策表:使用模型分A&B var.modelScoreA、var.modelScoreB var.decision、var.credits

可以看到,节点3的入参var.modelScoreA和var.modelScoreB,都可以在节点2的输出中获得,因此决策流2的入参只有feature.mobile.

节点的增删,都会引发决策流的入参变化,如果删除节点2,那决策流2的输入就会变成feature.mobile、modelScoreA、var.modelScoreB三个。很显然,若决策流2发生了这样的修改,对于风控业务侧而言,在提交trace准备执行决策流2时,业务侧是无法上送两个模型分作为入参的,这时候决策流2就会执行失败。

除此之外,执行时还可能会有一些其他的问题,比如组件预先设置了特征为不允许为空,但是实际运行过程中从特征平台拿到的特征值却为空,这时执行也会出现异常,导致trace执行失败。

这些运行时问题,往往在生产配置生效后才会被发现,在新系统启用的前几个月时间内,当策略更新后,经常会出现由于配置异常导致的执行失败,这时候我们的解决方案只能是使用组件的回滚功能,一键回滚到上一可用版本,但是这需要人工来操作;同时,这些错误版本的trace也会被卡住,一直等到新版本上线后,再等待人工介入重新提交。

为了使上面这种组件配置异常问题减少发生,我们在组件配置流程里增加了多种测试方式供策略配置人员提前测试以发现问题,但是这并不能解决根本问题。

最终,我们上线了空跑功能。

空跑,又叫陪跑,在决策流版本升级之后、生效之前,会生成一个空跑版本,对这个版本,我们会提交一批trace预先试跑一遍,如果一定数量下,trace都正常执行结束,则当前版本可以从空跑版本正式变更为决策版本;否则当前版本无法生效,直到策略人员分析完失败原因后修改并重新提交。

空跑版本无法对决策可见,当一笔决策trace执行结束之后,若空跑版本存在,会使用这笔trace的用户信息,发起空跑trace的提交,提交空跑trace的数量取决于当前有多少空跑版本存在。

举例说明一下空跑流程,假设有这样一个组件A,第一次修改后生成的空跑版本记作V1,第二次记作V2,第三次V3,这三个版本都在空跑中未生效。

如果每一笔决策trace,都发起3笔空跑trace——对应V1、V2、V3,虽然空跑trace的执行优先级为低优,理论上不会导致决策trace的处理延迟,但还是会产生资源的浪费,空跑版本越多越严重;同时,考虑到V3版本是基于V2版本修改而来的,V2同样基于V1,如果V3空跑成功的话,V2和V1作为过时的版本也就没有空跑的必要,因此,集中资源优先去跑V3,让更晚提交的版本更早空跑成功上线,是更优的执行方案。但是V2和V1的空跑频率只是降低,而不是完全暂停,这样可以做一个策略兜底:如果V3不幸空跑失败了,V2还有更高的空跑条数“起点”,能尽快跑完空跑条数,实现上线。

最终我们限制,当空跑版本存在时,每一笔决策trace会发生1-2条空跑:

最新的空跑版本(本例中是V3)在空跑失败之前,会固定发生一条空跑;最新之下的所有空跑版本整体共享100%的比例,以哈希结果决定另外一条空跑时发生在哪个版本下。

空跑上线之后,由于组件配置异常导致的线上决策结果延迟的问题已不再出现,配置问题的发现发生在测试和空跑阶段,极大地增强了新策略上线的稳定性。

最后

三代决策引擎的落地,都是阶段发展的需要:业务发展初期,规则集这种硬编码方式实现简单但是已经足够满足需求;随着业务规模的扩大,Drools的引入支持了更加复杂的决策场景;在业务成熟阶段,开发与策略进行了更好的职责分配,决策工具的建设就成为了更高的要求,自研风控引擎系统成为新的方案被落地。

技术无法与业务切割,为了更好的支持业务,决策引擎也会随之成长,我们也期望其最终成为一个更加智能高效的平台。