小玩具:练习构建简易图计算框架

157 阅读8分钟

小玩具:练习构建简易图计算框架

前言

本文记录了笔者在学习谷峪等人的专著《大规模图数据的分布式处理》时,所获得的些许心得。

结合书中的一些思路,根据自己的浅薄理解,实现了文中所述图计算小玩具,用于巩固所学知识的练手 Demo 。

什么是图计算

顾名思义,在图模型的基础上进行计算,目的是根据节点自身属性与图结构,挖掘出图中潜藏的有价值信息。

含有复杂关联的系统在自然界中俯拾皆是。我们在认识与处理这些系统时,往往将其抽象为图模型,系统中的参与者被视为图中节点,参与者之间的关联关系被当作图中连边。

常见的图模型系统包括社交网络、交通运输网、电力系统、蛋白质网络、金融交易等,覆盖领域极为广阔。不同的领域对图模型的计算要求略有不同。

在大数据与社交网络时代,当提到图计算,我们通常会想到 Google 的 PageRank 算法、Facebook 的社交网络推荐、交通/电力网络的级联失效预防、金融领域的异常账户交易分析等。因此,图计算可狭义的理解为计算机上的图数据处理算法。

在广义上,图计算是一个非常庞大的概念,它的计算方法起源于图论、复杂网络、网络科学、人工智能等,思想涉及计算机、遗传、数学、生物、金融、通信等多种学科知识。

本文余下部分提到的图计算,均为计算机领域内的图数据处理。

常见图计算方式

在图数据规模较小、单机可处理时,可将图数据载入内存或数据库中,使用单机图处理算法进行图计算。具体方法包括自行实现图算法或是调用第三方图计算库,如 NetworkX 。

当图数据规模大到单机内存放不下,或者单机处理耗时太久时,需要使用分布式计算框架来实现图计算。常见的图计算方式包括如下几种。

MapReduce

MapReduce 是通用的 OLAP 框架,通过实现自己的 Map 与 Reduce 函数,将图计算过程迁移到该计算风格内。

BSP

BSP ( Bulk Synchronous Parallel ) 整体同步并行模型,提供块间同步处理,块内异步并行的计算。

BSP 模型的计算逻辑由一系列迭代步组成,其中的每一步迭代称为一个“超步”。每个超步分为三个阶段:本地计算、消息通信和路障同步。

  1. 本地计算:应用计算逻辑的独立计算过程。计算单元在这一阶段互相独立平行计算,计算的数据在本地的内存中存储。在该阶段,计算单元之间是没有关联的针对自身的数据执行计算逻辑。

  2. 消息通信:在本地计算阶段完成后,各个计算单元要通过通信技术交换各自的数据信息,而没有执行任何的数据计算处理。

  3. 路障同步:保证每个计算单元在下一超级步开始时当前超级步中的处理全部结束,即块间的同步。全局同步虽然可能造成因各个处理单元负载及实际处理情况而导致的进度参差不齐的短板现象,即在整个计算过程中往往存在个别单元进展较慢,其他计算单元被动同步等待的情况,但是同时也避免了因异步造成的死锁问题。

BSP 模型是目前进行分布式大图处理的主流模型。基于 BSP 模型的分布式并行处理系统包括 Pregel 和 Giraph 。

构建简易图计算框架

本文搭建的简易图计算框架的思路来自 BSP 模型,是一个单机上的内存图计算框架。

图模型

图模型选择了工业界常用的属性图模型,包括 Graph、Element、Vertex、Edge 四种类型。

Graph

Graph 表示用户操作的图结构,包含 vertexMap 与 edgeMap 两个字典属性,分别是 VId/Vertex 映射表与 EId/Edge 映射表。

// Graph.java
public class Graph {
    private final Map<String, Vertex> vertexMap = new HashMap<>();
    private final Map<String, Edge> edgeMap = new HashMap<>();
    
    public void addVertex(Vertex vertex) {
        vertex.setGraph(this);
        vertexMap.put(vertex.getId(), vertex);
    }

    public Vertex getVertex(String id) {
        return vertexMap.getOrDefault(id, null);
    }

    public void addEdge(Edge edge) {
        edge.setGraph(this);
        edgeMap.put(edge.getId(), edge);
    }

    public Edge getEdge(String id) {
        return edgeMap.getOrDefault(id, null);
    }

    # Compute
    ...
}

Element

Element 表示图元素。

# Element.java
public class Element {
    protected String id;
    protected Graph graph;
    
