知识图谱存储与可视化

数据预处理 – 将文件转化为 csv 格式

此次实验有 3 个数据文件:company_node.txt, management_edge.txt 以及 person_node.txt。这些文件并不是标准的 csv 格式,而是以 \t 为分隔符,所以预处理的主要目的就是把文件转换为以 , 为分隔符的 csv 文件。另外对于文件中出现的字符串,我们在其两端加上引号。用 R 处理的代码如下:

1
2
3
4
5
library(tidyverse)
company_node <- read_delim("src/company_node.txt", "\t", escape_double = FALSE, trim_ws = TRUE)
write.csv(company_node, "csv/company_node.csv", fileEncoding = "UTF-8", row.names = FALSE)
person_node <- read_delim("src/person_node.txt", "\t", escape_double = FALSE, trim_ws = TRUE)
write.csv(person_node, "csv/person_node.csv", fileEncoding = "UTF-8", row.names = FALSE)

节点数据的处理没有太大问题,但在处理关系数据时,我们发现读入的数据在 :END_IDupdate_time 两列之前存在某些交错,如下图的第 15 行所示。并且,这还导致了数据读入的时候多了一列。

management_edge

所以我们需要对于这些交错的行进行修正。这只需对 update_time 进行字符串匹配,并对匹配到的行进行交换即可。

1
2
3
4
5
6
7
8
9
10
management_edge <- read_delim("src/management_edge.txt", "\t", escape_double = FALSE, trim_ws = TRUE)
for (i in 1:nrow(management_edge)) {
if(str_detect(management_edge$update_time[i], "pers")) {
tmp <- management_edge$`:END_ID`[i]
management_edge$`:END_ID`[i] <- management_edge$update_time[i]
management_edge$update_time[i] <- tmp
}
}
management_edge <- select(management_edge, -X7)
write.csv(management_edge, "csv/management_edge.csv", fileEncoding = "UTF-8", row.names = FALSE)

最后我们得到的数据文件格式如下:

company_node.csv

clean1

person_node.csv

clean2

management_edge.csv

clean3


将数据导入 neo4j

将数据转换成 csv 格式之后,导入到 neo4j 就比较简单了。这次实验数据文件并不大,我们也只是在本地环境 (Windows) 使用 neo4j。

neo4j 安装

首先从 neo4j 官网下载二进制压缩包 neo4j-community-3.5.4-windows.zip,然后解压到 D:\ProgramData\neo4j-community-3.5.4。之后配置环境变量:

NEO4J_HOME: D:\ProgramData\neo4j-community-3.5.4

Path (追加): %NEO4J_HOME%\bin

然后就可以从命令行启动 neo4j,启动命令为 neo4j server

console

导入数据

在运行上述命令之后,就可以访问网页端 http://localhost:7474/。首次登陆需要修改密码,之后便可以正常使用。我们使用 LOAD CSV 方法导入数据,选择这个方法的原因一是由于这次数据量并不算大,不用在导入速率上做过多考虑;二是使用这种方法并不需要像其他很多方法一样需要在 neo4j 关闭服务时才能导入。为了导入数据,需要将预处理之后的 csv 文件放在 NEO4J_HOME\import 文件夹下,然后在网页端的命令交互环境执行下述 cypher 语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
USING PERIODIC COMMIT 300
LOAD CSV WITH HEADERS FROM 'file:///company_node.csv' AS line
CREATE (:Company {
ID: line.`:ID`,
create_time: line.create_time,
update_time: line.update_time,
industry: line.industry,
first_register_addr: line.first_register_addr,
security_short_name: line.security_short_name,
legal_entity: line.legal_entity,
manager: line.manager,
code: line.code,
company_address: line.company_address,
register_number: line.register_number,
zipcode: line.zipcode,
company_name: line.company_name
})

上面的语句导入了公司节点,为验证成功导入,使用以下查询:

1
MATCH (c:Company) RETURN c

结果如图,这表明导入成功。

import1

此外可以为这些数据创建索引:

1
CREATE INDEX ON :Company(ID)

同样的方法导入个人节点:

1
2
3
4
5
6
7
8
9
10
11
USING PERIODIC COMMIT 300
LOAD CSV WITH HEADERS FROM 'file:///person_node.csv' AS line
CREATE (:Person {
ID: line.ID,
education: line.education,
brith: line.brith,
name: line.name,
sex: line.sex,
create_time: line.create_datetime,
update_time: line.update_datetime
})

