LiteFlow决策系统的策略模式,顺序、最坏、投票、权重
个人博客:无奈何杨(wnhyang)
个人语雀:wnhyang
共享语雀:在线知识共享
Github:wnhyang - Overview
想必大家都有听过或做过职业和性格测试吧,尤其是现在的毕业生,在投了简历之后经常会收到一个什么测评,那些测评真的是又臭又长,做的简直让人崩溃,很多时候都是边骂边做,都什么玩意!?
然而,本篇就由此出发,把整个测评作为一个策略的话,其中每一项都是一条规则,通常每一条规则(问答)需要我们输入一个类似1-9的分数,1和9分别代表两个极端,最终这个策略会结合所有的问答结果计算出我们的性格/职业。这是如何做的呢?其实就是一种分类算法,就拿二维平面直角坐标系举例吧!
如下二维平面直角坐标系下分出了4个区域,性格/职业测评的每条问答可以理解为其中一条经过原点的直线,1-9分别指示两个方向,你的答案最终会是一个由原点出发的n
条直线,这n
条直线可以绘成一个多边形,而这个多边形就构成了最终结果,长得有点类似雷达图。
当然这只是二维平面直角坐标系的例子,实际上现实往往比这个更复杂,高于三维的我也举不出例子啊🙂↔️
总之最后结果绝大多数情况下都会是一个不规则的东西(我实在不知道更高维的该怎么描述),这种测评会取出凸点作为我们的倾向性格/职业。
好吧,关于文章开篇就到这里了,下面就可以正式开始了。不过我还是想讲一个题外话,小时候接触的数学函数(方程)可以很轻易的表示在二维直角坐标系下,随着对于数学的深入探索,出现了越来越多的奇奇怪怪的字母和方程,有人也讲“数学的尽头是字母”🤔然而当我们换一个坐标系,这些是不是也会变个模样呢?所以说有时候换个角度看问题就会有不同收获,或者说换个角度问题就会迎刃而解。
策略
策略组件大致实现如下,编排时会使用p_cn.tag("code")
,运行FOR(p_fn).DO(r_cn).BREAK(p_bn);
或FOR(p_fn).parallel(true).DO(r_cn);
,前者适用于顺序模式,其他皆适用于后者。
@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS, nodeId = LFUtil.POLICY_COMMON_NODE, nodeType = NodeTypeEnum.COMMON, nodeName = "策略普通组件")
public void policy(NodeComponent bindCmp) {
PolicyContext policyContext = bindCmp.getContextBean(PolicyContext.class);
PolicyContext.PolicyCtx policy = PolicyConvert.INSTANCE.convert2Ctx(policyMapper.selectByCode(bindCmp.getTag()));
policyContext.addPolicy(policy.getCode(), policy);
log.info("当前策略(code:{}, name:{}, code:{})", policy.getCode(), policy.getName(), policy.getCode());
if (PolicyMode.ORDER.equals(policy.getMode())) {
bindCmp.invoke2Resp(LFUtil.P_F, policy.getCode());
} else {
bindCmp.invoke2Resp(LFUtil.P_FP, policy.getCode());
}
}
循环次数组件p_fn
如下,查询策略下的所有规则(规则还没做版本控制,后面再改),返回规则列表大小。
@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_FOR, nodeId = LFUtil.POLICY_FOR_NODE, nodeType = NodeTypeEnum.FOR, nodeName = "策略for组件")
public int policyFor(NodeComponent bindCmp) {
PolicyContext policyContext = bindCmp.getContextBean(PolicyContext.class);
String policyCode = bindCmp.getSubChainReqData();
List<PolicyContext.RuleCtx> ruleList = RuleConvert.INSTANCE.convert2Ctx(ruleMapper.selectByPolicyCode(policyCode));
policyContext.addRuleList(policyCode, ruleList);
return ruleList.size();
}
循环中断组件p_bn
如下,当策略上下文中有命中风险规则时就可以停止循环了。
@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS_BOOLEAN, nodeId = LFUtil.POLICY_BREAK_NODE, nodeType = NodeTypeEnum.BOOLEAN, nodeName = "策略break组件")
public boolean policyBreak(NodeComponent bindCmp) {
PolicyContext policyContext = bindCmp.getContextBean(PolicyContext.class);
String policyCode = bindCmp.getSubChainReqData();
return policyContext.isHitRisk(policyCode);
}
另外在使用异步循环编排时需要注意并发操作问题,尤其是对上下文的操作。
剩下的规则组件r_cn
和策略上下文PolicyContext
请往下看。
顺序:按部就班、循序渐进
顺序模式是最好理解,就是顺序运行策略下的所有规则,默认在第一条设定的风险规则触发后结束,其实更准确的叫法应该是首次。如下表在顺序模式下执行,到规则2就结束了,因为默认pass
之外的才是风险规则。
规则 | 是否命中 | 处置方式 |
---|---|---|
1 | true | pass |
2 | true | reject |
3 | false | sms |
4 | true | review |
最坏:未雨绸缪,防患未然
与顺序模式不同,需要执行所有的规则,综合最坏的作为结果。如下表在最坏模式下,最终结果是reject
(因为reject
>review
>pass
,这个是配置的)。
规则 | 是否命中 | 处置方式 |
---|---|---|
1 | true | pass |
2 | true | reject |
3 | false | sms |
4 | true | review |
投票:集体智慧,共同决策
同上,需要执行完所有规则,以命中规则的结果最多的作为最终结果。如下表在投票模式下,结果是pass
。
规则 | 是否命中 | 处置方式 |
---|---|---|
1 | true | pass |
2 | true | reject |
3 | false | review |
4 | true | pass |
可以使用这样的计数器,但是考虑到策略集下有不一样的策略集,想必还要再包一层Map<String,ConcurrentHashMap<String, AtomicInteger>>
,以策略code
作为键。
private final ConcurrentHashMap<String, AtomicInteger> counters = new ConcurrentHashMap<>();
/**
* 增加指定 key 的计数值。
* 如果 key 不存在,则初始化为 1;如果存在,则将当前值加 1。
*/
public void increment(String key) {
// 使用 computeIfAbsent 方法来确保只在第一次遇到该 key 时创建新的 AtomicInteger
counters.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet();
}
/**
* 获取指定 key 的当前计数值。
* 如果 key 不存在,则返回 0。
*/
public int get(String key) {
// 获取指定 key 的 AtomicInteger,并调用 get() 方法获取其值
AtomicInteger counter = counters.get(key);
return (counter != null) ? counter.get() : 0;
}
本来考虑的是在规则True
组件中根据策略不同做不同的事情,但后来放弃了,还是统一放在上下文中吧,且往下看。
权重:量化评估,科学分配
一样,需要运行完所有规则,综合权重模式阈值配置得出最终结论。如下表在权重模式下,结果是23
+21
+20
=64
,注意!!!这里只是得到一个数字,在策略设置为权重模式后额外还需要配置一个阈值表,拿这个数字去匹配对应的阈值区间得出最终结论。当然这只是个最简单例子,下面将展开,讨论其丰富的应用场景和更灵活的使用方法。
规则 | 是否命中 | 得分 |
---|---|---|
1 | true | 23 |
2 | true | 21 |
3 | false | 30 |
4 | true | 20 |
阈值配置表
得分 | 结果 |
---|---|
(-214,20] | pass |
(20,45] | review |
(45,70] | sms |
(70,900) | reject |
设计过程
阶段一:规则增加简单的分数属性,命中时累计就好,如:规则1命中时+10,规则2命中时-2,这样最为简单,也因此适用场景最少,最不灵活。
阶段二:固定公式计算,如下规则附加这些属性,在规则命中时计算一下,这种只适合简单的线性相关的规则计算。
/**
* 计算公式为:base + al aOpType(加/减/乘/除) ${value}(取决于opType类型,是指标还是字段),结果范围[lowerLimit,upperLimit]
*
* @author wnhyang
* @date 2024/12/9
**/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Weight {
private Double base; // 基础权重
private Double al; // 权重调整因子
private String aOpType; // 操作类型:加/减/乘/除
private String opType; // 指标还是字段
private String value; // 指标名称或字段名称
private Double upperLimit; // 上限
private Double lowerLimit; // 下限
/**
* 计算最终权重
*
* @param trueValue 实际值,当 opType 为 "zb" (指标) 时使用
* @return 最终计算出的权重
*/
public double compute(double trueValue) {
if (al == null || trueValue == 0 && ("/".equals(aOpType))) {
throw new IllegalArgumentException("Invalid parameters for computation.");
}
double adjustment = 0;
switch (aOpType) {
case "+":
adjustment = al + trueValue;
break;
case "-":
adjustment = al - trueValue;
break;
case "*":
adjustment = al * trueValue;
break;
case "/":
adjustment = al / trueValue;
break;
default:
throw new UnsupportedOperationException("Unsupported operation type: " + aOpType);
}
double result = base + adjustment;
return Math.min(upperLimit, Math.max(lowerLimit, result));
}
public static void main(String[] args) {
Weight weight = new Weight(10.41, -2.154, "*", "zb", "count", 5000.545, -56.654);
double compute = weight.compute(25.21);
System.out.println("Computed weight: " + compute);
}
}
阶段三:灵活公式,使用QLExpress
实现。如下讲计算公式作为规则的一个属性,通过getOutVarNames
获取需要用到的外部变量名,在运行表达式之前通过LiteFlow
上下文取值塞到QLExpress
的上下文中。
当然还有可以优化的地方,1、设计上下文时实现QLExpress
的IExpressContext
接口,也就不用获取后在塞,直接拿LiteFlow
上下文作为QLExpress
上下文用就行;2、还有就是min(upperLimit, max({}, lowerLimit))
是否要放在表达式中,其实也是没必要,可以放在表达式计算完成之后嘛。3、是否要计算平均值,现在是权重之和,是否要做加权平均呢?4、等等
@Test
public void test3() throws Exception {
ExpressRunner runner = new ExpressRunner();
DefaultContext<String, Object> context = new DefaultContext<>();
String fun = "base + al * value";
String express = StrUtil.format("min(upperLimit, max({}, lowerLimit))", fun);
log.info(express);
String[] outVarNames = runner.getOutVarNames(express);
log.info(Arrays.toString(outVarNames));
context.put("base", 45.434);
context.put("al", 3.352);
context.put("value", 24.3264);
context.put("lowerLimit", -35.342);
context.put("upperLimit", 3463.57);
Object r = runner.execute(express, context, null, true, false);
log.info("{}", r);
}
注意点
对于像这种可输入的、脚本类的、在系统中运行的,一定要做好安全性校验,避免直接操作系统资源,稍微不注意控制就会有安全漏洞。在保证安全的前提下,再考虑如何优化用户使用体验,如用户需要使用一些系统字段时,在编辑器文本域输入特殊字符(像“@”或“#”),监听到输入后显示候选列表,可以关键词匹配并选择需要的字段,一旦选中,这个将作为一个整体,只能整体操作,就像我们在发邮件,或者聊天时输入“@”一样,另外再做一个内置运算符的提示符,这样编辑公式就更加便捷,且能降低出错率。再进一步就是做一个常用公式库,提示列表中有直接选中就行,剩下的就是填充需要的字段就行。
应用场景
比如在做密码登录时,设置了两条规则,一条正向规则y1=f(x1)
,x1
表示最近人脸登录成功次数,其与结果负相关,人脸登录成功次数越多得到的负数越大;一条反向规则y2=f(x2)
,x2
表示最近密码登录失败次数,其与结果正相关,密码登录失败次数越多得到的分数越大,而且保证其“增长率”大于f(x1)
。
可以大致表示为下面的曲线,最近人脸登录成功次数少时,风险高一些,多时也会存在上限,因为再多也没有意义了;最新密码登录错误次数少时风险低,但密码登录次数越多风险急剧增加,这样的话在整合y=y1+y2=f(x1)+f(x2)
后,风险受密码登录错误次数的影响更大。
当然将两个公式整合到一块做为一个规则也是可以的,差别就是是否需要独立的规则条件。
条件 | 公式 | |
---|---|---|
不合并 | condition1 | f(x1) |
condition2 | f(x2) | |
合并 | condition1 | f(x1)+f(x2) |
还有就是在信贷计算信用时,需要计算收入稳定性+信用历史+就业情况+债务水平+资产情况的场景时,当然这依赖多方数据,而且一般的信用评估不是简单的规则配置能解决的。
模型:智能学习,进化升级
最终都将到这一步的,虽然现在做的项目中还没有集成模型,但是我之后一定会做。先立flag
嘛,实现不是实现另说吧🧐可别连立flag
的勇气都没有了!
关于模型我也不是专业的,也是仅有一点点了解。我一直认为学习能力、看待、解决问题的思想是最重要的,特别是我看了几个关于机器学习的视频后,虽然其中很多的公式我都不懂,但是能理解到其看待、解决问题的思路方法,很受益,很有启发。
规则
以下分别是规则组件(包含isAccess
实现)、命中规则、未命中规则组件。在规则命中时额外计算规则配置的权重表达式。
@LiteflowMethod(value = LiteFlowMethodEnum.IS_ACCESS, nodeId = LFUtil.RULE_COMMON_NODE, nodeType = NodeTypeEnum.COMMON)
public boolean ruleAccess(NodeComponent bindCmp) {
String policyCode = bindCmp.getSubChainReqData();
int index = bindCmp.getLoopIndex();
PolicyContext policyContext = bindCmp.getContextBean(PolicyContext.class);
PolicyContext.RuleCtx rule = policyContext.getRule(policyCode, index);
return !RuleStatus.OFF.equals(rule.getStatus());
}
@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS, nodeId = LFUtil.RULE_COMMON_NODE, nodeType = NodeTypeEnum.COMMON, nodeName = "规则普通组件")
public void rulProcess(NodeComponent bindCmp) {
String policyCode = bindCmp.getSubChainReqData();
int index = bindCmp.getLoopIndex();
PolicyContext policyContext = bindCmp.getContextBean(PolicyContext.class);
PolicyContext.RuleCtx rule = policyContext.getRule(policyCode, index);
bindCmp.invoke2Resp(StrUtil.format(LFUtil.RULE_CHAIN, rule.getCode()), rule);
}
@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS, nodeId = LFUtil.RULE_TRUE, nodeType = NodeTypeEnum.COMMON, nodeName = "规则true组件")
public void ruleTrue(NodeComponent bindCmp) {
PolicyContext policyContext = bindCmp.getContextBean(PolicyContext.class);
PolicyContext.RuleCtx rule = bindCmp.getSubChainReqData();
log.info("命中规则(name:{}, code:{})", rule.getName(), rule.getCode());
if (RuleStatus.MOCK.equals(rule.getStatus())) {
policyContext.addHitMockRuleVO(rule.getPolicyCode(), rule);
} else {
// 权重
if (PolicyMode.WEIGHT.equals(policyContext.getPolicy(rule.getPolicyCode()).getMode())) {
try {
Double value = (Double) QLExpressUtil.execute(rule.getExpress(), bindCmp.getContextBean(FieldContext.class));
rule.setExpressValue(value);
} catch (Exception e) {
log.error("规则表达式执行异常", e);
}
}
policyContext.addHitRuleVO(rule.getPolicyCode(), rule);
}
}
@LiteflowMethod(value = LiteFlowMethodEnum.PROCESS, nodeId = LFUtil.RULE_FALSE, nodeType = NodeTypeEnum.COMMON, nodeName = "规则false组件")
public void ruleFalse(NodeComponent bindCmp) {
log.info("规则未命中");
}
策略上下文
直接上代码了。
/**
* @author wnhyang
* @date 2024/4/3
**/
public class PolicyContext {
/**
* 处置方式集合
*/
private final Map<String, DisposalCtx> disposalMap = new ConcurrentHashMap<>();
/**
* 策略集
*/
private PolicySetCtx policySet;
/**
* 初始化
*
* @param disposalCtxList 处置方式集合
* @param policySet 策略集
*/
public void init(List<DisposalCtx> disposalCtxList, PolicySetCtx policySet) {
for (DisposalCtx disposalCtx : disposalCtxList) {
disposalMap.put(disposalCtx.getCode(), disposalCtx);
}
this.policySet = policySet;
}
/**
* 策略集合
*/
private final Map<String, PolicyCtx> policyMap = new ConcurrentHashMap<>();
/**
* 添加策略
*
* @param policyCode 策略code
* @param policy 策略
*/
public void addPolicy(String policyCode, PolicyCtx policy) {
policyMap.put(policyCode, policy);
}
/**
* 获取策略
*
* @param policyCode 策略code
* @return 策略
*/
public PolicyCtx getPolicy(String policyCode) {
return policyMap.get(policyCode);
}
/**
* 规则集合
*/
private final Map<String, List<RuleCtx>> ruleListMap = new ConcurrentHashMap<>();
/**
* 添加规则集合
*
* @param policyCode 策略code
* @param ruleList 规则列表
*/
public void addRuleList(String policyCode, List<RuleCtx> ruleList) {
ruleListMap.put(policyCode, ruleList);
}
/**
* 获取规则
*
* @param policyCode 策略code
* @param index 规则索引
* @return 规则
*/
public RuleCtx getRule(String policyCode, int index) {
return ruleListMap.get(policyCode).get(index);
}
/**
* 命中规则集合
*/
private final Map<String, List<RuleCtx>> hitRuleListMap = new ConcurrentHashMap<>();
/**
* 添加命中规则
*
* @param policyCode 策略code
* @param rule 规则
*/
public void addHitRuleVO(String policyCode, RuleCtx rule) {
if (!hitRuleListMap.containsKey(policyCode)) {
hitRuleListMap.put(policyCode, CollUtil.newArrayList());
}
hitRuleListMap.get(policyCode).add(rule);
}
/**
* 是否命中风险规则
*
* @param policyCode 策略code
* @return true/false
*/
public boolean isHitRisk(String policyCode) {
if (CollUtil.isNotEmpty(hitRuleListMap.get(policyCode))) {
for (RuleCtx ruleCtx : hitRuleListMap.get(policyCode)) {
if (!DisposalConstant.PASS_CODE.equals(ruleCtx.getDisposalCode())) {
return true;
}
}
}
return false;
}
/**
* 命中模拟规则集合
*/
private final Map<String, List<RuleCtx>> hitMockRuleListMap = new ConcurrentHashMap<>();
/**
* 添加命中模拟规则
*
* @param policyCode 策略code
* @param rule 规则
*/
public void addHitMockRuleVO(String policyCode, RuleCtx rule) {
if (!hitMockRuleListMap.containsKey(policyCode)) {
hitMockRuleListMap.put(policyCode, CollUtil.newArrayList());
}
hitMockRuleListMap.get(policyCode).add(rule);
}
/**
* 转策略集结果
*
* @return 策略集结果
*/
public PolicySetResult convert() {
PolicySetResult policySetResult = new PolicySetResult(policySet.getName(), policySet.getCode(), policySet.getChain(), policySet.getVersion());
for (Map.Entry<String, PolicyCtx> entry : policyMap.entrySet()) {
PolicyCtx policy = entry.getValue();
PolicyResult policyResult = new PolicyResult(policy.getName(), policy.getCode(), policy.getMode());
// 最坏
String maxDisposalCode = DisposalConstant.PASS_CODE;
int maxGrade = Integer.MIN_VALUE;
// 投票
Map<String, Integer> votes = new HashMap<>();
// 权重
double weight = 0.0;
List<RuleCtx> ruleList = hitRuleListMap.get(policy.getCode());
if (CollUtil.isNotEmpty(ruleList)) {
for (RuleCtx rule : ruleList) {
if (PolicyMode.VOTE.equals(policy.getMode())) {
// 投票
votes.put(rule.getDisposalCode(), votes.getOrDefault(rule.getDisposalCode(), 0) + 1);
} else if (PolicyMode.WEIGHT.equals(policy.getMode())) {
// 权重
weight += rule.getExpressValue();
}
RuleResult ruleResult = new RuleResult(rule.getName(), rule.getCode(), rule.getExpress());
// 最坏和顺序
DisposalCtx disposal = disposalMap.get(rule.getDisposalCode());
if (null != disposal) {
ruleResult.setDisposalName(disposal.getName());
ruleResult.setDisposalCode(disposal.getCode());
if (disposal.getGrade() > maxGrade) {
maxGrade = disposal.getGrade();
maxDisposalCode = disposal.getCode();
}
}
// 模拟/正式规则区分开
if (RuleStatus.MOCK.equals(rule.getStatus())) {
policyResult.addMockRuleResult(ruleResult);
} else {
policyResult.addRuleResult(ruleResult);
}
}
}
if (PolicyMode.VOTE.equals(policy.getMode())) {
String maxVoteDisposalCode = DisposalConstant.PASS_CODE;
int maxVoteCount = Integer.MIN_VALUE;
for (Map.Entry<String, Integer> entry1 : votes.entrySet()) {
if (entry1.getValue() > maxVoteCount) {
maxVoteCount = entry1.getValue();
maxVoteDisposalCode = entry1.getKey();
}
}
policyResult.setDisposalName(disposalMap.get(maxVoteDisposalCode).getName());
policyResult.setDisposalCode(maxVoteDisposalCode);
} else if (PolicyMode.WEIGHT.equals(policy.getMode())) {
List<Th> thList = policy.getThList();
// 排序
thList.sort(Comparator.comparing(Th::getScore));
for (Th th : thList) {
if (weight <= th.getScore()) {
policyResult.setDisposalName(disposalMap.get(th.getCode()).getName());
policyResult.setDisposalCode(th.getCode());
break;
}
}
} else {
policyResult.setDisposalName(disposalMap.get(maxDisposalCode).getName());
policyResult.setDisposalCode(maxDisposalCode);
}
policySetResult.addPolicyResult(policyResult);
}
// TODO 入度大于1?考虑投票、加权平均等方法:不考虑
policySetResult.setDisposalName(DisposalConstant.PASS_NAME);
policySetResult.setDisposalCode(DisposalConstant.PASS_CODE);
return policySetResult;
}
}
策略集
策略集是用来编排策略的,即前面的策略组件p_cn.tag("code")
,从前面已知策略会有结果的,那么编排他们的策略集如何取这个结果呢?
这就要考虑如何设计策略集的编排了,两种情况,入度为1,入度大于1。
如下,图1并行运行策略1、2,并最终都返回到结束节点,这时就要考虑如何处理策略1、2的结果了,投票?加权平均?随机选一个?还是其他什么方法?图2通过分流结束节点只会接收到一个策略,那么此时就不会有冲突,分流到哪个就返回哪个。
当然这块还没想好怎么做,只是一些想法。
小结
本来还想分享一下项目进展的,但转眼一看好像写的已经有点多了,那就下次吧!
写在最后
拙作艰辛,字句心血,望诸君垂青,多予支持,不胜感激。
个人博客:无奈何杨(wnhyang)
个人语雀:wnhyang
共享语雀:在线知识共享
Github:wnhyang - Overview