    # Getter/Setter
    ...
}

Vertex

Vertex 表示图中节点元素,继承了 Element 类型。

Vertex 使用无索引近邻结构,直接引用相关对象。

# Vertex.java
public class Vertex extends Element {
    private final List<Edge> inEdges = new ArrayList<>();
    private final List<Edge> outEdges = new ArrayList<>();
    private Map<String, Object> properties = new HashMap<>();
    private final List<VertexMessage> messageBox = new ArrayList<>();
    
    # Getter/Setter/equals/hashCode/builder
    ...
}

Edge

Edge 表示图中边元素,继承了 Element 类型

Edge 使用无索引近邻结构,直接引用相关对象。

# Edge.java
public class Edge extends Element {
    private Vertex outVertex;
    private Vertex inVertex;

    # Getter/Setter/equals/hashCode/builder
    ...
}

VertexHelper

VertexHelper 为 Vertex Properties 处理工具类,简化 Vertex 与属性调用者类的复杂度。

# PropertyHelper.java
public class PropertyHelper {
    public static final String DISTANCE_KEY = "distance";
    public static final String WEIGHT_KEY = "weight";
    public static final String TRIANGLE_KEY = "triangle";
    ...

    public static float getWeight(Vertex vertex) {
        Object o = vertex.getProperty(WEIGHT_KEY);
        if (o != null) {
            if (o instanceof Float) {
                return (float) o;
            } else {
                throw new RuntimeException(vertex.getId() + " Distance Property Has Illegal Value: " + o);
            }
        } else {
            vertex.putProperty(WEIGHT_KEY, 1.0);
            return 1;
        }
    }

    public static void setWeight(Vertex vertex, float weight) {
        vertex.putProperty(WEIGHT_KEY, weight);
    }

    ...

计算模型

参考 BSP 模型,计算模型为迭代模型,每次迭代过程可分为如下两步骤:

  • 构建消息
  • 接收消息

图计算的迭代过程中,当前图状态可分为:扩张态、稳态、收缩态。

以单源最短路径计算为例,从源节点开始,按照出度边逐步激活其他节点,这些节点激活后,又会激活其相邻节点。因此,在图计算初始阶段,消息规模和处于激活状态的节点规模会逐步扩大,即为扩张态。当大部分节点处于激活状态时,消息规模将到达峰值,即为稳态。当大部分节点找到其最短路径后,通过剪枝策略,不再发送消息,导致整个图中消息规模逐渐减少,即为收缩态。

计算接口 ICompute

首先定义计算接口,包括构建消息与接收消息两个方法。

# ICompute.java
public interface ICompute {
    /**
     * 根据传入节点的边找到对应的节点,将其作为消息的接收者,构建消息列表。
     *
     * @param sender 消息发送者
     * @return VertexMessage 消息集合
     */
    Set<VertexMessage> sendMessage(Vertex sender);

    /**
     * 消息接收者根据消息内容,更新自身信息。
     *
     * @param receiver 消息接收者
     * @param message  消息
     */
    void receiveMessage(Vertex receiver, VertexMessage message);
}

消息结构 VertexMessage

定义消息结构

# VertexMessage.java
public class VertexMessage {
    private String senderId;
    private String receiverId;