以及创建索引:

1
CREATE INDEX ON :Person(ID)

之后是关系数据的导入:

1
2
3
4
5
6
7
8
USING PERIODIC COMMIT 300
LOAD CSV WITH HEADERS FROM 'file:///management_edge.csv' AS line
MATCH (c:Company {ID: line.`:START_ID`}), (p:Person {ID: line.`:END_ID`})
CREATE (c)-[:isManagedBy {
create_time: line.create_time,
update_time: line.update_time,
title: line.title
}]->(p)

为了检验导入的效果,我们使用以下查询:

1
MATCH (c:Company)-[:isManagedBy]->(p:Person) RETURN c,p

得到的结果如下所示,这表明数据读取成功。

import1

导出 json

为方便测试,我们还将 neo4j 中的数据导出成 json 格式,用到的是 apoc 工具。为使用这个工具,需要从 https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/3.5.0.2 中下载最新版本的的 jar 包,然后将其放在 NEO4J_HOME\plugins 文件夹下,并修改 NEO4J_HOME\conf\neo4j.conf 文件,添加如下两行:

1
2
dbms.security.procedures.unrestricted=apoc.trigger.*,apoc.meta.*
apoc.export.file.enabled=true

重启 neo4j,运行下述 cypher 语句,就可以在相应的路径下看到导出的文件。

1
2
3
4
CALL apoc.graph.fromDB('test',{}) yield graph
CALL apoc.export.json.graph(graph, "D:/ProgramData/neo4j-community-3.5.4/export/query.json", {})
YIELD nodes, relationships, properties, file, source,format, time
RETURN *

这个语句以 json 格式导出了所有的节点和关系,如下所示:

json1

json2


Node.js Express框架链接前端与Neo4j

基本准备

Node.js安装

00

官网下载并安装,基本操作。

下载后自带npm,不需要再单独下载。环境变量在安装时已经被自动添加进路径中。

01

Express安装

命令行直接输入命令安装

1
2
npm install express -g
npm install express-generator -g

Express项目创建

在当前工作目录建立项目(比如我是在用户目录下)

1
2
3
express -e neo4j_d3js
cd neo4j_d3js
npm install

-e表示ejs模板引擎,不写的话默认创建jade模板引擎。

1
npm start

一开始按照教程使用node app完全没反应,后来添加了app.listen(3000)又提示被占用。于是查找后,发现使用上述命令不能添加监听端口。于是选择上述命令。

直接启动http://localhost:3000即可看见自己的网页。

为了让express也能识别html视图文件,需要:

在app.js文件中,找到

app.set(‘view engine’, ‘ejs’);

把它替换成:

app.set(‘view engine’, ‘html’);

再用app.engine()方法注册模板引擎的后缀名。

app.engine(‘html’, require(‘ejs’).__express);

view文件夹下本身有一个index.ejs文件,改后缀名为.html即可,注意,error.ejs也必须修改后缀名。

另外,在routes文件夹下有index.js文件,就用于路由设置的文件,直接在里面修改即可。

为了连接neo4j,还必须安装依赖包才能使用接口。

1
npm install node-neo4j —save

至此,所有外界安装就绪,接下来就是纯写代码工作了。


具体实现

环境创建与数据库链接

1
2
3
4
5
var express = require('express');
var router = express.Router();
var neo4j = require('node-neo4j');

db = new neo4j('http://neo4j:123456@localhost:7474');

此处首先引入了express环境,并且创建了一个路由router,路由主要用于监听和定位不同的发送请求,相当于创建了类似虚拟文件夹的概念,不同的请求会发送到约定的路径中,由路由监听并作出相应反馈。

根目录设定与渲染

1
2
3
router.get('/', function(req, res) {
res.render('index', { title: 'Express' });
});

这是该文件创建时自带的语句。经过尝试,发现这个语句用于根目录的创建与渲染。因为当我删除这句话时,浏览器的日志会报错提示某些未定义的元素。之后又查阅了res.render用法——渲染一个view,同时向callback传递渲染后的字符串,如果在渲染过程中有错误发生next(err)将会被自动调用。callback将会被传入一个可能发生的错误以及渲染后的页面,这样就不会自动输出了。因此这个函数是必不可少的。

