内容转自微信公众号,技术岁月 techyears,关注第一时间获取最新文章
I.决策引擎系统介绍
风控决策引擎系统是在大数据支撑下,根据行业专家经验制定规则策略、以及机器学习/深度学习/AI领域建立的模型运算,对当前的业务风险进行全面的评估,并给出决策结果的一套系统。
决策引擎,常用于金融反欺诈、金融信审等互金领域,由于黑产、羊毛党行业的盛行,风控决策引擎在电商、支付、游戏、社交等领域也有了长足的发展,刷单、套现、作弊,凡是和钱相关的业务都离不开风控决策引擎系统的支持保障。决策引擎和规则引擎比较接近(严格说决策引擎包含规则引擎,之前也有叫专家系统,推理引擎),它实现了业务决策与程序代码的分离。
一套商业决策引擎系统动辄百万而且需要不断加钱定制,大多数企业最终仍会走上自研道路,市场上有些开源规则引擎项目可参考,比较出名的开源规则引擎有drools、urule,都是基于Rete算法,都是基于java代码实现,一些企业会进行二次开发落地生产。而这类引擎功能强大但也比较“笨重”,易用性以及定制性并不够好,对其他语言栈二次开发困难。今天我们对决策引擎进行抽象建模,一步步打造一套简单实用的实现,这里会基于golang语言进行实现。
关于如何实现决策引擎的文章市面极少见,实践生产落地的经验分享也基本没有。我会结合工作实践及个人思考,从业务抽象建模,产品逻辑规划以及最终技术架构和代码实现等方面给出全方位的解决方案。
II.规则和决策抽象建模
举例说明什么是规则和决策:
“如果年龄小于18岁,那么就拒绝处理”
对这条规则进行抽象建模成条件表达式:特征 (年龄) 运算符(小于) 阈值(18) ---> 触发结果(拒绝)
这里产生几个概念:特征feature、运算符operator、阈值value,这三要素构成条件表达式condition,加上触发结果decision****,组成了规则rule的基础元素。
进一步举例: “如果年龄小于18岁或年龄大于50岁,那么就拒绝处理”
条件表达式1:特征 (年龄) 运算符(小于) 阈值(18)
条件表达式2:特征 (年龄) 运算符(大于) 阈值(50)
逻辑关系:(或)
触发结果:(拒绝) 任何一个为false即为拒绝
这里多了逻辑关系,规则可由多个条件表达式组成,对表达式结果再进行逻辑运算。
再举个例子:
“如果职业是学生,那么就拒绝处理”
条件表达式:特征(职业) 运算符(等于) 阈值(学生)触发结果(拒绝)
其他可选阈值:老师、工人、农民、程序员
“如果订单返回结果中包含exception字样,那么就异常处理“
条件表达式:特征(订单返回结果)运算符(contain) 阈值(exception)触发结果 (异常)
这里有了不同的特征类型,一般特征类型总结如下:
数值型,对应运算符可以有 >、<、=、>=、<=、==、!=,值必须为数字。
枚举型,对应运算符只有==,值为字符串数组[...]{"学生","老师","工人","农民","程序员"}
字符串型,对应运算符有= 、 != 、like 、in 、contain, 值为字符串或字符串数组。
触发结果:可以为“通过”、“拒绝”、“记录”、“告警”、“异常”等,任意自定义结果。
对于一些名单类特征,比如规则是“命中黑名单则触发拒绝”,将命中结果抽象为枚举型特征,对应条件表达式就是:命中黑名单 等于 true/false。
这里做个总结:一条规则的执行,先通过数据(身份证号)计算出特征(年龄),然后带入条件表达式(年龄<18)计算,并对多个表达式结果做逻辑运算,最终根据逻辑运算决定是否触发结果。
III.对规则代码实现
1.直接硬编码实现
var result string
const (
PASS = "pass"
REJECT = "reject"
)
func rule1(age int) {
if age < 18 || age > 50 { //18,50 can be stored in config file
result = REJECT
}
}
func rule2(hitBlacklist bool) {
if hitBlacklist {
result = REJECT
}
}
func main() {
rule1(19) //feature data get from idcard
rule2(false) //feature data get from db
fmt.Println(result)
}
硬编码实现,规则迭代成本高,不管是要改阈值,还是增减规则或调整表达式,都需要修改代码对程序进行发布,规则较多时可维护性极差,调整时效变差,出错概率增加。
那么如何把规则抽离出来,通过程序自动化解析,实现不用开发维护程序即可调整?这里通过自定义DSL,并实现一套DSL解析引擎。
2.自定义DSL实现
DSL是什么?全称是Domain Specific Language,领域特定语言。举个例子SQL就是数据库领域的交互语言,它定义了一套标准语法,各大数据库厂商(如mysql、oracle)对其进行解析实现,任何人都可通过编写SQL实现与数据库的交互。类似还有正则表达式、HTML&CSS等均形成了自己的语法标准。
那么为了完成规则引擎,我们也实现一套自定义DSL语法,并对其解析,通过DSL语法实现与规则引擎的交互,先参考看看drools的DSL语法长什么样?
rule "rule1"
when
( type == "cash", price == 5 ) || ( type == "card", price <= 10 )
then
rs = "pass"
end
可以看出基本类似一个简版编程语言,对该DSL进行解析的难度还是比较高的,而drools的rete算法也比较复杂。这里我们根据业务做个简单化抽象建模,利用yaml格式生成一个具有风控行业语义的自定义DSL。
rule:
rule_id: 139
rule_name: "test2"
conditions:
- condition:
feature: feature2
operator: LT
value: 18
- condition:
feature: feature3
operator: GT
value: 50
logic: OR
depends: [feature2,feature3]
decision: reject
定义了DSL语法格式,接下来就对其进行解析,先将yaml格式加载并转成struct。
type Rule struct {
Conditions []Condition `yaml:"conditions,flow"`
RuleId string `yaml:"rule_id"`
RuleName string `yaml:"rule_name"`
Logic string `yaml:"logic"`
Decision string `yaml:"decision"`
Depends []string `yaml:"depends"`
}
type Condition struct {
Feature string `yaml:"feature"`
Operator string `yaml:"operator"`
Value interface{} `yaml:"value"`
}
//load dsl from db or file
func loadDsl() *Dsl {
dsl := new(Dsl)
yamlFile, err := ioutil.ReadFile("ruleset.yaml")
if err != nil {
panic(err)
}
err = yaml.Unmarshal(yamlFile, dsl)
if err != nil {
panic(err)
}
return dsl
}
对rule结构体进行解析,rule包含condition,循环对condition进行运算,然后将所有结果根据rule.Logic再进行逻辑运算,最终结果为true则输出触发结果rule.Decision,false则输出空值。
//parse rule
func (dsl *Dsl) parseRule(rule *Rule) string {
var conditionRs = make([]bool, 0)
depends := getDepends(rule.Depends)
for _, condition := range rule.Conditions {
if data, ok := depends[condition.Feature]; ok {
conditionRs = append(conditionRs, ConditionExpression(condition.Operator, data, condition.Value))
}
}
logicRs := LogicExpression(conditionRs, rule.Logic)
if logicRs {
return rule.Decision
} else {
return ""
}
}
其中条件表达式如何解析呢?
"data condition.Operator condition.Value" 如果是python,php直接用eval即可实现,但golang没有这么好使的函数。
这时候“编译原理”可以登场了,将表达式转成AST抽象语法树,进行语法解析,利用栈计算的方式执行,这里推荐使用github开源库govaluate,已帮我们实现了一些逻辑运算,可以直接使用。
var operatorMap = map[string]string{
"GT": ">",
"LT": "<",
"GE": ">=",
"LE": "<=",
"EQ": "==",
"NEQ": "!=",
}
//condition expression:feautre value, operator ,threshold value
func ConditionExpression(operator string, feature interface{}, value interface{}) bool {
var params = make(map[string]interface{})
params["feature"] = feature
params["value"] = value
var expr *govaluate.EvaluableExpression
if _, ok := operatorMap[operator]; !ok {
panic("not support operator")
}
expr, _ = govaluate.NewEvaluableExpression(fmt.Sprintf("feature %s value", operatorMap[operator]))
eval, err := expr.Evaluate(params)
if err != nil {
panic(err)
}
if result, ok := eval.(bool); ok {
return result
} else {
panic("convert error")
}
}
条件表达式依赖的特征数据怎么来?
这里通过getDepends(rule.Depends),在rule中冗余了depends字段,存储规则中所有条件表达式需要的特征集合,getDepends函数实现外部url/rpc/db通讯调用以及计算特征逻辑。
获取特征可以在需要时实时调用计算,也可以提前做预加载预计算,实际上这部分可以抽象成单独系统平台(如外部数据平台、特征引擎),与决策引擎配合使用,后续文章中会有介绍。
需要对多个表达式结果根据rule.Logic再做一次运算,还是使用govaluate进行与或运算。
var logicMap = map[string]string{
"OR": "||",
"AND": "&&",
}
//use logic for all the condtions result
func LogicExpression(result []bool, logic string) bool {
resultLen := len(result)
if resultLen == 0 {
return false
}
if resultLen == 1 {
return result[0]
}
var exprStr string
for i := 0; i < resultLen; i++ {
exprStr += fmt.Sprintf(" %t", result[i])
if i != (resultLen - 1) {
exprStr += fmt.Sprintf(" %s", logicMap[logic])
}
}
expr, _ := govaluate.NewEvaluableExpression(exprStr)
eval, err := expr.Evaluate(nil)
if err != nil {
panic(err)
}
if result, ok := eval.(bool); ok {
return result
} else {
panic("convert error")
}
}
至此,就实现了规则的完整DSL表述和解析,那么如果多条规则在一起可以组成一个规则****集。那么规则集有什么用呢?
规则集是对一组规则的集合封装。根据业务逻辑不同,规则集有不同的规则组合策略。
规则集中只要一个规则命中拒绝结果即中断退出。这种方式对规则进行顺序执行。
规则集中所有规则要全部执行一遍。这种方式可并发执行规则,可能会命中多种不同结果,设定结果优先级,输出优先级最高的结果(如拒绝 > 记录 > 通过)
ruleset
- rule:
rule_id: 129
rule_name: "test1"
conditions:
- condition:
feature: feature1
operator: GT
value: 50
logic: AND
depends: [feature1]
decision: reject
- rule:
rule_id: 139
rule_name: "test2"
conditions:
- condition:
feature: feature2
operator: LT
value: 18
- condition:
feature: feature3
operator: GT
value: 50
logic: OR
depends: [feature2,feature3]
decision: reject
上面就是一个完整规则集DSL,至此调整规则只要修改yaml内容即可,做到了规则调整不再依赖程序变更。可以看到最终解析执行结果如下:
但这种方式对风控分析师来说还是比较困难,编写DSL也容易出错,那么有没有一种办法直接赋能业务,让他们做到可以直接可视化、傻瓜化调整?
3.实现可视化规则配置后台
设计实现一个可视化配置后台。添加规则设计如下:
特征需要预先加工好并初始相关数据。这里只能选择已有特征,运算符根据特征类型选择,如数值型有大于、小于、等于...,枚举的运算符只有等于,阈值根据特征类型是数值或字符串可以自己手工输入,是枚举型只能在规定集合中选择,这些都是通过特征加工及初始化特征来实现。
规则集添加配置如下:
使用mysql数据库进行数据添加变更存储,具体数据表实现比较简单。
ruleset表:id,name
rule表:id,name,decision,ruleset_id
condition表:id,name,feature,operator,value,rule_id
feature表:id,name,type,support_operator,support_value
至此,规则变更可通过后台可视化修改,然后生成相应yaml格式,通过规则引擎解析。关于数据库表数据如何生成yaml格式可直接参考最下方代码github链接。
4.其他考虑
4.1 规则内多个条件表达式有复杂逻辑关系如何实现
对应规则中更复杂的逻辑组合实现,可以对表达式condition依次命名为A,B,C,D,E,然后根据rule.logic做与或逻辑计算。
logic= (A or B) AND (C or D) AND E
通过抽象后,利用govaluate "(A || B) && (C ||D) &&E"实现起来也就比较简单了。
后台界面设计可以做成如下情况:
4.2 特征衍生及特征逻辑计算
当前对于条件表达式的抽象比较简单,只有3个元素,如果特征有逻辑+-*/甚至自定义函数的计算呢?如下表达式:
feature * 1.5 +3 > 5
通过govaluate 可以实现,但换个思路,把特征的加工放到特征引擎中,那么获取的特征数据就是计算好的结果, feature_new=(feature * 1.5 +3) ,计算表达式feature_new > 5。合理拆分系统功能边界,最大程度简化表达式,保持单一原则。
4.3 如果获取特征失败或出现空值怎么办
要根据业务场景具体分析,有的业务对数据结果有强依赖要求,获取特征失败(重试后仍失败),就进入熔断状态,等待恢复后重新执行。有的则会将表达式直接设为true(命中)或false(忽略)。
获取特征出现空值可能原因:请求特征引擎超时或出错失败;未获取到数据(本来就不存在);获取到零值数据(有些特征用0表达某种case),需要根据业务场景做出相应的处理。
4.4 自定义字段扩充dsl
上述DSL中,rule只定义id,condition,logic,rule_name,decision几个元素,根据不同业务场景可以自定义元素并进行扩充,如可加入命中产生冻结期,特征获取失败或出现空值校验也可以作为扩充字段。
ruleset可以增加策略选择,命中即退出 还是 执行全部规则,增加规则集分类和标签,方便不同业务场景维护。
还可以增加feature结构,对特征进行约束和字段补充。
IV.实现决策引擎
到这里,只是实现了决策引擎的规则部分,接下来会对规则集进行流程编排,类似BPMN规范的流程图,这里叫决策流,流程引擎。下一篇会重点介绍如何实现决策流,以及决策流上除了规则集节点外,还会引入条件节点,冠军挑战者节点(abtest),还有决策树、决策矩阵,后续如何执行模型模型引擎,热部署,监控,实时特征等内容。
内容转自微信公众号,技术岁月 techyears,关注第一时间获取最新文章
文章相关代码实现:
https://github.com/skyhackvip/risk_engine