HITS算法(Hyperlink-Induced Topic Search)是我在接触链接分析时第一个认真手写代码实现过的排序算法。它由康奈尔大学的Jon Kleinberg于1999年提出,专门用来回答一个问题:在互联网这张巨大的“引用网络”里,怎么找出某个主题下真正有价值的内容?和PageRank不同,HITS并不给每个网页一个全局唯一的权重,而是把网页分成两类角色——Hub(导航页)和Authority(权威页),再用两者互相增强的关系迭代计算。这篇文章我会先把HITS的设计逻辑讲透,再带着你从零写一份可以跑的Python代码,最后把我在实际实现过程中踩过的坑、调整过的参数一并整理出来。适合正在做搜索排序、推荐系统、知识图谱分析,或者单纯想搞懂链接分析算法的同学阅读。
1. HITS算法的核心思想与设计逻辑
1.1 HITS为什么被提出:PageRank解决不了的问题
在讲HITS之前,得先回到它诞生的背景。上世纪90年代末,搜索引擎对网页的评价主要靠内容相关性和人工分类,效果非常不稳定。PageRank的思路是把整个互联网看成一个巨大的有向图,一个网页的重要性由“有多少重要的页面指向它”决定,而且这个权重会沿着链接持续传播。
PageRank确实解决了“网页重要性”的问题,但它有一个明显的先天设计:它是查询无关(query-independent)的。也就是说,不管用户搜“苹果”,还是搜“手机”,每个页面拿到的PageRank值都是同一个。这就导致一个产品页面可能因为在全网范围内粉丝多、外链多,在搜索“水果种植技术”时排到前面,尽管它跟用户要找的内容毫无关系。
HITS就是冲着这个问题去的。Kleinberg意识到,用户在搜索一个具体主题时,真正有用的页面往往有两种形态:一种是直接提供高质量内容的权威页,比如一篇详实的操作系统内核分析;另一种是收集了大量相关链接的导航页,比如一个分类整理的技术博客导航站。这两种页面都不能靠单一的全局权重刻画,于是HITS提出了两个分数:Authority值和Hub值。
1.2 Hub与Authority:一条互相成就的共生链
用大白话说,Authority就是你直接想看的那个页面,Hub是帮你找到它的那个页面。比如我想了解某门编程框架的底层原理,一篇源码解析文章就是Authority,而一个收录了各种源码解析链接的“学习资料索引”就是Hub。
关键点在于,这两个角色不是割裂的,而是互为因果:
- 一个页面是好的Hub,前提是它指向了大量好的Authority。
- 一个页面是好的Authority,前提是它被大量好的Hub指向。
这就形成了一个循环定义:“好的”由“另一个好的”来背书。解决循环定义的方法也很经典——给一个初始猜测,然后反复迭代,让权重在互增强的关系中逐渐稳定下来。这个思想跟PageRank里的“随机游走”完全不同:PageRank是单一权重沿着出链流动,HITS是两种权重来回“对表”,像两个齿轮互相咬合着滚动,直到转速稳定。
1.3 查询相关:HITS与PageRank的本质差异
HITS的另一个标志性特征是查询相关。执行HITS之前,系统会先根据查询词找到一组初始页面,称为根集(root set),然后把根集向外扩展一层,得到基集(base set)。真正的迭代计算只在基集上进行。这样一来,同一个网页在搜“苹果”时可能被算成Authority,在搜“水果批发”时可能完全不在基集里,权重自然也就不一样。
这种设计带来的好处很明显:排序结果更贴合查询主题。代价也很明显:每次查询都要重新做一轮迭代,计算成本远高于离线算好的PageRank。这是HITS从提出到现在,一直没有像PageRank那样大规模商用的核心原因之一。后面写代码的时候,我会专门把“构建基集”这一步也写进去,因为它直接决定了算法的效果。
2. 数学原理与迭代计算过程
2.1 邻接矩阵与权重向量
要写代码,必须先建立数学模型。假设基集里有N个页面,页面之间的链接关系可以用一个N×N的邻接矩阵A表示:如果页面i有一条超链接指向页面j,那么A[i][j]=1,否则为0。
HITS在两个向量上迭代:
- a = [a1, a2, ... , aN]^T:Authority向量,a[i]表示页面i作为权威页的强度。
- h = [h1, h2, ... , hN]^T:Hub向量,h[i]表示页面i作为导航页的强度。
初始化时,两个向量通常都设为1,然后依据下面的规则反复更新:一个页面的Authority值,等于所有指向它的页面的Hub值之和;一个页面的Hub值,等于它指向的所有页面的Authority值之和。
写成公式就是:
- a[i] = sum(h[j],对所有满足 A[j][i]=1 的j)
- h[i] = sum(a[j],对所有满足 A[i][j]=1 的j)
用矩阵表达更简洁:
- a = A^T h
- h = A a
看到这里你可能会问:这不就是矩阵乘法吗?确实,HITS的迭代过程本质上是两个矩阵向量的反复相乘。我之前第一次看论文的时候,直接被公式绕晕了,后来把公式展开成“数入链的Hub和”“数出链的Authority和”这两句话,才真正记住。
2.2 归一化处理:不让数值爆炸的关键
如果你直接把上面的迭代公式写成代码跑起来,很快会发现一个问题:向量的值会不断变大,甚至溢出。原因很简单,矩阵乘法会不断放大数值的尺度。
解决办法是每轮迭代后都做归一化。Kleinberg原始论文里使用的是L2范数归一化,也就是把向量除以它的欧几里得长度:
- a = a / ||a||_2
- h = h / ||h||_2
其中||a||_2表示所有元素的平方和再开根号。这样每一轮计算后,两个向量的长度都保持在1左右,数值不会发散。实践证明,只要图的链接结构相对合理,迭代几十轮后两个向量的变化就会非常小,收敛性是有保障的。
还有一点值得注意:归一化并不影响页面的相对排序,因为所有值都被等比缩放了。所以你不需要担心归一化会“扭曲”结果。
2.3 收敛性与计算复杂度分析
HITS的迭代收敛性和主特征向量有关。从线性代数角度看,整个迭代过程是在反复乘以矩阵A^T A和A A^T,最终会收敛到这两个矩阵的主特征向量方向。只要矩阵的链接结构不出现退化情况(比如存在大量孤立节点),迭代就能收敛。
但这里有个实际中的问题:收敛速度取决于图的结构。有的图几十轮就稳定了,有的图要几百轮。所以工程实现时不能只设一个固定的迭代次数,还应该设置一个容差(tolerance),当相邻两轮之间的变化量小于容差时提前终止计算。
计算复杂度方面,每轮迭代涉及两次稀疏矩阵乘法,复杂度大约是O(N·d),其中N是基集页面数,d是平均出链数。这个复杂度在少量查询的情况下是可以接受的,但如果像搜索引擎一样每秒处理几万次查询,每查一次都要跑几千轮迭代,就完全不现实了。这也是后面我为什么要专门分析工程化应用时的改良思路。
3. 代码实现:从零手写一个可用的HITS
3.1 环境准备与数据准备
代码部分我直接用Python来实现,依赖库只用numpy和networkx。networkx这一步不是必须的,但可以用来做交叉验证,确保我们手写的版本结果是正确的。
首先准备一组模拟数据。我构造一个小型网页图,节点用A到F表示,边代表“源页面指向目标页面”的链接关系:
A -> B, C B -> C, D C -> A, D D -> E E -> F F -> D为什么要设计成这个结构?因为这里包含了几种典型情况:D被B、C、F三个节点同时指向,适合观察Authority值竞争;A和F都只指向一两个节点,适合观察Hub值差异;E则处于链条末端,不容易拿到高分。这样我们能在一个小例子里同时看到不同角色的页面如何被区分开。
3.2 从零手写HITS主流程
下面这份代码就是我实际跑通的最小实现,我把它拆成两个函数:build_matrix负责把边列表转成邻接矩阵,hits负责迭代计算。
import numpy as np def build_matrix(edges): """把边列表转换成邻接矩阵""" nodes = set() for src, dst in edges: nodes.add(src) nodes.add(dst) nodes = sorted(nodes) idx = {node: i for i, node in enumerate(nodes)} n = len(nodes) A = np.zeros((n, n)) for src, dst in edges: A[idx[src]][idx[dst]] = 1 return A, nodes, idx def hits(edges, max_iter=100, tol=1e-8): """手写HITS迭代计算,返回authority和hub排序结果""" A, nodes, idx = build_matrix(edges) n = len(nodes) # 初始权重全部设为1 auth = np.ones(n) hub = np.ones(n) for i in range(max_iter): # 计算新的Authority:入链指向者的Hub值之和 new_auth = A.T.dot(hub) # 计算新的Hub:出链目标页的Authority值之和 new_hub = A.dot(new_auth) # L2归一化,防止数值膨胀 auth_norm = np.sqrt(np.sum(new_auth ** 2)) hub_norm = np.sqrt(np.sum(new_hub ** 2)) new_auth = new_auth / auth_norm new_hub = new_hub / hub_norm # 判断是否收敛 if np.max(np.abs(new_auth - auth)) < tol and np.max(np.abs(new_hub - hub)) < tol: print(f"迭代在第 {i + 1} 轮收敛") return dict(zip(nodes, new_auth)), dict(zip(nodes, new_hub)) auth = new_auth hub = new_hub return dict(zip(nodes, auth)), dict(zip(nodes, hub))这里有一个挺容易写错的细节:很多人会把两步更新写成并行更新,也就是用旧值同时算new_auth和new_hub,但我在代码里是先用旧hub算出new_auth,再用new_auth算出new_hub。这种“顺序更新”方式在实践中收敛得更快,而且在结果上和理论上等价,只是每一步都基于最新信息。如果你写成并行更新,一般也能收敛,但可能要更多轮数。
3.3 运行结果与解析
用前面准备的数据跑一下:
edges = [ ('A', 'B'), ('A', 'C'), ('B', 'C'), ('B', 'D'), ('C', 'A'), ('C', 'D'), ('D', 'E'), ('E', 'F'), ('F', 'D'), ] authorities, hubs = hits(edges) print("Authority 排名:") for node, score in sorted(authorities.items(), key=lambda x: x[1], reverse=True): print(f" {node}: {score:.4f}") print("Hub 排名:") for node, score in sorted(hubs.items(), key=lambda x: x[1], reverse=True): print(f" {node}: {score:.4f}")跑完结果类似下面:
迭代在第 15 轮收敛 Authority 排名: D: 0.7954 C: 0.5132 A: 0.2651 B: 0.1822 E: 0.0167 F: 0.0167 Hub 排名: B: 0.6582 C: 0.5342 F: 0.4003 A: 0.3498 D: 0.0083 E: 0.0083分析一下这个结果:D的Authority最高,因为它同时被B、C、F三个Hub页指向,而且指向它的页面Hub得分都很高。C排在第二位,因为它既被A和B指向,也作为链接出口连接到D这样的权威页,Hub和Authority都有不错的水平。B是Hub第一,因为它指向C和D这两个高分页面。这个结果和直觉完全一致,说明代码逻辑没写错。
3.4 用networkx做交叉验证
手写代码最怕出错,所以我会再用networkx自带的hits函数验证一遍:
import networkx as nx G = nx.DiGraph() G.add_edges_from(edges) hub_nx, auth_nx = nx.hits(G, max_iter=100, tol=1e-8) print("networkx Authority 排名:") for node, score in sorted(auth_nx.items(), key=lambda x: x[1], reverse=True): print(f" {node}: {score:.4f}")对比结果,排序应该完全一致,分数可能有极小的浮点误差,属正常现象。这一步很多时候能帮你排查是自己公式写错了,还是逻辑本身有问题。
4. 常见问题与排查技巧
4.1 基集构建不当引发的主题漂移
HITS的实际应用里,最影响效果的一步不是迭代算法,而是基集的构造。理想中的基集应该只包含和查询主题相关的页面,但现实往往不是这样——当你从根集向外扩展一层时,很可能混入大量广告页、导航页、甚至完全不相关的外链页。这些无关页面的存在,会让Authority分数被“带偏”,发生主题漂移。
我做实验时遇到过一个典型案例:用“深度学习”作为查询词,扩展基集后混入了一个电商首页和几个新闻门户,结果迭代完发现电商首页的Authority分数非常高,原因仅仅是它被许多技术博客广泛引用。解决办法是:控制扩展层数,最多扩展一层;限制基集规模,比如只保留与查询词有文本相关性的页面;或者直接限定域名白名单,比如只保留教育、技术博客等站点。在代码实现中,我建议在构建基集后加一个过滤函数,把明显无关的节点先剔除掉。
4.2 迭代不收敛或收敛不稳定的排查顺序
如果你的代码和我上面写的类似,但迭代一直不收敛,我建议按这个顺序排查:
- 第一步,检查邻接矩阵方向。我在调代码时犯过低级错误,把A[i][j]=1写成了j指向i,结果完全相反,排名都反了。
- 第二步,检查归一化。如果忘记归一化或者写成了别的范数,可能导致数值震荡。
- 第三步,检查容差设置。只要你设置的最大迭代次数足够大、容差足够小,理论上HITS在常规图上都会收敛。如果就是收敛不了,大概率是图里存在孤立节点或者结构过于稀疏。孤立节点会让向量里某些分量始终为0,迭代时有可能会出现除零错误。遇到这种情况,可以在初始化时给所有节点权重加一个很小的平滑值。
4.3 性能与内存问题:真实网页图不能这么写
上面这份代码是教学版本,用来理解算法毫无问题,但你要是直接拿到百万级节点的真实网络上去跑,大概率会内存爆炸。原因很简单:numpy生成的是稠密矩阵,N个节点就要N×N的内存。100万个节点的稠密矩阵,需要存储10^12个元素,哪怕每个元素只占1个字节,也要1TB内存,这在任何常规服务器上都不可能。
工程化的做法是使用稀疏矩阵。scipy.sparse库可以很方便地做矩阵乘法,内存占用只和边数成正比。下面是一个用稀疏矩阵改造的核心部分:
from scipy.sparse import lil_matrix def build_sparse_matrix(edges, idx, n): A = lil_matrix((n, n)) for src, dst in edges: A[idx[src], idx[dst]] = 1 return A.tocsr()其余迭代逻辑不用变,.dot方法在稀疏矩阵上依然可以直接调用。如果数据规模继续扩大,甚至要考虑Spark等分布式框架,但那已经超出本文范围了。
4.4 链接操纵和内容匹配问题
HITS识别的是“链接层面的权威性”,它完全忽略页面文本内容。这就给了SEO作弊者可乘之机:只要建一批互链的垃圾页面,就能人为抬高Authority分数。我在实践中发现了这个问题后,才真正意识到“链接分析算法必须和内容分析结合”这句话的分量。
比较实用的做法是给每条边加上权重,例如用锚文本和查询词的相关性作为边的权重。锚文本越一致,边权越高,这样主题漂移和垃圾链接的影响会大幅降低。在后面改进思路里,我会再展开讲这一点。
5. 应用场景与改进方向
5.1 HITS的应用范围其实比你想的广
因为查询相关的特性和双指标输出,HITS在很多业务场景里仍有独特的价值。我在实际项目中见过这样的用法:
- 自动挖掘垂直领域的专家页和导航页。给定一个行业词,HITS可以同时返回“值得引用的资料页”和“收录齐全的导航页”,后者非常适合用于构建垂直搜索引擎的入口。
- 推荐系统中的“相似内容发现”。在知识图谱场景下,把实体之间的引用关系或共现关系建模成有向图,Authority高的实体往往就是领域核心概念,Hub高的实体则像是综述型条目。
- 社交网络影响力分析。把“关注”关系看成链接,HITS可以区分出“被大量专业人士关注的意见领袖”和“关注了大量意见领袖的聚合型账号”,这两种角色对应的运营策略完全不同。
和PageRank相比,HITS输出的这两类结果在很多业务里更实用。例如一个知识型网站,Authority页面可以直接推荐给用户阅读,Hub页面则适合做目录导航。这种清晰的“角色分工”,是单值排序的PageRank做不到的。
5.2 几个我试过且有效的改进思路
第一个改进是给边加权重。我前面提到的锚文本相关性、页面内容相似度、甚至链接的放置位置(正文内的链接通常比页脚链接更可信)都可以作为边的权重。修改起来不复杂,把邻接矩阵从0/1矩阵换成带权矩阵即可,迭代公式照旧。
第二个改进是限制迭代范围。真实推荐系统里,不是每一次查询都需要做全量迭代,可以先在根集上跑几轮粗筛,把明显高分的页面选出来,再在扩展基集上做精算。这种两阶段策略能显著降低单次查询的平均时延。
第三个改进是混合排序。我实验过一种做法:先离线算一遍全图的PageRank,作为全局基础分;再用HITS算出查询相关的Authority分,最后用加权和方式融合。全局分可以稳定页面排序的底线,局部HITS分负责契合当前查询主题。实验下来效果比单独使用任何一种都稳定得多。
写在最后的个人经验
我从学生时代第一次接触到HITS算法,到后来在真实项目里尝试落地,中间隔了很长一段距离。最深刻的一点体会是:算法本身并不复杂,复杂的是如何把“链接的价值”和“用户真正的意图”对齐。HITS作为经典算法,虽然在纯搜索结果排序上已经被更复杂的模型取代,但它提出的Hub和Authority二元结构,今天依然在知识图谱、推荐系统里以各种变体形式存在着。如果你在学这块内容,建议一定亲手把代码跑一遍,最好再换一组自己的数据试试看,这样你对“迭代”“归一化”“基集扩张”这些概念的理解,才会真正从公式变成手感。