初始化节点处理

1
2
3
4
5
6
7
8
9
10
11
router.get('/getInitialNode', function (req, res){
var randomPoint = Math.floor(Math.random() * 3761);
var sql_node = "MATCH(n) WHERE id(n) = "+randomPoint+" RETURN n";
db.cypherQuery(sql_node, function (err, result) {
if (err){
throw err;
}
console.log(result.data);
res.send(JSON.stringify(result));
});
});

此处路径与前端匹配。首先是利用JS自带的随机数函数生成0-1的随机数,3761代表我们处理过后导入neo4j中所有公司与个体的总和,我们对这个结果取整,用于初始点的选取。

之后是对neo4j查询语句,取出id标识匹配的数据并通过res.send函数返回相应的JSON字符串至前端。

邻居节点扩展

1
2
3
4
5
6
7
8
9
10
router.get('/spanNodes/*', function (req, res){
var sql_node = "MATCH (n) -[r]-> (p) WHERE id(n) = "+req.params[0]+" RETURN r,p";
db.cypherQuery(sql_node, function (err, result) {
if (err){
throw err;
}
console.log(result.data);
res.send(JSON.stringify(result));
});
});

此处很关键的处理在于路径的匹配,网上教程无一例外均提到利用正则表达式实现参数的传递,相当于在某个路径下匹配所有字符串,而这个字符串就自动识别为req.params。不过这里也只实现了一个参数,多参数传递没有细看。在前端,这里的参数是需要扩展邻居的结点_id值,直接用于查询语句,最终得到该节点所有的关系和邻居,同样方法返回。


Html与D3前端实现

SVG与力导向图

SVG基本参数设定

1
2
3
4
5
6
var marge = {top:60,bottom:60,left:60,right:60}
var svg = d3.select("svg")
var width = svg.attr("width")
var height = svg.attr("height")
var g = svg.append("g")
.attr("transform","translate("+marge.top+","+marge.left+")");

marge表示画布边框距离;widthheight为当前画图作用范围的长与宽;g标签为SVG分组标签;设定transform属性为顶端与左侧留出间距。

力导向图布局

1
2
3
4
5
6
var forceSimulation = d3.forceSimulation()
.force("link",d3.forceLink().id(function(d) {
return d._id;
}))
.force("charge",d3.forceManyBody())
.force("center",d3.forceCenter());

此处为新建力导向图,这是所有教程都会有的标准创建用法。但是在这里我在对link初始化方法时,将默认的sourcetarget为自动分配的index改为了我从数据库读出来带有的_id属性,当然这也是可以唯一区分节点的。否则直接使用会报错提示某些未定义元素的出现。

1
2
3
4
forceSimulation.nodes(nodes).on("tick",ticked);
forceSimulation.force("link").links(edges)
.distance(function(d){return 200;});
forceSimulation.force("center").x(width/2).y(height/2);

第一行表示将nodes数组中的节点提取出来,赋给相应的索引、坐标和物理变量,用于定位与能量计算,同时每过一个时钟tick就刷新一次节点的各项数据。

第二行表示将edges数组中的边(关系)提取出来,此处设定了边的长度为一个固定值。

第三行表示设定力导向图的中心位于整个画图范围的中间部位。

绘制

1
2
3
4
5
6
7
8
9
var links = g.append("g")
.selectAll("line")
.data(edges)
.enter()
.append("line")
.attr("stroke",function(d,i){
return colorScale(i);
})
.attr("stroke-width",2);

由于是初次画图,因此新增line分组,把边导入数据,此时明显只有数据没有元素,需要使用enter()为新进入的数据分配元素(标签),并且赋予颜色与线宽。颜色来源见后续部分。

1
2
3
4
5
6
7
8
var linksText = g.append("g")
.selectAll("text")
.data(edges)
.enter()
.append("text")
.text(function(d){
return d.title;
});

此处新建text标签,描述线上文字显示,即关系显示。从neo4j读出来的关系中title表示原数据中的职位。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
var gs = g.selectAll(".circleText")
.data(nodes)
.enter()
.append("g")
.attr("transform",function(d,i){
var cirX = d.x;
var cirY = d.y;
return "translate("+cirX+","+cirY+")";
})
.call(d3.drag()
.on("start",started)
.on("drag",dragged)
.on("end",ended)
);

