GraphX中属性图的运算
正如 RDD 具有 map、filter 和 reduceByKey 等基本操作一样,属性图也具有一组基本运算符,这些运算符采用用户定义的函数并生成具有转换后的属性和结构的新图。 具有优化实现的核心算子在 Graph 中定义,在 GraphOps 中定义表示为核心算子组合的便捷算子。 然而,由于 Scala 隐含了 GraphOps 中的运算符,它们可以自动作为 Graph 的成员使用。 例如,我们可以通过以下方式计算每个顶点(在 GraphOps 中定义)的入度:
val graph: Graph[(String, String), String] // 使用隐式 GraphOps.inDegrees 运算符 val inDegrees: VertexRDD[Int] = graph.inDegrees
GraphOps 类:包含 Graph 的附加功能。 所有操作都用高效的 GraphX API 表示。 这个类是为每个 Graph 对象隐式构造的。
区分核心图操作和 GraphOps 的原因是未来能够支持不同的图表示。 每个图表示必须提供核心操作的实现,并重用 GraphOps 中定义的许多有用操作。
一、操作的概述列表(Summary List of Operators)
以下是 Graph 和 GraphOps 中定义的功能的快速摘要,但为简单起见,将其显示为 Graph 的成员。 请注意,一些函数签名已被简化(例如,删除了默认参数和类型约束)并且一些更高级的功能已被删除,因此请查阅 API 文档以获取官方操作列表。
/** Summary of the functionality in the property graph */ class Graph[VD, ED] { // 关于图的相关信息 =================================================================== val numEdges: Long val numVertices: Long val inDegrees: VertexRDD[Int] val outDegrees: VertexRDD[Int] val degrees: VertexRDD[Int] // 图的视图作为集合 ============================================================= val vertices: VertexRDD[VD] val edges: EdgeRDD[ED] val triplets: RDD[EdgeTriplet[VD, ED]] // 用于缓存图的函数 ================================================================== def persist(newLevel: StorageLevel = StorageLevel.MEMORY_ONLY): Graph[VD, ED] def cache(): Graph[VD, ED] def unpersistVertices(blocking: Boolean = false): Graph[VD, ED] // 启发式更改分区 ============================================================ def partitionBy(partitionStrategy: PartitionStrategy): Graph[VD, ED] // 变换顶点和边属性 ========================================================== 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] // 修改图结构 ==================================================================== 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] // 将RDD和图连接起来 ====================================================================== 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] // 聚合有关相邻三元组的信息 ================================================= 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] // 迭代图并行计算 ========================================================== 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] // 基本图算法======================================================================== 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] }
二、图的属性操作(Property Operators)
与 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)
相反,使用 mapVertices 来保留索引:
val newGraph = graph.mapVertices((id, attr) => mapUdf(id, attr))
这些运算符通常用于初始化图形以进行特定计算或投影掉不必要的属性。 例如,给定一个以出度为顶点属性的图(我们稍后将描述如何构建这样的图),我们将其初始化为 PageRank:
// 给定一个顶点属性为出度的图 val inputGraph: Graph[Int, String] = graph.outerJoinVertices(graph.outDegrees)((vid, _, degOpt) => degOpt.getOrElse(0)) // 构造一个图,其中每条边都包含权重 // 并且每个顶点都是初始的PageRank val outputGraph: Graph[Double, Double] = inputGraph.mapTriplets(triplet => 1.0 / triplet.srcAttr).mapVertices((id, _) => 1.0)
三、图的结构化操作(Structural Operators)
目前 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(Seq((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(Seq(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) // 请注意,有一个用户 0(我们没有关于它的信息)连接到用户 // 4 (peter) and 5 (franklin). graph.triplets.map( triplet => triplet.srcAttr._1 + " is the " + triplet.attr + " of " + triplet.dstAttr._1 ).collect.foreach(println(_)) // 删除缺失的顶点以及连接到它们的边 val validGraph = graph.subgraph(vpred = (id, attr) => attr._2 != "Missing") // 有效子图将通过删除用户 0 断开用户 4 和 5 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)通过返回包含在输入图中也找到的顶点和边的图来构造子图。 这可以与子图运算符结合使用,以根据另一个相关图中的属性来限制图。 例如,我们可能会使用缺少顶点的图运行连接组件,然后将答案限制为有效子图。
// 运行连接组件 val ccGraph = graph.connectedComponents() // 不再包含缺少连接的点 // 删除掉没有连接边的节点 val validGraph = graph.subgraph(vpred = (id, attr) => attr._2 != "Missing") // 将返回限制在有效的子图 val validCCGraph = ccGraph.mask(validGraph)
groupEdges 运算符合并多重图中的平行边(即顶点对之间的重复边)。 在许多数值应用中,可以将平行边(它们的权重组合)添加到单个边中,从而减小图的大小。
四、图的连接操作(Join Operators)
在许多情况下,有必要将来自外部集合 (RDD) 的数据与图表连接起来。 例如,我们可能有额外的用户属性想要与现有图合并,或者我们可能想要将顶点属性从一个图拉到另一个图。 这些任务可以使用连接运算符来完成。 下面我们列出了键连接运算符:
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 连接起来,并返回一个新图,该图具有通过将用户定义的映射函数应用于连接顶点的结果而获得的顶点属性。 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,除了用户定义的映射函数应用于所有顶点并且可以更改顶点属性类型。 因为并非所有顶点都可能在输入 RDD 中具有匹配值,所以 map 函数采用 Option 类型。 例如,我们可以通过使用 outDegree 初始化顶点属性来为 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)
五、图的邻近聚合(Neighborhood Aggregation)
许多图形分析任务中的一个关键步骤是聚合有关每个顶点邻域的信息。 例如,我们可能想知道每个用户拥有的关注者数量或每个用户的关注者的平均年龄。 许多迭代图算法(例如,PageRank、最短路径和连接组件)重复聚合相邻顶点的属性(例如,当前 PageRank 值、到源的最短路径和最小可达顶点 id)。
为了提高性能,主要聚合运算符从 graph.mapReduceTriplets 更改为新的 graph.AggregateMessages。 虽然 API 的变化相对较小,下面提供了过渡指南:
5.1 聚合消息(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 中访问哪些数据(即源顶点属性而不是目标顶点属性)。 TripletFields 中定义了tripletsFields 的可能选项,默认值为TripletFields.All,这表示用户定义的sendMsg 函数可以访问EdgeContext 中的任何字段。 TripletFields 参数可用于通知 GraphX 只需要 EdgeContext 的一部分,从而允许 GraphX 选择优化的连接策略。 例如,如果我们计算每个用户的追随者的平均年龄,我们只需要源字段,因此我们将使用 TripletFields.Src 来指示我们只需要源字段。
在下面的示例中,我们使用 aggregateMessages 运算符来计算每个用户的资深粉丝的平均年龄,示例如下:
import org.apache.spark.graphx.{Graph, VertexRDD} import org.apache.spark.graphx.util.GraphGenerators // Create a graph with "age" as the vertex property. // 这里为了方便,使用的是随机图 val graph: Graph[Double, Int] = GraphGenerators.logNormalGraph(sc, numVertices = 100).mapVertices( (id, _) => id.toDouble ) // 计算资深粉丝的数量和它们的总年龄 val olderFollowers: VertexRDD[(Int, Double)] = graph.aggregateMessages[(Int, Double)]( triplet => { // Map Function if (triplet.srcAttr > triplet.dstAttr) { // 向包含计数器和年龄的目标顶点发送消息 triplet.sendToDst((1, triplet.srcAttr)) } }, // 累加计数器和年龄 (a, b) => (a._1 + b._1, a._2 + b._2) // Reduce Function ) // 将总年龄除于粉丝的数量得到资深粉丝的平均年龄 val avgAgeOfOlderFollowers: VertexRDD[Double] = olderFollowers.mapValues( (id, value) => value match { case (count, totalAge) => totalAge / count } ) // 显示结果 avgAgeOfOlderFollowers.collect.foreach(println(_))
ps:当消息的数量恒定时,聚合消息的性能是最佳的。
5.2 计算图的度信息
一个常见的聚合任务是计算每个顶点的度数:与每个顶点相邻的边数。 在有向图的上下文中,通常需要知道每个顶点的入度、出度和总度数。 GraphOps 类包含一组运算符来计算每个顶点的度数。 例如,在下面我们计算最大进、出和总度数:
// 定义一个reduce操作来计算最高度数的顶点 def max(a: (VertexId, Int), b: (VertexId, Int)): (VertexId, Int) = { if (a._2 > b._2) a else b } // 计算最大的度数 val maxInDegree: (VertexId, Int) = graph.inDegrees.reduce(max) val maxOutDegree: (VertexId, Int) = graph.outDegrees.reduce(max) val maxDegrees: (VertexId, Int) = graph.degrees.reduce(max)
5.3 收集相邻节点
在某些情况下,通过收集相邻顶点及其在每个顶点的属性来表示计算可能更容易。 这可以使用 collectNeighborIds 和 collectNeighbors 运算符轻松完成。
class GraphOps[VD, ED] { def collectNeighborIds(edgeDirection: EdgeDirection): VertexRDD[Array[VertexId]] def collectNeighbors(edgeDirection: EdgeDirection): VertexRDD[ Array[(VertexId, VD)] ] }
ps:这些操作的成本可能相当高,因为他们复制信息并需要大量通信。 如果可能,可以尝试直接使用 aggregateMessages 运算符代替相同的计算。
六、图的缓存和非缓存(Caching and Uncaching)
在 Spark 中,RDD 默认不保存在内存中。为避免重新计算,在多次使用它们时必须显式缓存它们。 GraphX 中的图形行为方式相同,多次使用图时,要确保首先对其调用 Graph.cache()。
在迭代计算中,为了获得最佳性能,也可能需要取消缓存。默认情况下,缓存的 RDD 和图将保留在内存中,直到内存压力迫使它们按 LRU 顺序被驱逐。对于迭代计算,先前迭代的中间结果将填满缓存。尽管它们最终会被驱逐,但存储在内存中的不必要数据会减慢垃圾收集速度。一旦不再需要中间结果,立即取消缓存它们会更有效。这涉及在每次迭代中具体化(缓存和强制)图或 RDD,取消缓存所有其他数据集,并且仅在未来的迭代中使用具体化的数据集。但是,由于图由多个 RDD 组成,因此很难正确地取消持久化它们。对于迭代计算,建议使用 Pregel API,它正确地取消了中间结果。