    # Getter/Setter/equals/hashCode/builder
    ...
}

计算过程

定义迭代过程,参数包含 ICompute 接口实现类对象、起始点 Id、迭代次数。

每个迭代中都有构建消息与接收消息两步。

# Graph.java
public void compute(ICompute compute, Set<String> startVertexIds, int iterateCount) {
    if (vertexMap.isEmpty()) return;

    Set<String> currentActiveVertices = new HashSet<>(startVertexIds);

    while (iterateCount > 0) {
        iterateCount--;

        // Step.1 构建消息
        // 可选的优化项:过滤不符合发送消息条件的节点
        // 可选的优化项:并发处理,注意避免并发问题
        Set<VertexMessage> messageBox = new HashSet<>();
        currentActiveVertices.forEach(vid -> 
            messageBox.addAll(compute.sendMessage(getVertex(vid)))
        );

        // 若通过并发方式构建消息,此处需添加同步处理,等待本次迭代全部消息构建完毕

        // Step.2 接受消息
        // 可选的优化项:根据消息的接收者 ID 合并消息
        // 可选的优化项:并发处理,注意避免并发问题
        currentActiveVertices = messageBox.stream()
                .map(msg -> {
                    String receiverId = msg.getReceiverId();
                    // 若为分布式计算,可在此根据接收者 ID 获取对应服务器,执行 RPC
                    compute.receiveMessage(getVertex(receiverId), msg);
                    return receiverId;
                }).collect(Collectors.toSet());

        // 若通过并发方式构建消息,此处需添加同步处理,等待本次迭代全部消息构建完毕
    }
}

用例

至此,简易计算框架已搭建完毕。

下面将通过此 Demo 进一步了解常见图计算过程。

创建测试用例

首先创建一个简单的测试用例 Graph 。

# Main.java
public static void main(String[] args) {
    Vertex v1 = new Vertex().id("v1").property("name", "v1");
    Vertex v2 = new Vertex().id("v2").property("name", "v2");
    Vertex v3 = new Vertex().id("v3").property("name", "v3");
    Vertex v4 = new Vertex().id("v4").property("name", "v4");
    Vertex v5 = new Vertex().id("v5").property("name", "v5");

    Graph graph = new Graph();
    graph.addVertex(v1);
    graph.addVertex(v2);
    graph.addVertex(v3);
    graph.addVertex(v4);
    graph.addVertex(v5);

    graph.addEdge(new Edge().id("e1").outVertex(v1).inVertex(v2));
    graph.addEdge(new Edge().id("e2").outVertex(v1).inVertex(v4));
    graph.addEdge(new Edge().id("e3").outVertex(v4).inVertex(v3));
    graph.addEdge(new Edge().id("e4").outVertex(v3).inVertex(v5));
    graph.addEdge(new Edge().id("e5").outVertex(v5).inVertex(v1));
    graph.addEdge(new Edge().id("e6").outVertex(v2).inVertex(v4));
    graph.addEdge(new Edge().id("e7").outVertex(v4).inVertex(v2));
    graph.addEdge(new Edge().id("e8").outVertex(v5).inVertex(v3));
    graph.addEdge(new Edge().id("e9").outVertex(v4).inVertex(v5));
}

单源最短路径

单源最短路径的目的是计算指定节点到图中其他所有节点的距离。

通过实现 ICompute 接口的两个方法来完成此计算。

# ShortestPathCompute.java
public class ShortestPathCompute implements ICompute {
    public static ShortestPathCompute INSTANCE = new ShortestPathCompute();

    @Override
    public Set<VertexMessage> sendMessage(Vertex outVertex) {
        Set<VertexMessage> result = new HashSet<>();
        int currentDistance = getDistance(outVertex);
        for (Edge edge : outVertex.getOutEdges()) {
            VertexMessage msg = new VertexMessage();
            msg.setSenderId(outVertex.getId());
            msg.setReceiverId(edge.getInVertex().getId());
            msg.setDistance(currentDistance + 1);
            result.add(msg);
        }
        return result;
    }

    @Override
    public void receiveMessage(Vertex inVertex, VertexMessage message) {
        int distance = message.getDistance();
        if (getDistance(inVertex) == 0 || getDistance(inVertex) > distance) {
            setDistance(inVertex, distance);
        }
    }
}

在测试用例中调用该计算过程。

# Main.java
public static void main(String[] args) {
    ...
    String startVertexId = "v1";
    graph.compute(ShortestPathCompute.INSTANCE, Set.of(startVertexId), iterateCount);
    setDistance(getVertex(startVertexId), 0);
    System.out.println(printVertices(PropertyHelper::getDistance));
}

输出结果如下。

Vertices {
    v1 : 0
    v2 : 1
    v3 : 2
    v4 : 1
    v5 : 2
}

PageRank

PageRank 目的是根据节点权重传播过程,计算最终收敛的各节点权重。

此处以朴素 PageRank 为例。

通过实现 ICompute 接口的两个方法来完成此计算。

# PageRankCompute.java
public class PageRankCompute implements ICompute {
    public static PageRankCompute INSTANCE = new PageRankCompute();

    @Override
    public Set<VertexMessage> sendMessage(Vertex outVertex) {
        Set<VertexMessage> result = new HashSet<>();
        float weight = getWeight(outVertex);
        int size = outVertex.getOutEdges().size();
        float passedWeight = weight / size;
        for (Edge edge : outVertex.getOutEdges()) {
            VertexMessage message = new VertexMessage();
            message.setSenderId(outVertex.getId());
            message.setReceiverId(edge.getInVertex().getId());
            message.setWeight(passedWeight);
            result.add(message);
        }
        setWeight(outVertex, 0);
        return result;
    }