此处为节点圆心的坐标和拖拽部分,这个在参考的教程中有提到,说圆心自身的坐标应当与圆半径、显示文字分开,具体理由不太能理解。

此处有定义基本的拖拽行为,这也是标准用法,具体的三个函数见后续部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
gs.append("circle")
.attr("r",function(d){
if(d.name == undefined)
return 60;
else
return 30;
})
.attr("fill",function(d,i){
return colorScale(i);
})
.on("dblclick",spanNodes)
.on("mouseover",showInfo)
.on("mouseout",hideInfo);

这部分为圆的主体部分,我将公司与个体区分开来,公司半径会更大,利于直观区分。填充颜色与线条颜色填充方法相同。

最后面的三个监听行为是前端的最核心部分,与express的交互和甲方需求都在这里实现。

1
2
3
4
5
6
7
8
9
10
gs.append("text")
.attr("x",-20)
.attr("y",0)
.attr("dy",0)
.text(function(d){
if(d.name == undefined)
return d.security_short_name;
else
return d.name;
});

此部分为圆上文字显示,内容为人名或公司名,属性为设置文字的起始显示位置。


函数模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function ticked(){
links
.attr("x1",function(d){return d.source.x;})
.attr("y1",function(d){return d.source.y;})
.attr("x2",function(d){return d.target.x;})
.attr("y2",function(d){return d.target.y;});
linksText
.attr("x",function(d){
return (d.source.x+d.target.x)/2;
})
.attr("y",function(d){
return (d.source.y+d.target.y)/2;
});
gs
.attr("transform",function(d) { return "translate(" + d.x + "," + d.y + ")"; });
}

ticked函数按照教程说法,用于不断更新边、边上文字、圆心的具体几何属性,在保证了基本几何参数正确的情况下,更便于做其他的操作,可以完全独立于相对位置来完成代码逻辑。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function started(d){
if(!d3.event.active){
forceSimulation.alphaTarget(0.8).restart();
}
d.fx = d.x;
d.fy = d.y;
}
function dragged(d){
d.fx = d3.event.x;
d.fy = d3.event.y;
}
function ended(d){
if(!d3.event.active){
forceSimulation.alphaTarget(0);
}
d.fx = null;
d.fy = null;
}

这三个函数为拖拽函数的三个行为函数,这也是标准用法,直接复制教程。大体意思是,拖拽开始时,固定原始圆心坐标;过程中会不断更新固定坐标;最后结束时,不再固定坐标,而是能够根据力导向图自身的能量计算到达平衡状态。fx = fixed x


1
2
3
var colorScale = d3.scaleOrdinal()
.domain(d3.range(nodes.length))
.range(d3.schemeCategory10);

此处为颜色模版使用,以节点数组长度为作用域分配10色模版,每添加一个新的元素便能够触发分配。经过试验,在没有新添加节点或边的情况下,只会分配一种颜色。


利用express交互实现功能

初始化图形

  此部分函数主要用于初始化画布,即随机选点与扩展邻居。同时会在双击行为时,满足一定条件时被调用,重新随机选取与扩展节点。因此,在逻辑部分,并不完全按照第一次随机选取实现,会有一些小处理。

1
2
3
4
5
6
7
8
9
10
$.ajax({
url: '/getInitialNode',
timeout: 3000,
dataType: 'json',
async: false,
success: function(res) {
console.log(res);
data = res;
}
});

此处基本为教程标准用法,大体含义就是针对express路由发送请求。此处发送初始节点请求,最大等待时间3秒,数据类型为json,关闭异步功能,若成功返回,则赋值给我的data变量。

其中最开始并没有增加关闭异步功能的语句,直到运行时一直出现如下错误

SCRIPT5007: Unable to get property '0' of undefined or null reference

但能够观察到data是有数据的,不存在找不到数组的情况。经过反复尝试,突然发现有的时候报错提示和控制台输出语句会交换顺序,于是怀疑可能是这二者是几乎同时执行的,经过一定的搜索和询问同学,最终解决了这个问题。

1
2
3
4
5
6
7
8
9
10
11
var i;
var index = data.data[0]._id;
for(i=0; i<nodes.length; i++)
if(nodes[i]._id == index)
break;
if(i == nodes.length){
nodes.push(data.data[0]);
...
}
else
initialGraph();

