GraphX编程指南(spark2.4)

本文是在学习graph的时候顺便翻译为中文,以便以后查阅,如果大家阅读过程中发现问题,请大家指正。thx

目录

概述

开始

属性图

属性图示例

图操作

图操作概览

属性操作

结构操作

连接运算

邻域聚合

Aggregate Messages (aggregateMessages)

Map Reduce 三元组过度指南(遗留)

计算度

Collecting Neighbors

Caching and Uncaching

Pregel API

Graph Builders

Vertex and Edge RDDs

VertexRDDs

EdgeRDDs

Optimized Representation

Graph Algorithms

PageRank

Connected Components

Triangle Counting

Examples

 


概述

GraphX是Spark中的一个新组件,用于图形和图形并行计算。在高层次上,GraphX通过扩展Spark RDD来引入一种新的Graph抽象:一个有向多重图,其属性附加到每个顶点和边。为了支持图形计算,GraphX给出了一组基本操作符(例如, subgraphjoinVertices, 和 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项目中的各个组件。顶点属性可能包含用户名和职业。我们可以用描述合作者之间关系的字符串来注释边:

The Property Graph

 

 

 

得到的图形将具有类型签名:

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

或图形

Edge Triplet

 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方法进行分布式图分区:

Edge Cut vs. Vertex Cut

 

GraphX不是沿边分割图形,而是沿顶点对图形进行分区,这样可以减少通信和存储开销。逻辑上,这相当于给机器分配边,并允许顶点跨越多台机器。边的分配方法依赖于PartitionStrategy,不同的启发式有几个折衷。用户可以在不同的策略之间进行选择,使用 Graph.partitionBy算子重新分区图。默认的分区策略是使用图结构中提供的边的初始分区。但是,用户可以轻松切换到2d分区或GraphX中包含的其他启发式。

RDD Graph Representation

 

一旦边缘被分割,有效的图并行计算的关键挑战是有效地将顶点属性与边缘连接起来。因为现实世界中的图通常具有比顶点更多的边,所以我们将顶点属性移动到这些边上。因为不是所有的分区都包含与所有顶点相邻的边,所以我们在内部维护一个路由表,该路由表标识在实现像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

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值