個人博客:無奈何楊(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 時創建新的 AtomicIntegercounters.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