本文最初发布于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 树的网页结构类似度研究
- 一种基于结构特征的树类似度计算方法
- 层次聚类算法的原理及实现
- 用于数据挖掘的聚类算法有哪些,各有何优点?
- 一篇文章透彻解读聚类分析
做者:姚昌,百度资深前端工程师
更多内容,欢迎关注公众号