个人博客:无奈何杨(wnhyang)

个人语雀:wnhyang

共享语雀:在线知识共享

Github:wnhyang - Overview


image

想必大家都有听过或做过职业和性格测试吧,尤其是现在的毕业生,在投了简历之后经常会收到一个什么测评,那些测评真的是又臭又长,做的简直让人崩溃,很多时候都是边骂边做,都什么玩意!?

然而,本篇就由此出发,把整个测评作为一个策略的话,其中每一项都是一条规则,通常每一条规则(问答)需要我们输入一个类似1-9的分数,1和9分别代表两个极端,最终这个策略会结合所有的问答结果计算出我们的性格/职业。这是如何做的呢?其实就是一种分类算法,就拿二维平面直角坐标系举例吧!

如下二维平面直角坐标系下分出了4个区域,性格/职业测评的每条问答可以理解为其中一条经过原点的直线,1-9分别指示两个方向,你的答案最终会是一个由原点出发的n条直线,这n条直线可以绘成一个多边形,而这个多边形就构成了最终结果,长得有点类似雷达图。

image

image

image

当然这只是二维平面直角坐标系的例子,实际上现实往往比这个更复杂,高于三维的我也举不出例子啊🙂‍↔️

总之最后结果绝大多数情况下都会是一个不规则的东西(我实在不知道更高维的该怎么描述),这种测评会取出凸点作为我们的倾向性格/职业。

好吧,关于文章开篇就到这里了,下面就可以正式开始了。不过我还是想讲一个题外话,小时候接触的数学函数(方程)可以很轻易的表示在二维直角坐标系下,随着对于数学的深入探索,出现了越来越多的奇奇怪怪的字母和方程,有人也讲“数学的尽头是字母”🤔然而当我们换一个坐标系,这些是不是也会变个模样呢?所以说有时候换个角度看问题就会有不同收获,或者说换个角度问题就会迎刃而解。

策略

策略组件大致实现如下,编排时会使用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之外的才是风险规则。

规则是否命中处置方式
1truepass
2truereject
3falsesms
4truereview

最坏:未雨绸缪,防患未然

与顺序模式不同,需要执行所有的规则,综合最坏的作为结果。如下表在最坏模式下,最终结果是reject(因为reject>review>pass,这个是配置的)。

规则是否命中处置方式
1truepass
2truereject
3falsesms
4truereview

投票:集体智慧,共同决策

同上,需要执行完所有规则,以命中规则的结果最多的作为最终结果。如下表在投票模式下,结果是pass

规则是否命中处置方式
1truepass
2truereject
3falsereview
4truepass

可以使用这样的计数器,但是考虑到策略集下有不一样的策略集,想必还要再包一层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组件中根据策略不同做不同的事情,但后来放弃了,还是统一放在上下文中吧,且往下看。

image

权重:量化评估,科学分配

一样,需要运行完所有规则,综合权重模式阈值配置得出最终结论。如下表在权重模式下,结果是23+21+20=64,注意!!!这里只是得到一个数字,在策略设置为权重模式后额外还需要配置一个阈值表,拿这个数字去匹配对应的阈值区间得出最终结论。当然这只是个最简单例子,下面将展开,讨论其丰富的应用场景和更灵活的使用方法。

规则是否命中得分
1true23
2true21
3false30
4true20

阈值配置表

得分结果
(-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、设计上下文时实现QLExpressIExpressContext接口,也就不用获取后在塞,直接拿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);
}

注意点

对于像这种可输入的、脚本类的、在系统中运行的,一定要做好安全性校验,避免直接操作系统资源,稍微不注意控制就会有安全漏洞。在保证安全的前提下,再考虑如何优化用户使用体验,如用户需要使用一些系统字段时,在编辑器文本域输入特殊字符(像“@”或“#”),监听到输入后显示候选列表,可以关键词匹配并选择需要的字段,一旦选中,这个将作为一个整体,只能整体操作,就像我们在发邮件,或者聊天时输入“@”一样,另外再做一个内置运算符的提示符,这样编辑公式就更加便捷,且能降低出错率。再进一步就是做一个常用公式库,提示列表中有直接选中就行,剩下的就是填充需要的字段就行。

image

应用场景

比如在做密码登录时,设置了两条规则,一条正向规则y1=f(x1)x1表示最近人脸登录成功次数,其与结果负相关,人脸登录成功次数越多得到的负数越大;一条反向规则y2=f(x2)x2表示最近密码登录失败次数,其与结果正相关,密码登录失败次数越多得到的分数越大,而且保证其“增长率”大于f(x1)

可以大致表示为下面的曲线,最近人脸登录成功次数少时,风险高一些,多时也会存在上限,因为再多也没有意义了;最新密码登录错误次数少时风险低,但密码登录次数越多风险急剧增加,这样的话在整合y=y1+y2=f(x1)+f(x2)后,风险受密码登录错误次数的影响更大。

image

当然将两个公式整合到一块做为一个规则也是可以的,差别就是是否需要独立的规则条件。

条件公式
不合并condition1f(x1)
condition2f(x2)
合并condition1f(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通过分流结束节点只会接收到一个策略,那么此时就不会有冲突,分流到哪个就返回哪个。

当然这块还没想好怎么做,只是一些想法。

image

小结

本来还想分享一下项目进展的,但转眼一看好像写的已经有点多了,那就下次吧!

写在最后

拙作艰辛,字句心血,望诸君垂青,多予支持,不胜感激。


个人博客:无奈何杨(wnhyang)

个人语雀:wnhyang

共享语雀:在线知识共享

Github:wnhyang - Overview


wnhyang
9 声望0 粉丝