    @Override
    public void receiveMessage(Vertex inVertex, VertexMessage message) {
        setWeight(inVertex, getWeight(inVertex) + message.getWeight());
    }
}

在测试用例中调用该计算过程。

# Main.java
public static void main(String[] args) {
    ...
    String startVertexId = "v1";
    graph.compute(PageRankCompute.INSTANCE, Set.of(startVertexId), iterateCount);
    System.out.println(printVertices(PropertyHelper::getWeight));
}

输出结果如下。

Vertices {
    v1 : 0.72280085
    v2 : 0.7049575
    v3 : 1.0750384
    v4 : 1.0784143
    v5 : 1.4187884
}

三角形

图中三角形的查找和统计是一个具有广泛应用的基础问题。例如社交网络推荐系统中,利用了三角形的两个重要特性——同质和传递。根据同质的特性,社交网络的用户倾向于与拥有相似爱好和兴趣的人建立朋友关系(在数据的角度,用户之间拥有相似的三角形模式),这种倾向性被称为“birds of a feather flock together”;传递的特性在社交网络中,主要表现为用户更愿意与拥有相同朋友圈子的人成为朋友。

通过实现 ICompute 接口的两个方法来完成此计算。

# TriangleCompute.java
public class TriangleCompute implements ICompute {
    public static final TriangleCompute INSTANCE = new TriangleCompute();
    @Override
    public Set<VertexMessage> sendMessage(Vertex sender) {
        Set<VertexMessage> result = new HashSet<>();
        String neighborIds = StringUtils.joinWith(",", Stream.concat(
                sender.getOutEdges().stream().map(Edge::getInVertex),
                sender.getInEdges().stream().map(Edge::getOutVertex)
        ).map(Vertex::getId).distinct().toArray());
        for (Edge edge : sender.getOutEdges()) {
            VertexMessage msg = new VertexMessage();
            msg.setSenderId(sender.getId());
            msg.setReceiverId(edge.getInVertex().getId());
            msg.setNeighborIds(neighborIds);
            result.add(msg);
        }
        for (Edge edge : sender.getInEdges()) {
            VertexMessage msg = new VertexMessage();
            msg.setSenderId(sender.getId());
            msg.setReceiverId(edge.getOutVertex().getId());
            msg.setNeighborIds(neighborIds);
            result.add(msg);
        }
        return result;
    }

    @Override
    public void receiveMessage(Vertex receiver, VertexMessage message) {
        List<VertexMessage> messageBox = receiver.getMessageBox();
        if (messageBox.contains(message)) return;
        for (VertexMessage oldMessage : messageBox) {
            Set<String> oldSenderNeighborIds = Set.of(oldMessage.getNeighborIds().split(","));
            Set<String> newSenderNeighborIds = Set.of(message.getNeighborIds().split(","));
            if (oldSenderNeighborIds.contains(message.getSenderId()) &&
                newSenderNeighborIds.contains(oldMessage.getSenderId())) {
                Set<Triple<String, String, String>> triples = PropertyHelper.getTriangle(receiver);
                triples.add(new ImmutableTriple<>(receiver.getId(), oldMessage.getSenderId(), message.getSenderId()));
            }
        }
        messageBox.add(message);
    }
}

在测试用例中调用该计算过程。

# Main.java
public static void main(String[] args) {
    ...
    String startVertexId = "v1";
    graph.compute(TriangleCompute.INSTANCE, Set.of(startVertexId), iterateCount);
    System.out.println(printVertices(PropertyHelper::getTriangle));
}

输出结果如下。

Vertices {
    v1 : [(v1,v2,v4)]
    v2 : [(v2,v1,v4)]
    v3 : [(v3,v5,v4)]
    v4 : [(v4,v1,v5)]
    v5 : [(v5,v1,v4)]
}

元胞自动机

设元胞自动机规则如下:

  • 每个节点只有 On/Off 两种状态。
  • 每轮迭代时,各节点根据邻居节点的多数状态设置自身状态。
# CellularCompute.java
public class CellularCompute implements ICompute {
    public static final CellularCompute INSTANCE = new CellularCompute();
    @Override
    public Set<VertexMessage> sendMessage(Vertex sender) {
        Set<VertexMessage> result = new HashSet<>();
        for (Edge edge : sender.getInEdges()) {
            VertexMessage msg = new VertexMessage();
            msg.setSenderId(sender.getId());
            msg.setReceiverId(edge.getOutVertex().getId());
            result.add(msg);
        }
        for (Edge edge : sender.getOutEdges()) {
            VertexMessage msg = new VertexMessage();
            msg.setSenderId(sender.getId());
            msg.setReceiverId(edge.getInVertex().getId());
            result.add(msg);
        }
        return result;
    }

