本文是在学习graph的时候顺便翻译为中文,以便以后查阅,如果大家阅读过程中发现问题,请大家指正。thx
目录
Aggregate Messages (aggregateMessages)
概述
GraphX是Spark中的一个新组件,用于图形和图形并行计算。在高层次上,GraphX通过扩展Spark RDD来引入一种新的Graph抽象:一个有向多重图,其属性附加到每个顶点和边。为了支持图形计算,GraphX给出了一组基本操作符(例如, subgraph, joinVertices, 和 aggregateMessages)以及Pregel API的优化变体。此外,GraphX还包括越来越多的图形算法和构建器,以简化图形分析任务。
开始
首先,您需要将Spark和GraphX导入到项目中,如下所示:
import org.apache.spark._
import org.apache.spark.graphx._
// To make some of the examples work we will also need RDD
import org.apache.spark.rdd.RDD
如果您不使用Spark shell,您还需要一个SparkContext。要了解关于使用Spark的更多信息,请参考Spark快速启动指南。
属性图
属性图是一个有向多重图,用户定义的对象在该类图能关联到每个顶点和边。有向的多重图是一个有向图,具有多个平行边,这些边共享同一个源和目的顶点。支持并行边的能力简化了在相同顶点之间可能存在多个关系(如同事和朋友)的建模场景。每个顶点都由一个唯一的64位长标识符(VertexId)进行键控。GraphX不对顶点标识符施加任何排序约束。同样,边也有相应的源和目标顶点标识符。
PS:在无向图中,关联一对顶点的无向边如果多于1条,则称这些边为平行边,平行边的条数称为重数。在有向图中,关联一对顶点的有向边如果多于1条,并且这些边的始点与终点相同(也就是他们的方向相同),称这些边为平行边。含平行边的图称为多重图,既不含平行边也不含环的图称为简单图。
属性图在顶点(VD)和边(ED)类型上参数化。它们分别是与每个顶点和边相关联的对象的类型。
GraphX优化了顶点和边类型的表示,当它们是原始数据类型(例如int、double等)时,通过将它们存储在专用数组中来减少内存占用。
在某些情况下,可能需要在同一图中具有不同属性类型的顶点。这可以通过继承来实现。例如,我们可以将用户和产品作为一个二部图来建模:
(PS:二分图又称作二部图,是图论中的一种特殊模型。 设G=(V,E)是一个无向图,如果顶点V可分割为两个互不相交的子集(A,B),并且图中的每条边(i,j)所关联的两个顶点i和j分别属于这两个不同的顶点集(i in A,j in B),则称图G为一个二分图。)
class VertexProperty()
case class UserProperty(val name: String) extends VertexProperty
case class ProductProperty(val name: String, val price: Double) extends VertexProperty
// The graph might then have the type:
var graph: Graph[VertexProperty, String] = null
与RDDs一样,属性图也是不可变的、分布的和容错的。对图的值或结构的更改是通过生成具有所需更改的新图来完成的。注意原始图形的重要部分(例如未受影响的结构、属性和索引)在新的图中重用,从而降低了这种固有的功能数据结构的成本。使用顶点范围启发式分区在执行程序之间对图进行分区(The graph is partitioned across the executors using a range of vertex partitioning heuristics.)。与RDDs一样,在发生故障时,可以在不同的机器上重新创建图的每个分区。
在逻辑上,属性图对应于一对类型化集合(RDDs),它们编码每个顶点和边的属性。因此,图类包含访问图的顶点和边的成员:
class Graph[VD, ED] {
val vertices: VertexRDD[VD]
val edges: EdgeRDD[ED]
}
类VertexRDD[VD]和EdgeRDD[ED]扩展并分别是RDD[(VertexId, VD)]和RDD[Edge]的优化版本。VertexRDD[VD]和EdgeRDD[ED]都提供了基于图形计算和内部优化的附加功能。在顶点和边缘RDDs一节中,我们将更详细地讨论VertexRDD和EdgeRDD API,但目前它们可以被看作是简单的RDDs形式: RDD[(VertexId, VD)]和RDD[edge [ED]。
属性图示例
假设我们要构造一个属性图,其中包含GraphX项目中的各个组件。顶点属性可能包含用户名和职业。我们可以用描述合作者之间关系的字符串来注释边:
得到的图形将具有类型签名:
val userGraph: Graph[(String, String), String]
从原始文件、RDDs甚至合成生成器构建属性图的方法有很多,在关于图形构建器的一节中将更详细地讨论这些方法。大概最常用的方法是使用图形对象。例如,下面的代码从一组RDDs构建了一个图形:
// Assume the SparkContext has already been constructed
val sc: SparkContext
// Create an RDD for the vertices
val users: RDD[(VertexId, (String, String))] =
sc.parallelize(Array((3L, ("rxin", "student")), (7L, ("jgonzal", "postdoc")),
(5L, ("franklin", "prof")), (2L, ("istoica", "prof"))))
// Create an RDD for edges
val relationships: RDD[Edge[String]] =
sc.parallelize(Array(Edge(3L, 7L, "collab"), Edge(5L, 3L, "advisor"),
Edge(2L, 5L, "colleague"), Edge(5L, 7L, "pi")))
// Define a default user in case there are relationship with missing user
val defaultUser = ("John Doe", "Missing")
// Build the initial Graph
val graph = Graph(users, relationships, defaultUser)
在上面的例子中,我们使用了Edge case类。边缘有一个srcId和一个dstId,对应于源和目标顶点标识符。此外,Edge类还有一个attr成员,它存储Edge属性。
我们可以分别的使用graph.vertices 和 graph.edges来解构一个图,把它分解成各自的顶点和边视图。
val graph: Graph[(String, String), String] // Constructed from above
// Count all users which are postdocs
graph.vertices.filter { case (id, (name, pos)) => pos == "postdoc" }.count
// Count all the edges where src > dst
graph.edges.filter(e => e.srcId > e.dstId).count
注意: graph.vertices返回一个VertexRDD[(String, String)]],它扩展了RDD[(VertexId, (String, String)]],因此我们使用scala case表达式来解构tuple。另一方面,graph.edges返回包含Edge[String]对象的EdgeRDD。我们还可以使用case类类型构造函数,如下所示:
graph.edges.filter { case Edge(src, dst, prop) => src > dst }.count
除了属性图的顶点和边缘视图之外,GraphX还公开了一个三元组视图。三元组视图在逻辑上连接到包含EdgeTriplet类实例的顶点和边缘属性(EdgeTriplet[VD, ED])。这个连接可以用以下SQL表达式表示:
SELECT src.id, dst.id, src.attr, e.attr, dst.attr FROM edges AS e LEFT JOIN vertices AS src, vertices AS dst ON e.srcId = src.Id AND e.dstId = dst.Id
或图形
EdgeTriplet类通过添加srcAttr和dstAttr成员来扩展Edge类,srcAttr和dstAttr分别包含源属性和目标属性的。我们可以使用图形的三元组视图来呈现一个集合,该集合是描述用户之间关系的字符串集合。
val graph: Graph[(String, String), String] // Constructed from above
// Use the triplets view to create an RDD of facts.
val facts: RDD[String] =
graph.triplets.map(triplet =>
triplet.srcAttr._1 + " is the " + triplet.attr + " of " + triplet.dstAttr._1)
facts.collect.foreach(println(_))
图操作
正如RDDs有基本的操作,如map、filter和reduceByKey,属性图也有一组基本的操作符,它们接受用户定义的函数并通过转换属性和结构来生成新图。优化实现后的核心操作定义在Graph 中,表示为核心操作组合的方便操作定义在GraphOps中。然而,归功于Scala的隐式转换,GraphOps中的操作可自动成为Graph的成员。例如,我们可以通过以下方法计算每个顶点的入度(用图形表示):
val graph: Graph[(String, String), String]
// Use the implicit GraphOps.inDegrees operator
val inDegrees: VertexRDD[Int] = graph.inDegrees
区分核心图操作和 GraphOps的原因是为了能够在未来支持不同的图形表示。每个图表示必须提供核心操作的实现,并重用在GraphOps中定义的许多有用操作。
图操作概览
下面是对Graph 和GraphOps中定义的功能的快速总结,但是为了简单起见,它作为图形的成员呈现。注意,一些函数签名已经被简化(例如,删除了默认参数和类型约束),一些更高级的功能也已经被删除,因此请查阅API文档以获得正式的操作列表。
/** Summary of the functionality in the property graph */
class Graph[VD, ED] {
// Information about the Graph ===================================================================
val numEdges: Long
val numVertices: Long
val inDegrees: VertexRDD[Int]
val outDegrees: VertexRDD[Int]
val degrees: VertexRDD[Int]
// Views of the graph as collections =============================================================
val vertices: VertexRDD[VD]
val edges: EdgeRDD[ED]
val triplets: RDD[EdgeTriplet[VD, ED]]
// Functions for caching graphs ==================================================================
def persist(newLevel: StorageLevel = StorageLevel.MEMORY_ONLY): Graph[VD, ED]
def cache(): Graph[VD, ED]
def unpersistVertices(blocking: Boolean = true): Graph[VD, ED]
// Change the partitioning heuristic ============================================================
def partitionBy(partitionStrategy: PartitionStrategy): Graph[VD, ED]
// Transform vertex and edge attributes ==========================================================
def mapVertices[VD2](map: (VertexId, VD) => VD2): Graph[VD2, ED]
def mapEdges[ED2](map: Edge[ED] => ED2): Graph[VD, ED2]
def mapEdges[ED2](map: (PartitionID, Iterator[Edge[ED]]) => Iterator[ED2]): Graph[VD, ED2]
def mapTriplets[ED2](map: EdgeTriplet[VD, ED] => ED2): Graph[VD, ED2]
def mapTriplets[ED2](map: (PartitionID, Iterator[EdgeTriplet[VD, ED]]) => Iterator[ED2])
: Graph[VD, ED2]
// Modify the graph structure ====================================================================
def reverse: Graph[VD, ED]
def subgraph(
epred: EdgeTriplet[VD,ED] => Boolean = (x => true),
vpred: (VertexId, VD) => Boolean = ((v, d) => true))
: Graph[VD, ED]
def mask[VD2, ED2](other: Graph[VD2, ED2]): Graph[VD, ED]
def groupEdges(merge: (ED, ED) => ED): Graph[VD, ED]
// Join RDDs with the graph ======================================================================
def joinVertices[U](table: RDD[(VertexId, U)])(mapFunc: (VertexId, VD, U) => VD): Graph[VD, ED]
def outerJoinVertices[U, VD2](other: RDD[(VertexId, U)])
(mapFunc: (VertexId, VD, Option[U]) => VD2)
: Graph[VD2, ED]
// Aggregate information about adjacent triplets =================================================
def collectNeighborIds(edgeDirection: EdgeDirection): VertexRDD[Array[VertexId]]
def collectNeighbors(edgeDirection: EdgeDirection): VertexRDD[Array[(VertexId, VD)]]
def aggregateMessages[Msg: ClassTag](
sendMsg: EdgeContext[VD, ED, Msg] => Unit,
mergeMsg: (Msg, Msg) => Msg,
tripletFields: TripletFields = TripletFields.All)
: VertexRDD[A]
// Iterative graph-parallel computation ==========================================================
def pregel[A](initialMsg: A, maxIterations: Int, activeDirection: EdgeDirection)(
vprog: (VertexId, VD, A) => VD,
sendMsg: EdgeTriplet[VD, ED] => Iterator[(VertexId,A)],
mergeMsg: (A, A) => A)
: Graph[VD, ED]
// Basic graph algorithms ========================================================================
def pageRank(tol: Double, resetProb: Double = 0.15): Graph[Double, Double]
def connectedComponents(): Graph[VertexId, ED]
def triangleCount(): Graph[Int, ED]
def stronglyConnectedComponents(numIter: Int): Graph[VertexId, ED]
}
属性操作
与RDD映射操作一样,属性图包含以下内容:
class Graph[VD, ED] {
def mapVertices[VD2](map: (VertexId, VD) => VD2): Graph[VD2, ED]
def mapEdges[ED2](map: Edge[ED] => ED2): Graph[VD, ED2]
def mapTriplets[ED2](map: EdgeTriplet[VD, ED] => ED2): Graph[VD, ED2]
}
这些操作中的每一个都产生一个新的图形,其顶点或边属性由用户定义的映射函数修改。
注意:在每种情况下,图形结构都不受影响。这是这些操作符的一个关键特性,它允许结果图重用原始图的结构索引。下面的代码片段在逻辑上是等价的,但是第一个代码片段没有保存结构索引,也不会从GraphX系统优化中获益:
val newVertices = graph.vertices.map { case (id, attr) => (id, mapUdf(id, attr)) }
val newGraph = Graph(newVertices, graph.edges)
相反,使用map顶点来保存索引:
val newGraph = graph.mapVertices((id, attr) => mapUdf(id, attr))
这些操作符通常用于初始化特定计算的图,或去掉不必要的属性(project away)。例如,给定这样一个图-----把顶点的出度作为顶点属性(我们稍后将描述如何构造这样的图),我们将它初始化以用于PageRank:
// Given a graph where the vertex property is the out degree
val inputGraph: Graph[Int, String] =
graph.outerJoinVertices(graph.outDegrees)((vid, _, degOpt) => degOpt.getOrElse(0))
// Construct a graph where each edge contains the weight
// and each vertex is the initial PageRank
val outputGraph: Graph[Double, Double] =
inputGraph.mapTriplets(triplet => 1.0 / triplet.srcAttr).mapVertices((id, _) => 1.0)
结构操作
目前,GraphX只支持一组常用的结构操作符,我们预计将来还会添加更多。下面是基本结构运算符的列表。
class Graph[VD, ED] {
def reverse: Graph[VD, ED]
def subgraph(epred: EdgeTriplet[VD,ED] => Boolean,
vpred: (VertexId, VD) => Boolean): Graph[VD, ED]
def mask[VD2, ED2](other: Graph[VD2, ED2]): Graph[VD, ED]
def groupEdges(merge: (ED, ED) => ED): Graph[VD,ED]
}
reverse操作返回一个新的图形,所有的边的方向都是反向的。例如,当尝试计算逆PageRank时,这可能是有用的。由于反向操作不修改顶点或边属性或改变边的数量,因此可以有效地实现它,而无需数据移动或重复。
subgraph 操作使用顶点和边谓词,并返回仅包含满足顶点谓词(求值为true)的顶点和满足边谓词的边并连接满足顶点谓词的顶点的图。子图运算可以在许多情况下使用,将图形限制在感兴趣的顶点和边,或者消除损坏的链接。例如,在以下代码中,我们删除了断开链接:
// Create an RDD for the vertices
val users: RDD[(VertexId, (String, String))] =
sc.parallelize(Array((3L, ("rxin", "student")), (7L, ("jgonzal", "postdoc")),
(5L, ("franklin", "prof")), (2L, ("istoica", "prof")),
(4L, ("peter", "student"))))
// Create an RDD for edges
val relationships: RDD[Edge[String]] =
sc.parallelize(Array(Edge(3L, 7L, "collab"), Edge(5L, 3L, "advisor"),
Edge(2L, 5L, "colleague"), Edge(5L, 7L, "pi"),
Edge(4L, 0L, "student"), Edge(5L, 0L, "colleague")))
// Define a default user in case there are relationship with missing user
val defaultUser = ("John Doe", "Missing")
// Build the initial Graph
val graph = Graph(users, relationships, defaultUser)
// Notice that there is a user 0 (for which we have no information) connected to users
// 4 (peter) and 5 (franklin).
graph.triplets.map(
triplet => triplet.srcAttr._1 + " is the " + triplet.attr + " of " + triplet.dstAttr._1
).collect.foreach(println(_))
// Remove missing vertices as well as the edges to connected to them
val validGraph = graph.subgraph(vpred = (id, attr) => attr._2 != "Missing")
// The valid subgraph will disconnect users 4 and 5 by removing user 0
validGraph.vertices.collect.foreach(println(_))
validGraph.triplets.map(
triplet => triplet.srcAttr._1 + " is the " + triplet.attr + " of " + triplet.dstAttr._1
).collect.foreach(println(_))
注意:在上面的示例中只提供顶点谓词。如果没有提供顶点或边的谓词,子图操作默认为true。
mask 操作通过返回包含输入图中也可以找到的顶点和边的图来构造子图。这可以与子图运算符一起使用,以根据另一个相关图中的属性来限制图。例如,我们可以使用缺少顶点的图形来运行连接的组件,然后将答案限制在有效的子图上。
// Run Connected Components
val ccGraph = graph.connectedComponents() // No longer contains missing field
// Remove missing vertices as well as the edges to connected to them
val validGraph = graph.subgraph(vpred = (id, attr) => attr._2 != "Missing")
// Restrict the answer to the valid subgraph
val validCCGraph = ccGraph.mask(validGraph)
groupEdges操作合并平行边(即,顶点对之间的重复边)在多重图中。在许多数值应用中,可以将平行边(它们的权重组合)加到单个边,从而减小图形的大小。
连接运算
在许多情况下,需要将来自外部集合(RDDs)的数据与图形连接起来。例如,我们可能有额外的用户属性,我们希望与现有的图合并,或者我们可能希望将顶点属性从一个图拉到另一个图。可以使用join操作符完成这些任务。下面列出键连接操作符:
class Graph[VD, ED] {
def joinVertices[U](table: RDD[(VertexId, U)])(map: (VertexId, VD, U) => VD)
: Graph[VD, ED]
def outerJoinVertices[U, VD2](table: RDD[(VertexId, U)])(map: (VertexId, VD, Option[U]) => VD2)
: Graph[VD2, ED]
}
joinVertices操作符将这些顶点与输入RDD连接起来,并将用户定义的map 函数应用于被连接顶点的的结果作为连接顶点的值,从而获得具有新顶点属性的图。在RDD中没有匹配值的顶点保留它们的原始值。
注意:如果RDD包含给定顶点的多个值,则只使用一个值。因此,建议使用以下命令使输入RDD惟一,这些命令还将对结果值进行索引,从而大大加快后续连接。
val nonUniqueCosts: RDD[(VertexId, Double)]
val uniqueCosts: VertexRDD[Double] =
graph.vertices.aggregateUsingIndex(nonUnique, (a,b) => a + b)
val joinedGraph = graph.joinVertices(uniqueCosts)(
(id, oldCost, extraCost) => oldCost + extraCost)
更一般的outerJoinVertices的行为类似于joinVertices,但用户定义的map函数适用于所有顶点,并可更改顶点属性类型。因为并非所有的顶点在输入RDD中都有匹配值,所以映射函数采用选项类型。例如,我们可以通过初始化具有输出度的顶点属性来为PageRank建立一个图。
val outDegrees: VertexRDD[Int] = graph.outDegrees
val degreeGraph = graph.outerJoinVertices(outDegrees) { (id, oldAttr, outDegOpt) =>
outDegOpt match {
case Some(outDeg) => outDeg
case None => 0 // No outDegree means zero outDegree
}
}
您应该已经注意到上述示例中使用的多个参数列表(例如,f(a)(b))柯里化函数模式。虽然我们可以把f(a)(b)写成f(a,b),但这意味着对b的类型推断不依赖于a,因此,用户需要为用户定义的函数提供类型注释:
val joinedGraph = graph.joinVertices(uniqueCosts,
(id: VertexId, oldCost: Double, extraCost: Double) => oldCost + extraCost)
邻域聚合
在许多图形分析任务中,一个关键步骤是聚合关于每个顶点的邻域的信息。例如,我们可能想知道每个用户拥有的关注者的数量,或者每个用户的关注者的平均年龄。许多迭代图算法(如PageRank、最短路径和连通分量)重复聚合相邻顶点的属性(如当前PageRank值、到源的最短路径和最小可达顶点id)。
为了提高性能,基础聚合操作符被从graph.mapReduceTriplets 改为graph.AggregateMessages。虽然API中的更改相对较小,但是我们还是在下面提供了一个转换指南。
Aggregate Messages (aggregateMessages)
GraphX中的核心聚合操作是aggregateMessages。该操作符将用户定义的sendMsg函数应用于图中的每个边三元组,然后使用mergeMsg函数在目标顶点聚合这些消息。
class Graph[VD, ED] {
def aggregateMessages[Msg: ClassTag](
sendMsg: EdgeContext[VD, ED, Msg] => Unit,
mergeMsg: (Msg, Msg) => Msg,
tripletFields: TripletFields = TripletFields.All)
: VertexRDD[Msg]
}
用户定义的sendMsg函数接受EdgeContext 类型的参数,它公开源和目标节点的属性以及边缘属性和函数(sendToSrc和sendToDst),以便向源和目标属性发送消息。可以将sendMsg看作map-reduce中的map函数。用户定义的mergeMsg函数接受两个指向相同顶点的消息,并生成一条消息。可以将mergeMsg看作map-reduce中的reduce函数。aggregateMessages操作符返回一个VertexRDD[Msg],其中包含指向每个顶点的聚合消息(Msg类型)。没有接收到消息的顶点不包括在返回的VertexRDDVertexRDD中。
此外,aggregateMessages接受一个可选的tripletsFields,该字段指示在EdgeContext中什么数据被接收(即,而不是目标顶点属性)。tripletsFields的可能选项在TripletFields中定义,默认值是TripletFields.All,它表明用户定义的sendMsg函数可以访问EdgeContext中的任何字段。tripletFields参数可以用来通知GraphX,只需要部分EdgeContext就可以让GraphX选择优化的连接策略。例如,如果我们计算每个用户关注者的平均年龄,我们只需要源字段,因此我们将使用TripletFields.Src,以表示我们只需要源字段。
在早期的GraphX版本中,我们使用字节码检查来推断TripletFields,但是我们发现字节码检查稍微不可靠,因此选择了更显式的用户控制。
在下面的例子中,我们使用aggregateMessages操作符来计算每个用户的高级跟随者的平均年龄。
import org.apache.spark.graphx.{Graph, VertexRDD}
import org.apache.spark.graphx.util.GraphGenerators
// Create a graph with "age" as the vertex property.
// Here we use a random graph for simplicity.
val graph: Graph[Double, Int] =
GraphGenerators.logNormalGraph(sc, numVertices = 100).mapVertices( (id, _) => id.toDouble )
// Compute the number of older followers and their total age
val olderFollowers: VertexRDD[(Int, Double)] = graph.aggregateMessages[(Int, Double)](
triplet => { // Map Function
if (triplet.srcAttr > triplet.dstAttr) {
// Send message to destination vertex containing counter and age
triplet.sendToDst((1, triplet.srcAttr))
}
},
// Add counter and age
(a, b) => (a._1 + b._1, a._2 + b._2) // Reduce Function
)
// Divide total age by number of older followers to get average age of older followers
val avgAgeOfOlderFollowers: VertexRDD[Double] =
olderFollowers.mapValues( (id, value) =>
value match { case (count, totalAge) => totalAge / count } )
// Display the results
avgAgeOfOlderFollowers.collect.foreach(println(_))
在Spark repo中“examples/src/main/scala/ org/apache/spark/examples/graphx/aggregatemessagese xample”找到完整的示例代码。
aggregateMessages操作在消息(以及消息的和)大小不变的情况下(例如,浮动和加法而不是列表和连接)执行最优。
Map Reduce 三元组过度指南(遗留)
在早期版本的GraphX中,邻域聚合是使用mapReduceTriplets操作符完成的:
class Graph[VD, ED] {
def mapReduceTriplets[Msg](
map: EdgeTriplet[VD, ED] => Iterator[(VertexId, Msg)],
reduce: (Msg, Msg) => Msg)
: VertexRDD[Msg]
}
mapReduceTriplets操作符接受一个用户定义的映射函数,该函数应用于每个三元组,并可以生成使用用户定义的reduce函数聚合的消息。然而,我们发现返回迭代器的用户开销很大,这限制了我们应用额外优化(例如,局部顶点重编号)的能力。在aggregateMessages中,我们引入了EdgeContext,它公开了三元组字段,还提供了显式地向源顶点和目标顶点发送消息的函数。此外,我们删除了字节码检查,而是要求用户指出实际上需要三元组中的哪些字段。
下面的代码块使用mapReduceTriplets:
val graph: Graph[Int, Float] = ...
def msgFun(triplet: Triplet[Int, Float]): Iterator[(Int, String)] = {
Iterator((triplet.dstId, "Hi"))
}
def reduceFun(a: String, b: String): String = a + " " + b
val result = graph.mapReduceTriplets[String](msgFun, reduceFun)
可以使用aggregateMessages重写为:
val graph: Graph[Int, Float] = ...
def msgFun(triplet: EdgeContext[Int, Float, String]) {
triplet.sendToDst("Hi")
}
def reduceFun(a: String, b: String): String = a + " " + b
val result = graph.aggregateMessages[String](msgFun, reduceFun)
计算度
一个常见的聚合任务是计算每个顶点的度:每个顶点的邻接边的数量。在有向图中,通常需要知道每个顶点的入度、出度和总的度。GraphOps类包含一组运算符,用于计算每个顶点的度数。例如,在下面的例子中,我们计算入、出和总的度的最大值:
// Define a reduce operation to compute the highest degree vertex
def max(a: (VertexId, Int), b: (VertexId, Int)): (VertexId, Int) = {
if (a._2 > b._2) a else b
}
// Compute the max degrees
val maxInDegree: (VertexId, Int) = graph.inDegrees.reduce(max)
val maxOutDegree: (VertexId, Int) = graph.outDegrees.reduce(max)
val maxDegrees: (VertexId, Int) = graph.degrees.reduce(max)
Collecting Neighbors
在某些情况下,通过收集相邻顶点及其在每个顶点上的属性,以更容易表达计算。这可以使用collectorids和collectNeighbors操作符轻松完成。
class GraphOps[VD, ED] {
def collectNeighborIds(edgeDirection: EdgeDirection): VertexRDD[Array[VertexId]]
def collectNeighbors(edgeDirection: EdgeDirection): VertexRDD[ Array[(VertexId, VD)] ]
}
这些操作可能非常消耗资源,因为他们复制信息并需要大量的沟通。如果可能,尝试直接使用aggregateMessages运算符表示相同的计算。
Caching and Uncaching
在Spark中,默认情况下rdd不会持久化在内存中。为了避免重新计算,在多次使用它们时必须显式地缓存它们(请参阅Spark编程指南)。GraphX中的图具有相同的行为。当多次使用图时,请确保首先对其调用graph .cache()。
在迭代计算中,为了获得最佳性能,uncaching也是必要的。默认情况下,缓存的rdd和图形将保留在内存中,直到内存压力迫使它们按照LRU顺序被驱逐。对于迭代计算,以前迭代的中间结果将填充缓存。虽然它们最终会被清除,但是存储在内存中的不必要的数据会减慢垃圾收集。更有效的方式是一旦不再需要中间结果,就马上释放它们。这涉及到在每次迭代中物化(缓存和强制)的图或RDD,释放缓存的所有其他数据集,并且只在未来迭代中使用物化数据集。但是,由于图是由多个RDD组成的,因此很难正确地取消它们的持久化。对于迭代计算,我们建议使用Pregel API,它可以正确地解除中间结果的持久化。
Pregel API
图本质上是递归的数据结构,因为顶点的属性依赖于相邻点的属性,而相邻点的属性又依赖于相邻点的属性。因此,许多重要的图算法迭代地重新计算每个顶点的属性,直到达到一个不动点条件。为了表达这些迭代算法,提出了一系列图形并行抽象。GraphX公开了Pregel API的一个变体。
在较高的层次上,GraphX中的Pregel操作是一个受图拓扑约束的大量同步并行消息传递抽象。Pregel操作符在一系列超级步骤中执行,在这些步骤中,顶点接收来自前一个超级步骤的入站消息的总和,为顶点属性计算一个新值,然后在下一个超级步骤中将消息发送给相邻的顶点。与Pregel不同,消息是作为边缘三元组的函数并行计算的,消息计算可以访问源和目标顶点属性。没有接收到消息的顶点将在超步骤中跳过。Pregel操作符终止迭代,并在没有剩余消息时返回最终的图形。
注意:与更多标准的Pregel实现不同,GraphX中的顶点只能向相邻的顶点发送消息,消息构造是使用用户定义的消息传递函数并行完成的。这些约束允许在GraphX中进行额外的优化。
下面是Pregel操作的类型签名及其实现的描述(注意:为了避免由于长继承链而导致的stackOverflowError, Pregel通过设置“spark.graphx.pregel”定期支持检查点图和消息。如果是正数,比如说10。并使用SparkContext设置检查点目录。setCheckpointDir(目录:String)):
class GraphOps[VD, ED] {
def pregel[A]
(initialMsg: A,
maxIter: Int = Int.MaxValue,
activeDir: EdgeDirection = EdgeDirection.Out)
(vprog: (VertexId, VD, A) => VD,
sendMsg: EdgeTriplet[VD, ED] => Iterator[(VertexId, A)],
mergeMsg: (A, A) => A)
: Graph[VD, ED] = {
// Receive the initial message at each vertex
var g = mapVertices( (vid, vdata) => vprog(vid, vdata, initialMsg) ).cache()
// compute the messages
var messages = GraphXUtils.mapReduceTriplets(g, sendMsg, mergeMsg)
var activeMessages = messages.count()
// Loop until no messages remain or maxIterations is achieved
var i = 0
while (activeMessages > 0 && i < maxIterations) {
// Receive the messages and update the vertices.
g = g.joinVertices(messages)(vprog).cache()
val oldMessages = messages
// Send new messages, skipping edges where neither side received a message. We must cache
// messages so it can be materialized on the next line, allowing us to uncache the previous
// iteration.
messages = GraphXUtils.mapReduceTriplets(
g, sendMsg, mergeMsg, Some((oldMessages, activeDirection))).cache()
activeMessages = messages.count()
i += 1
}
g
}
}
注意Pregel有两个参数列表(即graph.pregel (list1)(用于))。第一个参数列表包含配置参数,包括初始消息、最大迭代次数和发送消息的边缘方向(默认情况下沿边缘)。第二个参数列表包含用于接收消息(顶点程序vprog)、计算消息(sendMsg)和组合消息mergeMsg的用户定义函数。
在下面的例子中,我们可以使用Pregel算子来表示计算,例如单源最短路径。
i
import org.apache.spark.graphx.{Graph, VertexId}
import org.apache.spark.graphx.util.GraphGenerators
// A graph with edge attributes containing distances
val graph: Graph[Long, Double] =
GraphGenerators.logNormalGraph(sc, numVertices = 100).mapEdges(e => e.attr.toDouble)
val sourceId: VertexId = 42 // The ultimate source
// Initialize the graph such that all vertices except the root have distance infinity.
val initialGraph = graph.mapVertices((id, _) =>
if (id == sourceId) 0.0 else Double.PositiveInfinity)
val sssp = initialGraph.pregel(Double.PositiveInfinity)(
(id, dist, newDist) => math.min(dist, newDist), // Vertex Program
triplet => { // Send Message
if (triplet.srcAttr + triplet.attr < triplet.dstAttr) {
Iterator((triplet.dstId, triplet.srcAttr + triplet.attr))
} else {
Iterator.empty
}
},
(a, b) => math.min(a, b) // Merge Message
)
println(sssp.vertices.collect.mkString("\n"))
完整的示例代码在“examples/src/main/scala/ org/apache/spark/examples/graphx/ssspexample”中。
Graph Builders
GraphX提供了几种从RDD或磁盘上的顶点和边集合构建图形的方法。默认情况下,没有一个图生成器重新分区图形的边缘;相反,边缘保留在它们的默认分区中(例如HDFS中的原始块)。 Graph.groupEdges要求对图进行重新分区,因为它假定相同的边将在相同的分区上共定位,因此必须在调用groupEdges之前调用Graph.partitionBy进行分区。
object GraphLoader {
def edgeListFile(
sc: SparkContext,
path: String,
canonicalOrientation: Boolean = false,
minEdgePartitions: Int = 1)
: Graph[Int, Int]
}
GraphLoader.edgeListFile 提供从磁盘上的边列表加载图的方法。它解析以下形式的一对邻接表(源顶点ID,目标顶点ID),跳过以#开头的注释行:
# This is a comment
2 1
4 1
1 2
它从指定的边创建一个图形,自动创建边提到的任何顶点。所有顶点和边缘属性默认为1。canonicalOrientation 参数允许在正方向(srcId < dstId)重定向边缘,这是connected components算法所需要的。minEdgePartitions参数指定要生成的边缘分区的最小数量;例如,如果HDFS文件有更多块,那么可能会有比指定的更多的边缘分区。
object Graph {
def apply[VD, ED](
vertices: RDD[(VertexId, VD)],
edges: RDD[Edge[ED]],
defaultVertexAttr: VD = null)
: Graph[VD, ED]
def fromEdges[VD, ED](
edges: RDD[Edge[ED]],
defaultValue: VD): Graph[VD, ED]
def fromEdgeTuples[VD](
rawEdges: RDD[(VertexId, VertexId)],
defaultValue: VD,
uniqueEdges: Option[PartitionStrategy] = None): Graph[VD, Int]
}
Graph.apply允许从顶点和边的rdd创建图形。重复的顶点是任意选取的,在边RDD中找到的顶点(但不是顶点RDD)被指定为默认属性。
Graph.fromEdges允许仅从边缘的RDD创建一个图形,自动创建边缘提到的任何顶点,并为它们分配默认值。
Graph.fromEdgeTuples允许仅从边元组的RDD创建图形,将边赋值为1,并自动创建边提到的任何顶点,并将它们赋值为默认值。它还支持消除边缘的重复;为了去重,传递一些PartitionStrategy作为uniqueedge参数(例如,uniqueedge = Some(PartitionStrategy. randomvertexcut))。分区策略是必要的,以在同一分区上对相同的边进行共定位,以便可以对它们进行重复数据删除。
Vertex and Edge RDDs
GraphX公开存储在图中的顶点和边的RDD视图。但是,因为GraphX在优化的数据结构中维护顶点和边,而这些数据结构提供了额外的功能,所以顶点和边分别作为VertexRDDVertexRDD和EdgeRDDEdgeRDD返回。在本节中,我们将回顾这些类型中一些附加的有用功能。注意,这只是一个不完整的列表,请参考API文档中的官方操作列表。
VertexRDDs
VertexRDD[A]扩展了RDD[(VertexId, A)],并添加了额外的约束,即每个VertexId只出现一次。此外,VertexRDD[A]表示一组顶点,每个顶点都具有A类型的属性。在内部,这是通过将顶点属性存储在可重用的散列映射数据结构中实现的。因此,如果两个vertexrdd来自相同的基本VertexRDD(例如,通过筛选器或映射值),那么无需散列计算,就可以在固定的时间内连接它们。为了利用这种索引数据结构,VertexRDD公开了以下附加功能:
class VertexRDD[VD] extends RDD[(VertexId, VD)] {
// Filter the vertex set but preserves the internal index
def filter(pred: Tuple2[VertexId, VD] => Boolean): VertexRDD[VD]
// Transform the values without changing the ids (preserves the internal index)
def mapValues[VD2](map: VD => VD2): VertexRDD[VD2]
def mapValues[VD2](map: (VertexId, VD) => VD2): VertexRDD[VD2]
// Show only vertices unique to this set based on their VertexId's
def minus(other: RDD[(VertexId, VD)])
// Remove vertices from this set that appear in the other set
def diff(other: VertexRDD[VD]): VertexRDD[VD]
// Join operators that take advantage of the internal indexing to accelerate joins (substantially)
def leftJoin[VD2, VD3](other: RDD[(VertexId, VD2)])(f: (VertexId, VD, Option[VD2]) => VD3): VertexRDD[VD3]
def innerJoin[U, VD2](other: RDD[(VertexId, U)])(f: (VertexId, VD, U) => VD2): VertexRDD[VD2]
// Use the index on this RDD to accelerate a `reduceByKey` operation on the input RDD.
def aggregateUsingIndex[VD2](other: RDD[(VertexId, VD2)], reduceFunc: (VD2, VD2) => VD2): VertexRDD[VD2]
}
注意:例如,过滤器操作符如何返回VertexRDD。Filter实际上是使用BitSet实现的,因此可以重用索引并保留与其他vertexrdd进行快速连接的能力。同样,mapValues操作符不允许map函数更改VertexId,从而允许重用相同的HashMap数据结构。当连接来自相同HashMap的两个VertexRDDs时,leftJoin和innerJoin都能够识别,并通过线性扫描而不是昂贵的点查找实现连接。
aggregateUsingIndex操作对于从一个RDD[(VertexId, a)]高效构造一个新的VertexRDDVertexRDD非常有用。从概念上讲,如果我在一组顶点上构造了一个VertexRDD[B],它是某个RDD[(VertexId, a)]中的顶点的超集,那么我可以重用索引来聚合和索引RDD[(VertexId, a)]。例如:
val setA: VertexRDD[Int] = VertexRDD(sc.parallelize(0L until 100L).map(id => (id, 1)))
val rddB: RDD[(VertexId, Double)] = sc.parallelize(0L until 100L).flatMap(id => List((id, 1.0), (id, 2.0)))
// There should be 200 entries in rddB
rddB.count
val setB: VertexRDD[Double] = setA.aggregateUsingIndex(rddB, _ + _)
// There should be 100 entries in setB
setB.count
// Joining A and B should now be fast!
val setC: VertexRDD[Double] = setA.innerJoin(setB)((id, a, b) => a + b)
EdgeRDDs
EdgeRDD[ED]扩展了RDD[Edge[ED]],它将边组织在块中,这些块是使用PartitionStrategy中定义的一种分区策略来分区。在每个分区内,边缘属性和邻接结构被单独存储,以便在更改属性值时最大限度地重用。
EdgeRDDEdgeRDD公开的三个额外功能是:
// Transform the edge attributes while preserving the structure
def mapValues[ED2](f: Edge[ED] => ED2): EdgeRDD[ED2]
// Reverse the edges reusing both attributes and structure
def reverse: EdgeRDD[ED]
// Join two `EdgeRDD`s partitioned using the same partitioning strategy.
def innerJoin[ED2, ED3](other: EdgeRDD[ED2])(f: (VertexId, VertexId, ED, ED2) => ED3): EdgeRDD[ED3]
在大多数应用程序中,我们发现EdgeRDD 上的操作是通过图形操作符完成的,或者依赖于在基本RDD类中定义的操作。
Optimized Representation
虽然对于分布式图的GraphX表示中使用的优化的详细描述超出了本指南的范围,但是一些深入的理解可能有助于可伸缩算法的设计以及API的最佳使用。GraphX采用vertex-cut方法进行分布式图分区:
GraphX不是沿边分割图形,而是沿顶点对图形进行分区,这样可以减少通信和存储开销。逻辑上,这相当于给机器分配边,并允许顶点跨越多台机器。边的分配方法依赖于PartitionStrategy,不同的启发式有几个折衷。用户可以在不同的策略之间进行选择,使用 Graph.partitionBy算子重新分区图。默认的分区策略是使用图结构中提供的边的初始分区。但是,用户可以轻松切换到2d分区或GraphX中包含的其他启发式。
一旦边缘被分割,有效的图并行计算的关键挑战是有效地将顶点属性与边缘连接起来。因为现实世界中的图通常具有比顶点更多的边,所以我们将顶点属性移动到这些边上。因为不是所有的分区都包含与所有顶点相邻的边,所以我们在内部维护一个路由表,该路由表标识在实现像triplets和aggregateMessages等算子的连接需求时在哪里广播顶点。
Graph Algorithms
GraphX包括一组简化分析任务的图算法。算法包含在 org.apache.spark.graphx.lib包中,而且在Graph 上可以通过GraphOps直接访问这些方法。本节描述算法及其使用方法。
PageRank
PageRank衡量的是一个图中每个顶点的重要性,假设一条从u到v的边代表了u对v重要性的认可。例如,如果一个Twitter用户被很多人关注,这个用户的排名就会很高。
GraphX提供了PageRank的静态和动态实现,作为PageRank对象上的方法。静态PageRank的迭代次数是固定的,而动态PageRank的会一直迭代,直到秩收敛(即,停止更改超过指定的公差)。GraphOps允许将这些算法作为图上的方法直接调用。
GraphX还包括一个示例社交网络数据集,我们可以在其上运行PageRank。数据/图形x/用户中给出了一组用户。在data/graphx/followers.txt中给出了用户之间的一组关系。我们计算每个用户的PageRank如下:
i
import org.apache.spark.graphx.GraphLoader
// Load the edges as a graph
val graph = GraphLoader.edgeListFile(sc, "data/graphx/followers.txt")
// Run PageRank
val ranks = graph.pageRank(0.0001).vertices
// Join the ranks with the usernames
val users = sc.textFile("data/graphx/users.txt").map { line =>
val fields = line.split(",")
(fields(0).toLong, fields(1))
}
val ranksByUsername = users.join(ranks).map {
case (id, (username, rank)) => (username, rank)
}
// Print the result
println(ranksByUsername.collect().mkString("\n"))
源码位置:examples/src/main/scala/org/apache/spark/examples/graphx/PageRankExample.scala
Connected Components
连通分量算法将图的每个连通分量标记为其编号最低顶点的ID。例如,在社交网络中,连接的组件可以近似聚类。GraphX在ConnectedComponents对象中包含该算法的实现,我们使用PageRank小节数据集计算示例社交网络数据集的连接分量如下:
import org.apache.spark.graphx.GraphLoader
// Load the graph as in the PageRank example
val graph = GraphLoader.edgeListFile(sc, "data/graphx/followers.txt")
// Find the connected components
val cc = graph.connectedComponents().vertices
// Join the connected components with the usernames
val users = sc.textFile("data/graphx/users.txt").map { line =>
val fields = line.split(",")
(fields(0).toLong, fields(1))
}
val ccByUsername = users.join(cc).map {
case (id, (username, cc)) => (username, cc)
}
// Print the result
println(ccByUsername.collect().mkString("\n"))
完整的源码位置:examples/src/main/scala/org/apache/spark/examples/graphx/ConnectedComponentsExample.scal
Triangle Counting
当顶点具有两个相邻顶点并且在它们之间具有边时,顶点是三角形的一部分。GraphX在TriangleCount对象中实现了一个三角形计数算法,该算法确定通过每个顶点的三角形的数量,从而提供一种聚类度量。我们使用PageRank部分计算社交网络数据集来计算三角形计数。请注意,TriangleCount要求边处于规范方向(srcId < dstId),并且使用 Graph.partitionBy对图进行分区。
import org.apache.spark.graphx.{GraphLoader, PartitionStrategy}
// Load the edges in canonical order and partition the graph for triangle count
val graph = GraphLoader.edgeListFile(sc, "data/graphx/followers.txt", true)
.partitionBy(PartitionStrategy.RandomVertexCut)
// Find the triangle count for each vertex
val triCounts = graph.triangleCount().vertices
// Join the triangle counts with the usernames
val users = sc.textFile("data/graphx/users.txt").map { line =>
val fields = line.split(",")
(fields(0).toLong, fields(1))
}
val triCountByUsername = users.join(triCounts).map { case (id, (username, tc)) =>
(username, tc)
}
// Print the result
println(triCountByUsername.collect().mkString("\n"))
源码位置:"examples/src/main/scala/org/apache/spark/examples/graphx/TriangleCountingExample.scala
Examples
假设我想从一些文本文件构建一个图形,将图形限制为重要的关系和用户,在子图形上运行页面级别,然后最终返回与顶级用户关联的属性。我可以用GraphX在几行代码中完成所有这些:
import org.apache.spark.graphx.GraphLoader
// Load my user data and parse into tuples of user id and attribute list
val users = (sc.textFile("data/graphx/users.txt")
.map(line => line.split(",")).map( parts => (parts.head.toLong, parts.tail) ))
// Parse the edge data which is already in userId -> userId format
val followerGraph = GraphLoader.edgeListFile(sc, "data/graphx/followers.txt")
// Attach the user attributes
val graph = followerGraph.outerJoinVertices(users) {
case (uid, deg, Some(attrList)) => attrList
// Some users may not have attributes so we set them as empty
case (uid, deg, None) => Array.empty[String]
}
// Restrict the graph to users with usernames and names
val subgraph = graph.subgraph(vpred = (vid, attr) => attr.size == 2)
// Compute the PageRank
val pagerankGraph = subgraph.pageRank(0.001)
// Get the attributes of the top pagerank users
val userInfoWithPageRank = subgraph.outerJoinVertices(pagerankGraph.vertices) {
case (uid, attrList, Some(pr)) => (pr, attrList.toList)
case (uid, attrList, None) => (0.0, attrList.toList)
}
println(userInfoWithPageRank.vertices.top(5)(Ordering.by(_._2._1)).mkString("\n"))
源码位置:examples/src/main/scala/org/apache/spark/examples/graphx/ComprehensiveExample.scala