这部分逻辑用于判断随机选取的节点是否已经在画布中,如果存在不能反复添加,否则会出现两个相同的节点。此处如果节点已经存在必须递归调用此函数,直到找到未添加节点为止。这样写很不安全,一旦出现暴力攻击,将数据库节点使用完后,会无限循环调用函数,导致栈溢出。但此处是为了实验,也便于用户使用,才这样写的。否则必须增加节点数记录,判断是否已经用完节点。

1
2
3
4
5
6
7
8
9
10
$.ajax({
url: '/spanNodes/' + index,
timeout: 3000,
dataType: 'json',
async: false,
success: function(res) {
console.log(res);
data = res;
}
});

此处类似初始节点过程,只不过这里传递了参数,index在上面定义,为选定节点的索引。

1
2
3
4
5
6
7
8
data.data.forEach(function (neibor){
nodes.push(neibor[1]);
var link = neibor[0];
link.source = index;
link.target = neibor[1]._id;
edges.push(link);
});
restart();

此处将返回的所有邻居遍历,由于此函数在前面的判断时,保证了选取的节点为新的节点,因此通过该节点生成的邻居也必然是新的,此处没必要判断边是否已经存在。数据存储好后,重新刷新画布。

扩展邻居

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function spanNodes(d, index){
var indexM = d._id;
...
$.ajax({
url: '/spanNodes/' + indexM,
...
var update_num = 0;
data.data.forEach(function (neibor){
var i;
for(i=0; i<nodes.length; i++)
if(nodes[i]._id == neibor[1]._id)
break;
if(i == nodes.length){
nodes.push(neibor[1]);
var link = neibor[0];
link.source = indexM;
link.target = neibor[1]._id;
edges.push(link);
update_num = 1;
}
});
if(update_num == 0)
initialGraph();
else
restart();

变量update_num用于记录当前节点是否真的扩展了边,这有两种情况——该节点没有邻居和该节点已经被双击过了。无论是哪种,我们都不应该新添加边,并且需要调用initialGraph()函数重新随机生成节点和邻居,保证每次双击都有新的元素出现。另外,在添加边时,还要判断边地另一端节点是否已经出现在画布中,如果出现了,不能反复添加节点,只需要添加新的边即可。

信息显示

1
2
3
4
5
6
7
8
9
10
11
12
13
function showInfo(d, index){
old_color = d3.color(d3.select(this).attr("fill"));
old_color.opacity = 0.7
d3.select(this).attr("fill", old_color);
if(d.name == undefined){
var out = "Company Name - "+d.company_name+"\nManager - "+d.manager+"\nIndustry - "+d.industry+"\nCompany Address - "+d.company_address;
return layout.style("visibility","visible").text(out);
}
else{
var out = "Name - "+d.name+"\nEducation - "+d.education+"\nSex - "+d.sex;
return layout.style("visibility","visible").text(out);
}
}

第一二三行行为:鼠标移动到节点上,节点会变透明,0.7为透明程度,1表示不透明。old_color记录原始颜色,便于鼠标移开后恢复原始颜色。

layout为实现设定的全局风格,作用域为所有bodyvisible表示将这部分文本显示出来。默认显示在浏览器画布的左下角。

信息隐藏

1
2
3
4
5
6
7
function hideInfo(d, index){
old_color.opacity = 1;
d3.select(this).transition()
.duration(500)
.attr("fill", old_color);
return layout.style("visibility","hidden");
}

此处将透明度复原,并且使用了延迟行为。hidden表示隐藏之前的文字。

刷新画布

1
2
3
4
5
6
7
8
function restart(){
...
links = links
...
.merge(links);
...
forceSimulation.restart();
}

这部分基本和第一次初始化时相同,只有一些小细节不同。比如已经定义过的变量只需要更新即可,不要反复定义;再比如上方展示的,原变量只需要对新数据操作后与原数据合并即可,此时不能添加新的分组标签;最后是默认的重启函数,此处可以设置alpha值,默认是1,我就没作修改了。


结果展示

鼠标在空白处

03

左下角什么都没有显示。


鼠标移至公司

02

圆变透明了,并且左下角出现信息。


双击扩展

04

双击后如果没有邻居或者邻居已经显示出来了,就随机创建新的节点。并且这里,个体也变透明了,左下角有相应信息。

If it helps, you may buy me a cup of coffee plz.
0%