本文最初發佈於KFive Team公衆號,原始連接:https://mp.weixin.qq.com/s/mNfsPxdwsiHMkb65AcS8dAjavascript
在開始本文的論述以前,先講一些背景知識。前端
百度搜索結果由一個個卡片構成,全部卡片都是獨立開發維護的代碼模塊。因爲歷史積累致使這些卡片數量龐大(2000+),存在不少重複或者類似的卡片。這些卡片會嚴重拖累搜索總體的迭代效率,而且給卡片管理和維護增長了不少成本。因此咱們想要對全部卡片統一梳理,但願能作到卡片的收斂和歸一,減輕歷史包袱,輕裝上陣。java
因爲卡片數量龐大,一個一我的工確認顯然是不現實的,怎麼才能更高效地開展這項工做是一個棘手的問題。node
目前搜索卡片都採用了組件化的開發方式,那麼每一個卡片的結構其實能夠用一個組件樹的結構來表示。咱們的思路就是對這些組件樹進行類似度分析,最後將類似度高的組件樹歸爲一類,那麼這一類類似度高的模板就是能夠複用和歸一化的。接下來具體介紹一下實現方法。算法
卡片類似度計算
卡片解析成組件樹
藉助編譯工具或者框架自己提供的 compiler
方法,能夠很容易的將卡片template
代碼轉化爲 AST 數據,AST 自己就是一個樹形結構。咱們拿一個卡片 A 舉例,卡片 A 對應的 AST 對象以下所示:前端工程師
{ type: 1, tag: 'c-aladdin', attrsList: [ { name: ':title', value: 'title' }, { name: 'url', value: 'javascript:void(0)' }, { name: 'hide-footer', value: '' }, { name: '@title-click', value: 'toggleTab' }, { name: 'title-arrow', value: '' }, { name: 'title-feedback', value: '' } ], attrsMap: { ':title': 'title', url: 'javascript:void(0)', 'hide-footer': '', '@title-click': 'toggleTab', 'title-arrow': '', 'title-feedback': '' }, parent: undefined, children: [ { type: 1, tag: 'c-scroll', attrsList: [Array], attrsMap: [Object], parent: [Circular], children: [Array], plain: false, hasBindings: true, events: [Object] } ], plain: false, hasBindings: true, attrs: [ { name: 'title', value: 'title' }, { name: 'url', value: '"javascript:void(0)"' }, { name: 'hide-footer', value: '""' }, { name: 'title-arrow', value: '""' }, { name: 'title-feedback', value: '""' } ], events: { 'title-click': { value: 'toggleTab', modifiers: undefined } } }
這個AST樹節點上存在不少屬性,包括tag
/attrsList
/parent
/children
/events
等,咱們爲了計算方便,這裏去掉一些屬性,只保留tag
/children
等少數屬性。另外加上節點的id
和層級信息等,最終生成的模板組件樹結構以下:框架
{ "name": "ly_viewport_scene", "root": { "depth": 1, "id": 1, "name": "c-aladdin", "children": [ { "depth": 2, "id": 2, "name": "c-scroll", "children": [ { "depth": 3, "id": 3, "name": "c-scroll-item", "children": [ { "depth": 4, "id": 4, "name": "c-link", "children": [ { "depth": 5, "id": 5, "name": "div", "children": [ { "depth": 6, "id": 6, "name": "c-image" }, { "depth": 6, "id": 7, "name": "c-label" } ] }, { "depth": 5, "id": 6, "name": "c-line", "children": [] } ] } ] }, { "depth": 3, "id": 4, "name": "c-scroll-item", "children": [ { "depth": 4, "id": 5, "name": "c-more" } ] } ] } ] } }
最終咱們能夠拿到全部卡片的組件樹結構信息。機器學習
組件樹的類似度計算
那麼兩個卡片對應的組件樹的類似度怎麼計算呢?目前有幾種主流的思路:ide
- 基於樹編輯距離的類似度計算:編輯距離指的是把樹 T1 轉化成樹 T2 的最小代價,包括替換節點、插入節點、刪除節點的代價。代價越小,編輯距離越短,說明兩棵樹越類似。
- 基於公共子路徑的類似度計算:分別計算出樹 T1 和 T2 全部子路徑,在樹 T1 和 T2 中都出現過的公共子路徑越多,說明兩棵樹越類似。
- 基於公共子字符串的類似度計算:將樹結構轉換爲前序遍歷(或其它遍歷方式)獲得的由節點名組成的字符串 S1 和 S2 ,而後計算 S1 和 S2 的公共子串的長度和個數,數值越大說明兩棵樹越類似。
- 基於同層子節點匹配的類似度計算:對應樹 T1 和 T2 的每一層節點,使用動態規劃算法獲得最類似節點,在最類似節點上繼續進行匹配,其它節點則產生差別值,最後獲得彙總的差別值越小,兩棵樹越類似。
- 基於同構特徵的類似度計算:構造出 K 個節點的全部非同構形態子樹,而後計算樹 T 中這些子樹的同構個數,將其做爲特徵向量,最後經過計算兩組特徵向量來計算兩棵樹的類似度。
下面重點介紹一下我所採用的方法 2 - 基於公共子路徑的類似度計算方法。工具
基於公共子路徑的類似度計算方法
規定樹的一條路徑是從根節點到葉子節點這條線上全部節點組成的一條路徑,子路徑就是這條路徑上截取任意一段獲得的路徑。仍是卡片 A 爲例,對上面的組件樹對象可視化獲得:
計算組件樹子路徑的方法以下:
/** * 計算一個模板組件樹的全部子路徑 * * @param tree 模板組件樹 * @return 添加了 depth 和 subPaths 的模板組件樹 */ export function getSubPathsFromTree(tree: ITree): ITree { const subPaths = {}; const maxDepth = -1; if (tree.root) { deepTraverse(tree.root, [], subPaths, [], maxDepth); tree.depth = maxDepth; tree.subPaths = subPaths; } return tree; } /** * 深度遍歷組件樹 * * @param root 當前遍歷到的節點 * @param rootPath 從根節點到當前節點的路徑 * @param subPaths 全部子路徑 * @param track 保存統計過的節點 id 構成的子路徑(形如'0/1/3'),避免重複統計 * @param maxDepth 計算組件樹的深度 */ function deepTraverse( root: INode, rootPath: IRootPathNode[], subPaths: ISubPath, track: string[], maxDepth: number ): void { const { id, tag, depth, children } = root; if (maxDepth < depth) { maxDepth = depth; } const newPath = rootPath.concat([{ id, tag, depth }]); if (!children || !children.length) { generateSubPath(newPath, 1, subPaths, track); } else { for (const node of children) { deepTraverse(node, newPath, subPaths, track, maxDepth); } } } /** * 計算一條從根節點到葉子節點的路徑的全部子路徑 * * @param rootPath 從根節點到當前節點的路徑 * @param len 當前須要生成子路徑的長度 * @param subPaths 全部子路徑 * @param track 保存統計過的節點 id 構成的子路徑(形如'0/1/3'),避免重複統計 */ function generateSubPath( rootPath: IRootPathNode[], len: number, subPaths: ISubPath, track: string[] ) { for (let i = 0; i + len - 1 < rootPath.length; i++) { const curPath = rootPath.slice(i, i + len); const depth = curPath[0].depth; const idKey = curPath.map(item => item.id).join("/"); const tagKey = curPath.map(item => item.tag).join("/"); if (track.indexOf(idKey) === -1) { track.push(idKey); if (subPaths[tagKey]) { subPaths[tagKey].count += 1; } else { subPaths[tagKey] = { count: 1, depth }; } } } if (len < rootPath.length) { generateSubPath(rootPath, len + 1, subPaths, track); } }
經過計算得出它的全部子路徑共 33 個,以下:
咱們能夠知道,若是 T1 和 T2 中的相同子路徑數越多,說明 T1 和 T2 越類似。另外,還會發現類似的樹中不一致的區塊每每出如今樹中層次較深的位置。所以,能夠設計出計算類似度的公式:
其中 Nump(T) 表示子路徑 p 在樹 T 中出現的次數,P 表示在樹 T1 和 T2 中一共有的子路徑集合,C1 和 C2 分別對應樹 T1 和 T2 各自的子路徑集合,wp 和 vp 分別對應樹 T1 和 T2 中子路徑 p 的權重參數。這個權重參數由子路徑的深度決定,子路徑的深度規定爲子路徑中第一個節點的深度。子路徑越深,權重參數應該越小,權重參數的計算公式以下:
這樣計算出來的類似度數值能夠在很大程度上反映兩個組件樹的類似程度,若是兩個樹越類似,那麼這個類似度數值就會越大。最大值是 1,代表兩個樹徹底相同(包括全部節點信息和結構信息),最小值是 0,代表兩個樹沒有任何類似點。
計算兩個組件樹類似度的方法以下:
/** * 計算兩個組件樹的類似度值 * * @param tree1 第一個組件樹 * @param tree2 第二個組件樹 * @return 類似度值。範圍0~1,0表示徹底不一樣,1表示徹底相同 */ export default function calcSimilarityBySubPath(tree1: ITree, tree2: ITree): number { const { depth: depth1, subPaths: subPaths1 } = getSubPathsFromTree(tree1); const { depth: depth2, subPaths: subPaths2 } = getSubPathsFromTree(tree2); const commonSubPath = []; const allSubPathSet = new Set(); Object.keys(subPaths1).forEach(subPath => { allSubPathSet.add(subPath); if (subPaths2[subPath]) { commonSubPath.push(subPath); } }); Object.keys(subPaths2).forEach(subPath => allSubPathSet.add(subPath)); // key: subPath, value: weight const allSubPath: ISubPathWeight = Array.from(allSubPathSet).reduce( (acc, key) => { const weight1 = calcWeight( depth1, subPaths1[key] && subPaths1[key].depth ); const weight2 = calcWeight( depth2, subPaths2[key] && subPaths2[key].depth ); acc[key] = [weight1, weight2]; return acc; }, {} ); const value = Object.keys(allSubPath).reduce( (acc, key) => { if (allSubPath[key][0] && allSubPath[key][1]) { const curValue = allSubPath[key][0] * subPaths1[key].count + allSubPath[key][1] * subPaths2[key].count; acc.simValue += curValue; acc.totalValue += curValue; } else if (allSubPath[key][0]) { acc.totalValue += allSubPath[key][0] * subPaths1[key].count; } else if (allSubPath[key][1]) { acc.totalValue += allSubPath[key][1] * subPaths2[key].count; } return acc; }, { simValue: 0, totalValue: 0 } ); const kernelValue = Number(Number(value.simValue / value.totalValue).toFixed(2)); return kernelValue; }
示例
根據上面的類似度計算方法,咱們選取兩個卡片(卡片 A 和卡片 B),計算出這兩個卡片之間的類似度爲 0.71。
組件樹對比:
兩個樹總體結構類似,只是在局部區塊c-link
節點下的子節點結構有所不一樣。
模板線上樣式對比:
咱們看這兩個卡片真正在線上展示的樣子,能夠看到都是隻包含了標題組件和橫滑組件的卡片,只是橫滑內部每一個單元的元素有些不同。因此 0.71 仍是能夠比較好的反映這兩個卡片的類似度。
卡片分類和歸一
對卡片進行分類是指,類似度越高的卡片應該被歸爲一類。可是目前咱們也不肯定能將這些卡片分爲多少類,每一個分類結果中也不肯定會包含多少個卡片。
這個問題可使用聚類分析的方法解決,屬於典型的機器學習中的無監督學習。即從沒有標註的數據中分析數據的特徵,獲得的分類結果是不肯定的。對應的機器學習中還有一種類型叫做監督學習,即依賴於從預先標註的數據中學習到如何對數據特徵進行判斷和歸類,而後才能夠對未知數據進行預測。
常見的聚類分析算法有如下幾種:
- K-Means 均值聚類
- 層次聚類
- 基於密度的聚類
- 基於網格的聚類
我選擇了相對簡單的層次聚類方法來作分析。
聚類分析過程
層次聚類算法的主要流程是,把每個樣本數據都視爲一個類,而後計算各種之間的距離,選取最相近的兩個類,將它們合併爲一個類。新的這些類再繼續計算距離,合併距離最近的兩個類。如此往復,若是沒有終止條件判斷,最後就會合併成只有一個類。它是一個自底向上的構建一顆有層次的嵌套聚類樹的過程。
各種之間的距離在目前討論的場景中就是卡片之間的類似度值。根據上面的計算組件樹類似度的方法,咱們對全部卡片進行兩兩計算,獲得一個包含全部卡片類似度的矩陣。接下來就是基於這個類似度矩陣的計算。
計算步驟以下:
- 基於類似度矩陣,選取類似度值最大的兩個卡片,把這兩個卡片合併爲一類
- 而後更新類似度矩陣:計算新合併類與其它類的距離(類似度),將新合併生成的類加入到矩陣中,刪除合併以前的子類
- 而後基於更新後的類似度矩陣,迭代重複上述步驟,直到知足終止條件時中止
假設咱們有 5 個卡片A
/B
/C
/D
/E
,聚類的簡易流程以下圖所示:
具體的聚類計算過程以下:
/** * 聚類算法 * * @param nodes 全部節點結構化信息 * @param matrix 類似度矩陣 * @param options 聚類參數 */ function cluster( nodes: IClusterNodes, matrix: IMatrix, options: IClusterOptions ): void { const matrixOnlyAdd = clone(matrix); if (!options.thresholdCount) { options.thresholdCount = 100; } if (!options.thresholdSimilarity) { options.thresholdSimilarity = 1; } while (Object.keys(nodes).length > options.thresholdCount) { const maxEdge = getMaxEdge(matrix); if (maxEdge.value < options.thresholdSimilarity) { break; } // merge nodes and update nodes const id = uuid.v1(); const node1 = nodes[maxEdge.source]; const node2 = nodes[maxEdge.target]; const simValue = calcSimValue(nodes, maxEdge); nodes[id] = { set: node1.set.concat(node2.set), simValue, }; delete nodes[maxEdge.source]; delete nodes[maxEdge.target]; // update matrix matrix[id] = {}; matrixOnlyAdd[id] = {}; for (const key in nodes) { if (id !== key) { const distance = calcDistanceByAverage( nodes[id].set, matrixOnlyAdd, key ); if (distance) { matrix[id][key] = distance; matrixOnlyAdd[id][key] = distance; } } } rmMatrixNodes(matrix, maxEdge.source); rmMatrixNodes(matrix, maxEdge.target); } } /** * 計算複合節點添加了 maxEdge 後,造成的新的複合節點的總體類似度,暫以平均值近似計算 * * @param nodes 全部節點數據 * @param maxEdge 擁有最大類似的邊 * @return 新的複合節點的總體類似度 */ function calcSimValue(nodes: IClusterNodes, maxEdge: IGraphEdge): number { const node1 = nodes[maxEdge.source]; const node2 = nodes[maxEdge.target]; let simValue; if (node1.simValue && node2.simValue) { simValue = (node1.simValue + node2.simValue + maxEdge.value) / 3; } else if (node1.simValue) { simValue = (node1.simValue + maxEdge.value) / 2; } else if (node2.simValue) { simValue = (node2.simValue + maxEdge.value) / 2; } else { simValue = maxEdge.value; } return simValue; } /** * 計算節點(聚合)到節點(聚合)的類似度,取節點到聚合節點中的每一個節點的類似度平均值 * * @param list 聚合節點包含的節點列表 * @param matrix 模板類似度矩陣 * @param id 節點 ID * @return 類似度值 */ function calcDistanceByAverage( list: Array<{ id: string; name: string }>, matrix: IMatrix, id: string ): number { let average = 0; for (const { id: subId } of list) { average += matrix[id] && matrix[id][subId] ? Number(matrix[id][subId]) : matrix[subId][id] ? Number(matrix[subId][id]) : 0; } average /= list.length; return average; }
這裏面還涉及到一些細節問題:
計算聚合節點與其它節點的距離
當合並了兩個節點生成一個新的聚合節點時,須要計算新生成的聚合節點到其它全部節點的距離。這裏有兩種狀況:
- 聚合節點到其它單節點(只包含一個卡片)的距離:這種狀況下咱們採用計算平均值的辦法,計算聚合節點中的全部子節點到其它節點的類似度平均值做爲距離。
- 聚合節點到其它聚合節點的距離:這種狀況下因爲聚合節點內部節點到其它聚合節點的距離已經算出,咱們依舊能夠採用上面的辦法。而不是計算兩個聚合節點中全部子節點的距離平均值,由於這樣計算量會很大,時間複雜度達到
O(n2)
。
終止條件的選擇
因爲聚類分析是一個嵌套迭代的過程。若是沒有終止條件,最終聚合成一個類對於模板分析是沒有意義的。因此咱們須要肯定迭代的終止條件。我採用了兩種方法:
- 規定一個 分類結果個數閥值:聚類迭代過程當中當分類個數小於閥值時終止。這種辦法比較簡單粗暴,問題是獲得最終結果時,咱們並不知道每一個聚合分類內部的模板類似度狀況。
- 規定一個 分類的最低內部類似度閥值:合併模板的時候,咱們額外計算一下聚合分類內部的類似度(這個類似度選取分類內部任意兩個模板之間類似度的最小值),而後在聚類迭代的過程當中判斷聚類內部類似度是否小於閥值,若是小於閥值則中止。
模板聚類效果
基於上面的模板分析方法,咱們最終計算出了卡片歸一化的結果。可視化之後的效果以下所示,這裏還能夠調節類似度閥值查看不一樣程度的分類結果:
這裏的類似度範圍(*100)是 0~100。
待完善的部分
- 生成的組件樹僅限於解析卡片的
template
部分,而且僅限於靜態分析,若是有其它的判斷邏輯或者數據處理邏輯,是覆蓋不到的,那麼卡片仍是會有 diff 的。 - 不少看上去類似的卡片,在代碼實現上實際上是不一樣的。好比多套了一層
div
,或者有隱藏的 DOM 元素,只有在點擊交互後才展示等。這種類型的卡片組件樹之間的類似度可能會比較小,最終不會被聚類到一塊兒,但其實也屬於咱們歸一化的對象。 - 基於子路徑的類似度計算方法,對兄弟節點的順序不敏感,而對於不一樣模板而言,即便兄弟節點相同,可是順序不一樣,展示上也會有很大的變化。
針對這些不足點,後續還有很大的優化空間。
參考
- Tree Kernels: Quantifying Similarity Among Tree-Structured Data
- 基於 HTML 樹的網頁結構類似度研究
- 一種基於結構特徵的樹類似度計算方法
- 層次聚類算法的原理及實現
- 用於數據挖掘的聚類算法有哪些,各有何優點?
- 一篇文章透徹解讀聚類分析
做者:姚昌,百度資深前端工程師
更多內容,歡迎關注公衆號