gitbook/业务开发算法50讲/docs/485339.md
2022-09-03 22:05:03 +08:00

267 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 22PageRank谷歌是如何计算网页排名的
你好,我是微扰君。
上一讲我们学习了谷歌三架马车之一 MapReduce。建立在Google File System的基础上MapReduce很好地解决了谷歌当时的大规模分布式计算问题让业务工程师不再需要处理和分布式计算相关的容错、数据分发、计算调度等复杂的技术细节而把精力放在业务问题本身。
不过谷歌作为一家搜索引擎公司搜索自然是谷歌重中之重的核心业务。今天我们就来学习谷歌三架马车之二——PageRank算法。
早期的搜索引擎一般只是基于关键字进行匹配,按照匹配情况,把爬虫爬到的全部内容,再基于时间顺序进行排列,如果对搜索质量的把控高一点,可能最多也就是做到基于关键词出现频次的排序。但是,这样搜索出来的质量往往不是很让我们满意,而且容易出现站点作弊的情况,比如通过大量在网页内容中填充某些关键词,以提高自己的网页排名。
为了让用户获得更好的搜索体验从而打败竞争对手,谷歌是如何设计自己计算网页排名的算法的呢?
这就要提到PageRank算法由谷歌创始人也是斯坦福大学的博士生 Larry Page 提出的算法既以Larry Page本人名字来命名同时也包含了网页排名的意义。 PageRank 算法**不止可以让用户搜索到自己关心的内容,也往往能让质量更高的网页得以排到更前的位置**,同时它也是一个典型的 MapReduce 的应用场景。
那 PageRank 具体是怎么做网页排名的呢?
## 基于引用的排名
其实这里面的想法主要受到了论文影响力因子的启发;在学术网络中论文的影响力因子往往是基于论文被引用次数来衡量的,这是一种最简单也非常有效的评价指标。
一篇影响广泛的论文往往会成为许多其他工作的基础从而收获大量的引用。以1998年发布的PageRank这篇论文本身为例到现在一共收获了16102次引用足以说明这是一篇非常有影响力的工作。
![图片](https://static001.geekbang.org/resource/image/ae/1d/aee0f3fc53327c32841e4b089c496c1d.png?wh=1698x266)
有相当多的学者在用图的方法研究学术网络中的问题,如果你把论文看成图,那么论文之间的引用关系就是一条条有向边,入边越多的节点,影响力一般来说也越高。
网页和学术论文其实在有些方面是很像的。如果把网页看成图上的节点,由于网页之间有一些超链接指向,谷歌所能爬到的所有网页就会构成一张类似于学术网络的图。
PageRank 对网页的排名,本质上也是这样一种基于引用情况和影响力的排名。**背后的逻辑很简单,被更多超链接指向的网页,可以推断它往往会有更好的质量**因为当时许多HomePage类的导航网站都会链接到一些提供优质服务的网站如果一个网站质量很差自然也不会被太多链接所指向。
那简单统计引用次数,也就是我们把爬到的那些在网络中被链接次数更多的网页排到更前面,行不行呢?
这样当然也一定程度上可以反映出网页的排名情况,**但不同网站的链接所代表的权重应该是不同的**。比如雅虎链接的网页和某个个人主页所链接的网页,显然代表的意义是不同的,如果把所有的引用都看成权重一样的,并不令人满意,而且也很容易作弊。只要建立大量的网页,链接向某个想要在搜索引擎中提高权重的网页,在只看引用次数的算法下,很容易就把网页排名提高了。
## PageRank
Larry Page 的PageRank算法考虑到了不同链接的权重整个过程也非常简单容易理解。
我们先按照他论文中的数学语言描述一下整个网页集合方便后面分析。假设u代表了某一个网页$F\_u$代表u所指向的网页集合$N\_u$代表$F\_u$集合的大小;$B\_u$代表指向u的网页集合c是一个因子用来保持所有网页权重之和是一个常数。
如果我们用 R(u) 表示网页u的权重拉里佩奇是这样计算权重的
$$R(u)=\\sum\_{v\\subset B\_u}R(v)/(N\_v)$$
光看数学公式不是很好理解,我们对照这个图片来看:
![图片](https://static001.geekbang.org/resource/image/dd/ed/ddd24f4a3aa5240fb2f390bf029e0ced.jpg?wh=1920x1145)
每个网页都有不同的权重用来排名,如何计算这个权重呢?拉里佩奇的做法就是对指向当前页面的所有页面,我们直接它们的权重做某种程度上的加权平均。
每个页面的总体权重会被平均分散到它所指向的页面中去比如图中权重为100的网页有两条出边那么每条边的权重就会记录为50。那么对于被指向的网页权重如何计算呢比如权重为53的网页就是用指向它的两条链接的权重进行累计求和也就是50+3=53。
这样的分配是简单而有效的。因为我们可以简化地认为**用户从某个网页跳转到另一个网页的概率,就是在当前网页的所有超链接中,随机选择一个,进行跳转**,那当前网页的影响力被平均地转移到它所指向的网页,也是很符合直觉的。
### 如何初始化权重?
但是这样的计算方式显然是动态的、迭代的,每一轮迭代计算的结果都依赖上一轮迭代的结果,所以它们看起来都会依赖整个图的初始状态,我们如何初始化权重呢?
**其实无论怎么初始化权重,最终都会趋于平稳**。数学上这个问题被称为马氏链平稳状态定理,我们大致了解一下,只要所有状态之间都是互相可达,且整个转移过程没有周期性。那么无论如何初始化,只要状态转移矩阵是确定的,最终整个马氏链一定会趋于稳定。背后的数学就不仔细展开讲解了,你感兴趣的话可以自己搜索了解一下。
我们用一个论文中具体的例子来看一看这个过程假设现在有三个网页A、B、C之间的链接关系就是下面的图
![图片](https://static001.geekbang.org/resource/image/04/ef/0422af2f47ed637d1f42479b21f071ef.jpg?wh=1920x1145)
如果按照前面说的方式进行权重的转移我们可以得到一个类似于马氏链的状态转移矩阵矩阵的每一列都代表某个网页的权重如何传递到其他网页上A的50%到B50%到CB的100%到CC的100%到A
$$
\\left\[\\begin{array}{ccc}
0 & 0 & 1 \\\\\\
0.5 & 0 & 0 \\\\\\
0.5 & 1 & 0
\\end{array}\\right\]
$$
所以如果用这个矩阵乘以表示每个页面权重的向量,得到的新的向量,自然代表一轮计算之后的网页权重。
假设我们初始化三个网页的权重都是 1/3 ,那么:
$$
\\left\[\\begin{array}{ccc}
0 & 0 & 1 \\\\\\
0.5 & 0 & 0 \\\\\\
0.5 & 1 & 0
\\end{array}\\right\] \*\\left\[\\begin{array}{c}
1 / 3 \\\\\\
1 / 3 \\\\\\
1 / 3
\\end{array}\\right\]=\\left\[\\begin{array}{c}
1 / 3 \\\\\\
1 / 6 \\\\\\
1 / 2
\\end{array}\\right\]
$$
经过一次权重分配计算之后,得到的新的权重是 1/3、1/6、1/2。
我们继续反复进行这样的转移和分配进行12次计算之后得到的新的权重就是 (77/192 19/96 77/192) 写成小数是 (0.401 0.198 0.401),很容易发现,权重会渐渐收敛到 0.4 0.2 0.4。
这其实就是所谓的马氏平稳状态。如果你用 0.4 0.2 0.4 作为网页权重再做一次计算,会发现整个网页的权重不会再有任何变化。
$$
\\left\[\\begin{array}{ccc}
0 & 0 & 1 \\\\\\
0.5 & 0 & 0 \\\\\\
0.5 & 1 & 0
\\end{array}\\right\] \*\\left\[\\begin{array}{c}
0.4 \\\\\\
0.2 \\\\\\
0.4
\\end{array}\\right\]=\\left\[\\begin{array}{c}
0.4 \\\\\\
0.2 \\\\\\
0.4
\\end{array}\\right\]
$$
总的来说在这个简单例子里无论如何初始化网页权重在这样的链接情况下所有网页的权重都是固定可以计算出来的。A和C的权重高一些也很容易理解因为相比于B有更多的链接指向A和C。
在更大的图上,这样的收敛性质,在所有节点强连通的情况下依然是可以得到保障的。所以在有限次的计算中,一旦给定了网页链接图,所有网页的权重都是收敛到一个稳定值的。那么在搜索的时候我们基于这个权重来做排名,就可以得到一个比较优的搜索结果了。
但是,在推导问题时我们用的例子是一种特殊的网络链接图,所有的节点都是既有出边也有入边的,对于真实的网页链接来说,考虑到边界情况,还有两个问题需要处理一下:
* Dangling links
* Spider Traps
### Dangling Links
第一个就是Dangling links指的是那些有入边但是没有出边的节点在网页链接图中有这样的节点存在会导致很大的问题。
我们仔细思考一下刚刚的计算过程,很容易发现,整个过程的所有页面权重之和是没有变化的,这是因为每个被转移到某个节点的权重,都会在下一轮计算中被平均地分配到其他页面上。
但是如果出现了Dangling links情况会大有不同所有分配到那些只有入边但没有出边的节点上的权重都不会再有机会转移到其他节点了。这些节点就像一个个黑洞吸收着网页上所有的权重随着不断迭代整个图上的权重仍然会收敛但是会全部收敛为0。这显然是不可接受的。
那要如何解决这个问题呢Larry Page 认为这样的节点对其他节点权重的计算没有任何贡献既然让它们参与计算会导致所有权重收敛为0不如直接把它们都移除。
不过这个移除过程可能会导致新的 Dangling links 产生,所以整个过程需要迭代地进行,直到剩余的网页中不再存在 Dangling links。当然被移除的额外节点会对整个马氏链的计算产生一定影响但是Larry Page 认为这个影响在谷歌搜索引擎收纳的巨量网页前是可以接受的。
移除后,等新的网页链接图上的计算收敛,我们再把被移除的节点加入图中,进行权重计算,但是不去修改所有已经收敛的权重,这样就可以得到全部网页的权重了。
### 平滑性和蜘蛛陷阱
第二个要处理的问题是 Spider Traps。这个问题和Dangling links的情况有点像说的是图中另一种节点虽然有出链但是出链只指向自己。
![图片](https://static001.geekbang.org/resource/image/19/6f/191895212c3c7cfabf9504fef68d386f.jpg?wh=1920x1145)
比如图中的C就是这样一个节点感兴趣的话你可以自己列一下转移矩阵做一做权重的计算感受一下。如果图中存在这样的节点运行PageRank算法之后权重仍然会收敛不过这次就是所有的权重都集中在C这一个节点上了。
不过即使没有这样的节点存在,因为网页数量众多,而链接相对有限,整个网页链接图是比较稀疏的。
可以想像运行PageRank之后很可能节点之间的权重分布很不均匀有一部分节点处于网络的边缘且入边很少所以权重几乎为零而另一部分权重会比较大。那我们怎样才能做到尽量地共同富裕让权重结果更加均衡一些呢
思路很简单,就是添加一个跳转因子$\\beta$,让每个网页都雨露均沾,相当于增加到所有其他节点的一个链接,只不过权重很小。但是只要增加了,就足以让每个网页的权重不至于接近于零,可以平滑整个图上权重的计算。
新的计算公式是:$$R=(1-\\beta)_M_R+e\*\\beta/N$$
R是权重M是转移矩阵e是单位矩阵。添加了跳转因子$\\beta$后每个新的权重计算就不只依赖原图中的链接还考虑了雨露均沾的跳转因子。这样Spider Traps就会被抑制了至少不会出现某些节点上几乎没有权重的情况可以得到一个比较合理的网页排名权重。
## 利用Spark进行实现
好啦学习了这么多原理性的东西,现在你是不是迫不及待想要实现一下呢?
思路也很简单,无非就是建图,然后迭代地计算权重,重点就是判断一下收敛条件。
比如上一轮结果和这一轮结果的差距是否小于一个阈值是的话就停止计算或者更简单直接规定一个迭代轮次因为即使在很大的图上估计也只需要百次左右的迭代整个网络就会趋于收敛了。如果你是数据分析高手可能直接用Python套个科学计算的库利用矩阵计算10几行代码就可以完成这项工作了。
听起来很完美是吧但是不要忘了我们面对的是海量的网站数据单机的环境是远远搞不定的这个时候就是我们上一节课学习的MapReduce的用武之地了。
不过MapReduce和Hadoop确实已经是上一代的大数据计算引擎了我们这次就用Spark来实现一下如果你对Spark不熟也没有关系整个实现非常简单相信你看代码也很容易理解。主要是想说明一下MapReduce这样的计算框架的高度抽象和泛化能力让它可以解决绝大部分分布式计算问题而PageRank正是绝佳的使用场景。
废话不多说,我们直接写代码:
```scala
def main(args: Array[String]) {
if (args.length < 1) {
System.err.println("Usage: SparkPageRank <file> <iter>")
System.exit(1)
}
showWarning()
// spark 初始化
val spark = SparkSession
.builder
.appName("SparkPageRank")
.getOrCreate()
// 迭代轮次
val iters = if (args.length > 1) args(1).toInt else 10
val lines = spark.read.textFile(args(0)).rdd
// 转化为邻接表
val links = lines.map{ s =>
val parts = s.split("\\s+")
(parts(0), parts(1))
}.distinct().groupByKey().cache()
// 初始化所有节点的权重为1
var ranks = links.mapValues(v => 1.0)
for (i <- 1 to iters) {
// 将所有的处边对应的权重分配计算出来
val contribs = links
.join(ranks)
.values
.flatMap{ case (urls, rank) =>
val size = urls.size
urls.map(url => (url, rank / size))
}
// 进行累积求和
ranks = contribs.reduceByKey(_ + _).mapValues(0.15 + 0.85 * _)
}
val output = ranks.collect()
output.foreach(tup => println(tup._1 + " has rank: " + tup._2 + "."))
spark.stop()
}
```
假设我们输入的是网页之间的边表:
```scala
url_1 url_4
url_2 url_1
url_3 url_2
url_3 url_1
url_4 url_3
url_4 url_1
```
代码会先把边表转化为邻接表,相关的概念我们之前学搜索和网络的时候都有提过,你不熟悉的话可以看[这里](https://time.geekbang.org/column/article/475478)回顾。
![图片](https://static001.geekbang.org/resource/image/86/85/862065762ba56e2111yy0ddd44173485.jpg?wh=1920x1145)
有了邻接表之后主要就是两步:
* 第一步是把当前轮次的所有网页权重分配出去,就按照权重除以出边数量进行分配,映射为 <被链接网页被分配权重> 的 key-value 对。
* 第二步汇聚这些key-value对通过reduce进行求和。
整个过程以及中间结果都是在Spark计算集群中分布式计算、分布式存储的。而且写出来的核心代码非常简洁这就是MapReduce的威力了。
经过20轮的迭代在上面的图中我们可以得到这样的计算结果
```scala
url_4 has rank: 1.3705281840649928.
url_2 has rank: 0.4613200524321036.
url_3 has rank: 0.7323900229505396.
url_1 has rank: 1.4357617405523626.
```
感兴趣你也可以自己部署一个单机版的Spark试一试。
## 总结
今天我们学习了谷歌三驾马车之二 PageRank 算法,核心是利用网页之间的链接关系,通过迭代的方式计算网页的权重,帮助谷歌获得了更好的搜索质量,打败了竞争对手。
PageRank 的应用非常多比如一个很常见的它可以用来帮助微博挖掘平台上有影响力的大V。这些应用往往都需要大量的计算资源MapReduce或者Spark这样的分布式计算平台可以很好地帮助我们屏蔽底层的技术细节而将研发人员的精力都放在业务开发之上。
其中为了解决Spider Traps增加跳转因子的思想也很常见。比如各种广告系统或者推荐系统在广告或者内容没有历史数据的时候我们就会为这些内容提供一些试探流量。这背后的思想其实和跳转因子也是类似的。相信当类似业务需要出现时现在你可以想到解决方案了。
### 课后作业
最后也给你再留一个开放式思考题这样的PageRank算法有什么可以进一步优化的地方吗
很期待在评论区看到你的想法。如果觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友一起学习。下节课见~