查看原文
其他

工具&技巧 | 经济学圈特供 小刘帮你画专业社会网络图(二)

Dyson 数据Seminar 2021-06-03


——接上期(点此回顾
本期我们将详细介绍一幅精美的社会网络分析图的绘制过程,话不多说,开始吧!




数据预处理


首先,我们导入用来画图的数据,如图一所示:

图一
限于篇幅,我们这边只取部分的市作为节点来画图。聪明的你肯定有所察觉,我们的数据其实是一个n*n的方阵。(至于这个方阵怎么得到的,不是本文重点,有疑问请在公众号下方留言)
接下来,我们需要对原始矩阵做一个数值分类,代码如下(处理数据、画图完整代码我会整合在文章附录中):
# 取出所有的节点备用node_list = list(df.index)# 对数据做初始分类data_bins = [-float('inf'), -1, 30000, 2000000, 10000000, 20000000, float('inf')]data_labels = [-1, 0, 1, 2, 3, 4]df = df.apply(lambda row: pd.cut(row, data_bins, labels=data_labels),axis=1)
左右滑动查看更多

我这边使用了pd.cut来进行分类,data_bins是规定好所有的分类区间,默认是左开右闭,float('inf')是无穷大的意思,而data_labels是每个区间对应的标签。由于我们的处理方式中,标签也要进行数值运算,所以data_labels里面全是数字类型的元素。

至于为什么这么分类,是由研发部的神仙姐姐根据一些统计指标不断调优得出的。

分完类之后,我们的数据表变成了图二的样子:

图二
做完数据的预处理之后,我们终于可以开始画图啦!




绘制一模社会网络图


首先,我们看看一模的社会网络图要怎么画。
由于一模的图是无向图,我们可以这么初始化networkx的图对象:
# 画图准备"""图的类型Graph类是无向图的基类,无向图能有自己的属性或参数,不包含重边,允许有回路,节点可以是任何hash的python对象,节点和边可以保存key/value属性对。该类的构造函数为Graph(data=None,**attr),其中data可以是边列表,或任意一个Networkx的图对象,默认为none;attr是关键字参数,例如key=value对形式的属性。
MultiGraph是可以有重边的无向图,其它和Graph类似。其构造函数MultiGraph(data=None, *attr)。
DiGraph是有向图的基类,有向图可以有数自己的属性或参数,不包含重边,允许有回路;节点可以是任何hash的python对象,边和节点可含key/value属性对。该类的构造函数DiGraph(data=None,**attr),其中data可以是边列表,或任意一个Networkx的图对象,默认为none;attr是关键字参数,例如key=value对形式的属性。
MultiDiGraph是可以有重边的有向图,其它和DiGraph类似。其构造函数MultiDiGraph(data=None, *attr)。"""G = nx.Graph()plt.figure(1,figsize=(60,60)) # 设置“一模”图片大小
左右滑动查看更多

值得一提的是,我是按照上述代码完成初始化之后,然后对节点数值进行一些运算,根据运算结果,再做一次节点属性上的分类。

# 计算节点属性
# 节点数值的分类node_bins = [-float('inf'), 0, 25, 41, 50.5, 58, float('inf')]# 第一个是节点的大小,第二个需要填入RBG颜色值node_args = {-1: (50, (187,255,255), ) , 0: (100, (187,255,255), ) , 1: (200, (152,245,255), ) , 2: (400, (142,229,238), ) , 3: (550, (0,238,238), ) , 4: (800, (0,197,205), )}node_dict = {}# 为所有节点做分类,目的是避免为每一个节点都去设置颜色,大小for node in node_list: a1n = df.loc[node,:] an1 = df.loc[:,node] a11 = df.loc[node,node] node_sum = a1n.sum()+an1.sum()-a11 # 横向量加总+纵向量加总-对角线 node_type = pd.cut([node_sum], node_bins, labels=node_args.keys())[0] print(node, '=》 节点的数值计算结果为', node_sum, '分类为', node_type) G.add_node(node) if node_type not in node_dict: node_dict[node_type] = []    node_dict[node_type].append(node)
    左右滑动查看更多
在以上代码中,我们规定,一个节点的属性,跟这个节点对应于矩阵中的“横向量加总+纵向量加总-对角线”的结果node_sum有关,如图三:

图三

计算出node_sum后,再根据node_bins中的区间、node_args中的key来分类。计算出node_type后,然后将节点加入图对象G中,同时将其保存在node_dict中对应的节点属性分类下(方便画图时直接使用)。

注意:图四中的分类,我们是对原始矩阵,做数值上的分类。图五中的计算,是根据之前的数值分类,进行二次分类。目的是对节点的颜色、大小等属性进行分类。这个必须区分清楚。

计算结果如图四所示:

图四
此外,node_args中还规定了不同的node_type对应在图中的不同的大小和颜色,这里有两个地方我觉得值得提一下。

1、其实使用字符串来作为key会更好,用来区别于第一次的数值分类。


2、有可能大家会觉得使用字典来存放大小、颜色这类属性会更加直观一些,这个就是仁者见仁智者见智了,我觉得使用元组或列表会更简洁。

# 计算连线属性
# 连线数值的分类edge_bins = [-float('inf'), 0, 1, 2, 3, float('inf')]
# 第一个是线的粗细,第二个需要填入RBG颜色值,第三个为连线的种类edge_args = { 0: (0, (255,255,255), 'dotted') , 1: (0, (255,255,255), 'dotted') , 2: (0.4, (135,206,250), 'dotted') , 3: (0.6, (100,149,237), 'solid') , 4: (1.5, (65,105,225), 'solid')}hidden_list = (1,)
edge_dict = {edge_type:[] for edge_type in edge_args.keys()}# 此处的连线为所有节点的组合,例如[1,2,3] => [(1, 2), (1, 3), (2, 3)]for edge0 in itertools.combinations(node_list,2): edge_num = df.loc[edge0] + df.loc[edge0[::-1]] # a12+a21 edge_type = pd.cut([edge_num], edge_bins, labels=edge_args.keys())[0] print(edge0, '=》 连线的数值计算结果为', edge_num, '分类为', edge_type) G.add_edge(*edge0, weight=edge_args[edge_type][0]) # 此处权重跟图中哪个区在中央有关 edge_dict[edge_type].append(edge0)
左右滑动查看更多
连线属性的计算与节点的思路基本相同,我增加了一个hidden_list,用来规定不画哪些类的连线,防止画出来的图线条太多,看不清。
计算结果如图五所示:

图五
将所有前期准备工作都处理完毕后,下面就可以开始画图了。
# 为节点分类排序,分类数值越大,位置越可能靠近同心圆圆心nlist = sorted(list(node_dict.values()), key=lambda tup:tup[0])
"""由于shell_layout有个BUG,如果一个同心圆上只有一个节点那么算法就会把他放在这组同心圆的圆心处无论多少个这种节点,都会重叠在一起我的解决方法,暂定为:添加假的节点,然后在生成pos之后删掉,避免真的画进图中所以,最好别用FakeNode来命名节点"""nlist = copy.deepcopy(nlist)fakenode_list = []for i, l0 in enumerate(nlist): if i == 0: continue if len(l0) == 1: fake_node = 'FakeNode' + str(random.randint(0,100)) l0.append(fake_node) fakenode_list.append(fake_node) # 节点排列为同心圆排列方式pos = nx.shell_layout(G , nlist=nlist )# 删除假的节点pos = {node:arr for node,arr in pos.items() if node not in fakenode_list}
# 每一类“节点”单独设置一个样式for node_type, nodelist in node_dict.items(): nx.draw_networkx_nodes(G , pos , alpha=1.0 # 节点颜色的透明度 , nodelist=nodelist # 需要加入的节点列表 , node_size=node_args[node_type][0] # 节点大小 , node_color=rgb_to_hex(node_args[node_type][1]) # 节点颜色,将RBG转为十六进制颜色数 # range(len(nodelist))#                            , cmap=plt.cm.Greens )
# 每一类“连线”单独设置一个样式for edge_type, edgelist in edge_dict.items(): # 略过不要的分类 if edge_type in hidden_list: continue nx.draw_networkx_edges(G , pos , edgelist=edgelist # 需要加入的连线列表 , width=edge_args[edge_type][0] # 连线的粗细 , edge_color=rgb_to_hex(edge_args[edge_type][1]) # 连线的颜色 , alpha=0.9 # 连线颜色的透明度 , style=edge_args[edge_type][2] )
# 设置标签的样式nx.draw_networkx_labels(G , pos , font_family='sans-serif' # 字体 , font_size=4 # 字体大小 , alpha=0.8) # 透明度
plt.savefig("2019一模.png" , dpi=600 # 输出图片的分辨路 )plt.show()

左右滑动查看更多

代码有点长,一是由于三大属性每个都要设置一遍,二是由于shell_layout中的一个BUG(也可能是我不会用QAQ),具体情况我已备注在代码中,这里不再赘述。
这里思路方面其实也很简单,生成节点坐标集pos,再依次画出节点、连线、标签,按部就班即可完成。最终完成的图就是大家在上期推文中看到的第一幅图。





绘制二模社会网络图


接下来我们来看看二模的社会网络图,为了规避可能出现的莫名其妙的BUG,我们可以先使用如下代码把图清空:
G.clear()

左右滑动查看更多

接下来,流程差不多。由于二模图是个有向图,我们用nx.DiGraph()生成一个有向图的对象G。
G = nx.DiGraph()plt.figure(1,figsize=(60,60)) # 设置“二模”图片大小

左右滑动查看更多

节点方面,二模的节点与一模有所不同,一模中一个城市就对应一个节点,而在二模中,一个城市要分成“出发”和“到达”。因为A城市企业投给B城市的资金,与B城市企业投给A城市的资金,一般是不会相等的。为了把这个情况真实的反应在图中,我们有必要对一个城市做两个位置分类,请看代码:
# 计算节点大小
# 节点数值的分类node_bins = [-float('inf'), 0, 10, 18.5, 27.5, 34.5,float('inf')]# 将“出发”(设为0)、“到达”(设为1)节点分开设置参数, 第一个是节点的大小,第二个是节点形状,第三个是节点颜色node_args = {-1: {0:(100, 'o', (255,255,255),) , 1:(100, 'h', (255,255,255),)} , 0: {0:(100, 'o', (255,248,220),) , 1:(100, 'h', (255,240,245),)} , 1: {0:(200, 'o', (255,250,205),) , 1:(200, 'h', (255,228,225),)} , 2: {0:(300, 'o', (255,246,143),) , 1:(300, 'h', (255,181,197),)} , 3: {0:(400, 'o', (255,236,139),) , 1:(400, 'h', (255,160,122),)} , 4: {0:(500, 'o', (255,215,0),) , 1:(500, 'h', (255,114,86),)} }# 为所有节点做“位置”分类node_dict = {}for node in node_list: # 计算横向量,即出发类节点 a1n = df.loc[node,:] node_sum = a1n.sum() # 横向量总和 node_type = pd.cut([node_sum], node_bins, labels=node_args.keys())[0] if (node_type, 0) not in node_dict: node_dict[(node_type, 0)] = [] node_dict[(node_type, 0)].append(node) print(node, '分类', (node_type, 0)) G.add_node(node) # 计算纵向量,即到达类节点 an1 = df.loc[:,node] node_sum = an1.sum() # “到达”类节点前后都加一个空格,用来区分“出发”类节点,以免覆盖“出发”类节点 node = ' ' + node + ' ' node_type = pd.cut([node_sum], node_bins, labels=node_args.keys())[0] if (node_type, 1) not in node_dict: node_dict[(node_type, 1)] = [] node_dict[(node_type, 1)].append(node) print(node, '分类', (node_type, 1)) G.add_node(node)

左右滑动查看更多

对比一模的节点属性分类代码,我们这里有几个变化:

1、每个属性分类下再添加了一个位置类的属性,即“出发”(设为0)、“到达”(设为1)。同样的,容易看晕的同学可以改成字符串。


2、在向node_dict中存入node_type的时候,我加上位置属性0、1,。由于元组是不可变变量,自然能作为字典的key。


3、“出发”类节点与“到达”类节点必然会重名,重名的节点在使用add_node()函数的时候会直接覆盖已有节点,这个从pos的对象类型(字典)也看得出来。针对这种情况,我想出来的办法是,在“到达”类节点的名字前后都加一个空格。加两个空格的原因,是防止图中的标签因为空格的存在而发生位置偏移,例如在最后加一个空格,视觉上标签就会往左偏。

# 计算连线属性
# 连线数值的分类edge_bins = [-float('inf'), 0, 1, 2, 3, float('inf')]
# 第一个是线的粗细,第二个需要填入RBG颜色值edge_args = { 0: (0.1, (255,255,255), ) , 1: (0.2, (255,255,255), ) , 2: (0.4, (255,160,122), ) , 3: (0.6, (238,149,114), ) , 4: (1.5, (255,114,86), )}hidden_list = (1,)
edge_dict = {edge_type:[] for edge_type in edge_args.keys()}# 此处的连线为所有节点的排列,例如[1,2,3] => [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)]for edge0 in itertools.product(node_list,repeat=2): edge_num = df.loc[edge0] # 取出“二模”数组的对应值 edge_type = pd.cut([edge_num], edge_bins, labels=edge_args.keys())[0] G.add_edge(*edge0, weight=edge_args[edge_type][0]) # 此处权重跟图中哪个区在中央有关    edge_dict[edge_type].append((edge0[0],' ' + edge0[1] + ' '))

左右滑动查看更多

二模的连线的属性有两点可提:

1、我在做这个项目的时候,有向的连线可以修改箭头样式,但永远都会是实线,怎么修改都画不出虚线来,设置draw_networkx_edges中的style参数没有任何效果,有画出来的同学可以留言教教我。


2、在遍历所有连线的时候,我这里是使用了itertools库中的product函数,即笛卡尔积函数。这是为了确保遍历矩阵中所有的点,我们是在node_dict中对“到达”类节点的名称进行了修改,node_list中还是原来矩阵中的节点个数。一模中我们使用的是combinations函数,因为两个节点之间只会有一种连线。

设置完成以后,开始画图:
merge_list = lambda l0: functools.reduce(lambda l1,l2:l1+l2, l0)# 这里的nlist中,越靠前的分类,在图中的位置越可能靠近同心圆中心# 此分类元祖中,例如(4,0),第一个值4,代表4这个node分类;第二个数字,代表“出发”或者“到达”,0即“出发”,1即“到达”circle_classify = [ ((4,0),) , ((3,0), (3,1)) , ((2,0), (2,1)) , ((1,0), (1,1)) , ((0,0), (0,1))]nlist = []node_type_set = set(node_dict.keys())for type_tup in circle_classify: circle_list = [] for node_type_tup in type_tup:#         print(node_type_tup) if node_type_tup in node_type_set: circle_list.extend(node_dict[node_type_tup]) node_type_set.remove(node_type_tup) else: print('Warning: circle_classify中的分类', node_type_tup, '没有数据') nlist.append(circle_list) # 将circle_classify中没有的涉及的分类添加至最大的同心圆if node_type_set: rest_types = merge_list([node_dict[node_type] for node_type in node_type_set]) nlist.append(rest_types)    print('添加',rest_types,'到最外围')
左右滑动查看更多

我只贴出了有变化的代码。在一模中,我是根据节点的属性分类来排同心圆内的位置的。而在二模中,我们需要根据图形主题的需要,突出某几个特定节点的重要性,所以我这边就在circle_classify中允许自定义节点位置。

同时为了减少不必要的麻烦,未加入circle_classify的分类我都会放在半径最大的同心圆上,并打印出来以做提示,主要是为了避免分类太多时的一些不必要的麻烦。

nlist调整完毕之后,就可以按照类似于一模中的代码来画图了,最终图形也就是大家在上期文章中看到的第二幅图:

至此,我们就完成了社会网络分析图的绘制,你学会了吗?
超大声地说一句:关注我们并在公众号后台回复“小刘老师”即可获取一模、二模完整画图代码喔~还不赶快行动!







►往期推荐

回复【Python】👉简单有用易上手

回复【学术前沿】👉机器学习丨大数据

回复【数据资源】👉公开数据

回复【可视化】👉你心心念念的数据呈现

回复【老姚专栏】👉老姚趣谈值得一看


►一周热文

工具&技巧 | 经济学圈特供 小刘帮你画专业社会网络图(一)

学术前沿 | 想要用好机器学习,这三个坑你千万得留心

特别推荐丨老姚专栏:“读书无用论”有道理吗?从比较的视角看“识别”问题

数据呈现丨装X利器来袭,Python可视化库Bokeh助你俘获小姐姐的心

工具&方法 | 六步法,用Python进行机器学习项目可以如此明了




数据Seminar

这里是大数据、分析技术与学术研究的三叉路口


作者:Dyson(刘颖波)审阅:威武哥(叶武威)、江东(刘良东)编辑:青酱




    欢迎扫描👇二维码添加关注    


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存