    @Override
    public void receiveMessage(Vertex receiver, VertexMessage message) {
        List<VertexMessage> messageBox = receiver.getMessageBox();
        if (messageBox.contains(message)) return;
        messageBox.add(message);
        long onCount = messageBox.stream().filter(msg -> getStatus(receiver.getGraph().getVertex(msg.getSenderId()))).count();
        long offCount = messageBox.stream().filter(msg -> !getStatus(receiver.getGraph().getVertex(msg.getSenderId()))).count();
        setStatus(receiver, onCount > offCount);
    }
}

在测试用例中调用该计算过程。

# Main.java
public static void main(String[] args) {
    ...
    graph.compute(CellularCompute.INSTANCE, graph.getVertexMap().keySet(), iterateCount);
    System.out.println(printVertices(PropertyHelper::getStatus));
}

输出结果如下。

Vertices {
    v1 : true
    v2 : true
    v3 : true
    v4 : true
    v5 : true
}

网络博弈

以最简单的囚徒困境博弈模型为例,设收益矩阵为 [1, -1, 2, 0]

在每轮迭代过程中,需完成两部分计算:

  • 各节点要与所有邻居进行一轮博弈,计算收益。
  • 比较自身与邻居的收益,学习收益最大者的策略,作为下一轮迭代使用的策略。
# GameCompute.java
public class GameCompute implements ICompute {
    public static final GameCompute INSTANCE = new GameCompute();
    public static final int R = 1;
    public static final int S = -1;
    public static final int T = 2;
    public static final int P = 0;

    public static int game(Vertex self, Vertex other) {
        boolean selfChoice = getCooperate(self);
        boolean otherChoice = getCooperate(other);
        if (selfChoice && otherChoice) return R;
        if (selfChoice) return S;
        if (otherChoice) return T;
        return P;
    }

    @Override
    public Set<VertexMessage> sendMessage(Vertex sender) {
        Set<VertexMessage> result = new HashSet<>();
        int totalWeight = 0;
        for (Edge edge : sender.getInEdges()) {
            totalWeight += game(sender, edge.getOutVertex());
        }
        for (Edge edge : sender.getOutEdges()) {
            totalWeight += game(sender, edge.getInVertex());
        }
        setWeight(sender, getWeight(sender) + totalWeight);

        for (Edge edge : sender.getInEdges()) {
            VertexMessage msg = new VertexMessage();
            msg.setSenderId(sender.getId());
            msg.setReceiverId(edge.getOutVertex().getId());
            msg.setWeight(totalWeight);
            result.add(msg);
        }
        for (Edge edge : sender.getOutEdges()) {
            VertexMessage msg = new VertexMessage();
            msg.setSenderId(sender.getId());
            msg.setReceiverId(edge.getInVertex().getId());
            msg.setWeight(totalWeight);
            result.add(msg);
        }
        return result;
    }

    @Override
    public void receiveMessage(Vertex receiver, VertexMessage message) {
        List<VertexMessage> messageBox = receiver.getMessageBox();
        if (messageBox.contains(message)) return;
        VertexMessage maxOldMessage = messageBox.stream().min((m1, m2) -> m1.getWeight() < m2.getWeight() ? 1 : 0).orElse(null);
        if (maxOldMessage == null) {
            setCooperate(receiver, getCooperate(receiver.getGraph().getVertex(message.getSenderId())));
        } else {
            if (maxOldMessage.getWeight() < message.getWeight()) {
                setCooperate(receiver, getCooperate(receiver.getGraph().getVertex(message.getSenderId())));
            }
        }
        messageBox.add(message);
    }
}

在测试用例中调用该计算过程。

# Main.java
public static void main(String[] args) {
    ...
    graph.compute(GameCompute.INSTANCE, graph.getVertexMap().keySet(), iterateCount);
    System.out.println(printVertices(PropertyHelper::getWeight));
}

输出结果如下。

Vertices {
    v1 : 20.0
    v2 : -23.0
    v3 : 3.0
    v4 : 38.0
    v5 : 5.0
}

若想查看迭代过程中的各节点状态,读者可自行实现该计算过程,在 Graph::compute 内增加输出。

更进一步

网络上的流行病传播模型也是迭代计算过程,读者可根据兴趣自行实现计算过程。