목차
이번 포스트에서는 제가 그동안 작성해 온 네트워크 관련 모든 포스트를 활용하여 삼국지 등장인물 간의 상호작용 네트워크를 생성하고 분석해 보겠습니다. 이번 포스트의 최종 목표는 아래와 같이 네트워크를 시각화하는 것입니다.
1. Mecab으로 삼국지 텍스트에서 형태소 추출하기
1 - 1. Mecab 사전에 삼국지 등장인물 이름 등록하기
가장 먼저 해야 할 작업은 삼국지 텍스트에서 형태소를 추출하는 것입니다. 이 과정을 위해 Mecab을 사용하겠습니다.
Mecab의 설치 방법은 아래 포스트를 참고하시기 바랍니다.
아래는 이번 포스트에서 사용할 파이썬 패키지들입니다.
from konlpy.tag import Mecab
import re
import numpy as np
import pandas as pd
import networkx as nx
import seaborn as sns
import matplotlib.pyplot as plt
먼저 삼국지 텍스트 파일을 읽어오겠습니다. (삼국지 텍스트 파일은 구글에서 쉽게 찾으실 수 있습니다.) 저는 이문열 삼국지를 1권부터 10권까지 합쳐 하나의 텍스트 파일로 만들었습니다. 단, 10권 마지막에 있는 노래는 인물들의 상호작용과 상관이 없으므로 제외하였습니다. 읽어들인 텍스트에 정규 표현식을 적용하여, 특수 기호와 숫자들을 제외했습니다. 텍스트를 끝에서부터 출력해보면 아래와 같습니다.
infile = 'sangu.txt'
with open(infile, 'r', encoding="utf-8") as file:
data = file.read().replace('\n', ' ')
text = re.sub('[^가-힣]+', ' ', data)
print(text[-3000:])
이제 아래의 파일을 다운 받아서 열어줍시다. 이 파일은 분석 대상인 삼국지 등장인물의 리스트를 포함하고 있습니다. 특히, 유비의 경우처럼 등장인물들의 별칭(예: 현덕, 유현덕, 유황숙 등)도 별도로 리스트에 포함되어 있는 점에 주의해야 합니다. 이는 텍스트 내에서 등장인물이 다양하게 불리는 경우를 고려하기 위한 것입니다. 나중에 이 별칭들은 각 등장인물의 주요 이름으로 통합될 예정입니다. 추가하고 싶은 등장인물이나 별칭이 있다면 리스트에 추가해도 좋습니다.
with open("chars", "rb") as fp: # Unpickling
chars = pickle.load(fp)
이제 'stopwords.txt' 파일을 다운로드하여 열어보겠습니다. 이 파일에는 형태소 분석 시 의미 없는 단어들을 제외하기 위한 불용어(stopwords) 목록이 포함되어 있습니다. 파일에는 주로 부사, 접속사, 조사 등이 포함되어 있습니다. 저는 이용 가능한 링크에서 제공하는 stopwords 리스트를 참고했으며, 여기에 '나관중', '이문열', '삼국지'와 같은 단어를 추가했습니다. 이렇게 함으로써 텍스트 분석의 정확도를 높일 수 있습니다.
with open('stopwords.txt', 'r', encoding="utf-8") as file:
stopwords = file.read()
이제 삼국지 등장인물들의 이름과 별칭을 Mecab의 사용자 정의 사전에 등록하겠습니다. 이를 위해 먼저 Mecab의 user-dic 디렉토리에 위치한 nnp.csv 파일을 열어야 합니다. 이 파일은 사용자가 특정 명사를 사전에 추가하여 형태소 분석기가 이름과 별칭을 인식할 수 있게 해줍니다. 파일을 열고, 필요한 등장인물의 이름과 별칭을 추가하는 작업을 진행하겠습니다.
with open("C:/mecab/user-dic/nnp.csv", 'r', encoding='utf-8') as f:
user_dic = f.readlines()
이제 'char_dict.txt' 파일을 다운로드하셔서 각 라인을 'nnp.csv' 파일에 추가하시면 됩니다. 'char_dict.txt' 파일에는 각 삼국지 등장인물의 이름과 별칭이 Mecab의 사전 형식에 맞춰져 있습니다. 이 파일을 이용하여 사용자 정의 사전을 강화함으로써, Mecab이 삼국지 텍스트에서 등장인물의 이름을 정확하게 인식하고 분석할 수 있도록 합니다.
with open('char_dict.txt', encoding='utf-8') as f:
char_list = f.readlines()
for char in char_list:
user_dic.append(char)
with open("C:/mecab/user-dic/nnp.csv", 'w', encoding='utf-8') as f:
for line in user_dic:
f.write(line)
nnp.csv 파일의 수정이 끝난 뒤에 Windows Powershell을 관리자 권한으로 실행해준 뒤, C:\mecab 으로 이동하여 아래의 명령어를 실행합니다. 명령어 실행 후, 아래처럼 콘솔에 표시되는 메시지를 통해 사전 등록 과정이 성공적으로 완료되었는지 확인할 수 있습니다.
.\tools\add-userdic-win.ps1
사전에 인물들의 이름을 등록 했으니, 이제 텍스트에서 추출할 수 있을까요?
정답은 아닙니다. Mecab 사전에 등록된 단어들 사이에는 우선순위가 존재하여, 우선순위가 낮은 고유명사가 올바르게 추출되지 않을 수 있습니다. 예를 들어 '손권'이 '손'으로 잘못 추출되는 경우가 발생할 수 있습니다. 이러한 문제를 해결하기 위해, 등록한 이름들의 우선순위를 변경하는 작업이 필요합니다. 아래의 코드는 등록한 고유명사들의 우선순위를 0으로 설정합니다.
with open("C:/mecab/mecab-ko-dic/user-nnp.csv", 'r', encoding='utf-8') as f:
user_dic = f.readlines()
with open("C:/mecab/mecab-ko-dic/user-nnp.csv", 'w', encoding='utf-8') as f:
for line in user_dic:
splt_txt = line.split(",")
splt_txt[3] = '0'
new_order = ",".join(splt_txt)
f.write(new_order)
수정된 파일을 반영하기 위해, Windows Powershell에서 아래 명령어를 실행하시면 됩니다. 이 명령은 Mecab의 사전 컴파일을 다시 수행하여 우선순위 변경사항을 적용합니다. 명령어를 실행한 후, 에러가 없다면 Powershell에는 특별한 오류 메시지 없이 아래처럼 프로세스가 완료될 것입니다.
.\tools\compile-win.ps1
1 - 2. 삼국지 텍스트에서 형태소 추출하기
아래의 코드는 Mecab을 이용하여 텍스트의 형태소를 추출하는 함수입니다.
def wordTokens(text, stop_words):
"""
:param text: A string
:param stop_words: A list of stop words to not be considered in the final tokens
:return: A list of word tokens
"""
wtokens = Mecab(dicpath=r"C:\mecab\mecab-ko-dic").morphs(text)
# Remove stop words
wtokens = [w for w in wtokens if w not in stop_words]
return wtokens
삼국지 텍스트에서 형태소를 추출한 결과를 보면 다음과 같습니다.
tokens = wordTokens(text, stopwords)
tokens
이제 텍스트 데이터의 cleaning은 끝났습니다. 다음은 데이터로부터 네트워크를 구축하는 작업을 해야합니다.
2. Networkx로 삼국지 등장인물 네트워크 만들고 분석하기
2 - 1. 네트워크 구축
다음 단계로, 위에서 얻은 형태소 리스트로부터 인물 간의 상호작용 네트워크를 구축해보겠습니다.
먼저, indices_dic이라는 함수를 통해서 등장인물의 이름 또는 별칭을 key로 갖고, 우리가 위에서 만든 삼국지 텍스트의 형태소 리스트에서 key가 위치하는 index들의 array를 value로 가지는 dictionary를 만들어줍니다.
links_dic_f 함수는 각 인물별로 다른 인물과 상호작용한 횟수에 대한 정보를 담고 있는 중첩된(nested) 딕셔너리를 반환합니다. 구체적으로, 이 중첩된 딕셔너리의 key:value 쌍은 다음과 같은 형태입니다: {'인물1': {'인물2': 인물1과 인물2의 상호작용 횟수}, '인물3': 인물1과 인물3의 상호작용 횟수}}.
그렇다면 텍스트 데이터에서 두 인물의 상호작용을 어떻게 정의할까요? 이는 우리가 위에서 얻은 형태소 리스트에서 두 인물의 이름 또는 별칭이 등장하는 위치를 이용해서 정의합니다. 구체적으로, 텍스트에서 두 인물의 이름이나 별칭이 서로 가까운 위치에서 등장하면, 이를 하나의 상호작용으로 간주하는 것입니다. 이때 가까운 정도의 기준이 되는 파라미터가 바로 threshold입니다. 예를 들어, threshold가 10인 경우, 유비가 형태소 리스트의 17번째에서 등장하고 관우가 22번째에서 등장한다면, 이는 유비와 관우가 한번의 상호작용을 한 것으로 간주하는 것입니다. 한편, 유비가 형태소 리스트의 17번째에 있고, 하후돈이 37번째에 있다면, 이는 상호작용으로 간주하지 않습니다.
이런 식으로 각 인물의 이름이나 별칭이 등장한 위치를 하나하나 비교하여 상호작용의 횟수에 대한 정보를 얻는 것입니다. 추가로, node_include_threshold 파라미터는 기준 횟수 이상의 상호작용을 한 경우만 고려하기 위함입니다. 저는 threshold=20, node_include_threshold=5로 놓고, 중첩된 딕셔너리를 만들었습니다.
def indices_dic(char_list, words):
"""
:param char_list: A list of character names
:param words: A list of word tokens
:return: A dictionary with the characters' names as keys and a array of indices as values.
"""
dic = {}
for char in char_list:
indices = [i for i, x in enumerate(words) if x == char]
dic[char] = np.array(indices)
return dic
def links_dic_f(indices_dic, threshold, node_include_threshold=5):
"""
:param indices_dic: The dictionary with the indices for each character
:param threshold: The distance threshold. If the difference of two indices of two characters is
lower than the threshold, this counts as an interaction
:return: A nested dictionary. Each key is a character name. The values are dictionaries with
keys the characters the initial key character has interacted with, and with values the
number of interactions.
"""
link_dic = {}
for first_char, ind_arr1 in indices_dic.items():
dic = {}
for second_char, ind_arr2 in indices_dic.items():
# Don't count interactions with self
if first_char == second_char:
continue
matr = np.abs(ind_arr1[np.newaxis].T - ind_arr2) <= threshold
s = np.sum(matr)
# Only include character pairs with more than node_include_threshold
if s > node_include_threshold:
dic[second_char] = s
link_dic[first_char] = dic
return link_dic
ind_dic = indices_dic(chars, tokens)
grand_dic = links_dic_f(ind_dic, 20)
print(grand_dic)
우리가 사용하는 등장인물 리스트(chars)에는 인물들의 별칭(유비의 경우: 유현덕, 현덕, 유황숙 등)이 포함되어 있습니다. 따라서, 인물별로 별칭이 갖는 정보를 하나로 통합해야 합니다. 이러한 작업을 위해서 아래 코드의 함수를 사용합니다.
첫 번째 함수(merge_nickname)가 기본 뼈대가 되는 함수입니다. 이를 살펴보면, input으로 한 등장인물의 이름과 그의 별칭(mainname과 nickname)을 받습니다. 예를 들어, mainname=유비, nickname=현덕 이라고 합시다. 그러면 첫 번째 for 구문에서 현덕의 dictionary에 있는 (key, value)를 유비의 dictionary로 병합 합니다.
두 번째 for 구문은 모든 이름과 별칭(key)을 순회하면서 작업을 합니다. 이때 , key=유비이면, 유비의 dictionary에서 현덕에 대한 정보를 삭제합니다. 이는 첫 번째 for 구문에서 이미 정보가 병합 되었기 때문입니다. 반대로, key가 다른 인물인 경우, 그 인물의 dictionary에서 현덕과 유비에 대한 정보를 병합 합니다. 예를 들어, 다른 인물이 조조라고 합시다. 그러면 만약 조조의 dictionary에 유비와 현덕이 동시에 존재하면, 현덕의 value를 유비의 value에 합치고, 현덕에 대한 정보를 삭제합니다. 만약, 유비는 없고 현덕만 존재하면, 유비라는 key를 새로 만들고 현덕의 value를 넣어줍니다. 그리고 현덕에 대한 정보는 삭제합니다.
이 과정을 제가 만든 (이름, 별칭) tuple의 리스트(nick_list)에 대해서 수행하면, 등장인물 리스트(chars)에 존재하는 각 별칭의 정보가 해당 인물의 정보에 병합 됩니다.
def merge_nickname(dic, mainname, nickname):
"""
:param dic: A link dictionary produced by the links_dic_f function
:param mainname: The main name of the character that will remain after the merge
:param nickname: The nickname of the character that will be merge into the main name
:return: A link dictionary like the one produced by links_dic_f but with the nickname
values merged into the main name
"""
for key in dic[nickname]:
if key in dic[mainname]:
dic[mainname][key] += dic[nickname][key]
elif key==mainname:
continue
else:
dic[mainname][key] = dic[nickname][key]
for key in dic:
if key==mainname:
dic[key].pop(nickname, None)
continue
if nickname in dic[key]:
if mainname in dic[key]:
dic[key][mainname] += dic[key][nickname]
else:
dic[key][mainname] = dic[key][nickname]
dic[key].pop(nickname, None)
dic.pop(nickname, None)
return dic
def merge_all_nicknames(dic, nickname_list):
"""
:param dic: A link dictionary produced by the links_dic_f function
:param nickname_list: A list of tuples. The first item of the tuple is the main name of
a character and the second in the nickname
:return: An updated link dictionary with all the nicknames merged into the main names
"""
for tup in nickname_list:
(mainname, nickname) = tup
dic = merge_nickname(dic, mainname, nickname)
return dic
nick_list = [('유비', '현덕'),('유비', '유현덕'), ('유비', '유황숙'),('유비','황숙'), ('조조', '맹덕'),
('조조', '조맹덕'),('원소', '본초'),('원소', '원본초'),('관우', '운장'),('관우', '관운장'),
('장비', '익덕'), ('장비', '장익덕'), ('조운', '자룡'), ('조운', '조자룡'),('순욱', '문약'),
('순욱', '순문약'), ('원술', '공로'), ('원술', '원공로'),('여포', '봉선'), ('곽가', '봉효'),
('곽가', '곽봉효') , ('제갈량', '공명'),('제갈량', '제갈공명'), ('제갈량', '와룡'),
('서서', '원직'), ('방통', '봉추'), ('방통', '사원'),('노숙','자경'), ('사마의','중달')]
grand_dic = merge_all_nicknames(grand_dic, nick_list)
아래의 함수를 이용해서 edge가 상호작용이 존재하는 등장인물의 리스트를 만듭니다. 이 리스트는 네트워크의 노드들을 생성할 때 사용될 것입니다.
def remove_zero_link_chars(link_dic):
"""
:param link_dic: A link dictionary produced by the links_dic_f function
:param chars_list: A list of characters
:return: A list of characters. All of the characters in the final list have links with
other characters in the link dictionary
"""
fin_list = link_dic.keys()
return fin_list
new_chars = remove_zero_link_chars(grand_dic)
노드 리스트를 만들었으니, 이제 edge의 차례입니다. 아래의 함수는 두 인물 사이의 weighted edge를 나타내는 tuple의 리스트를 반환합니다. 이 튜플은 (등장인물1, 등장인물2, weight)의 형태를 갖습니다.
def edge_tuples_f(link_dic):
"""
:param link_dic: A link dictionary produced by the links_dic_f function
:return: A list of tuples to be used as the edges of a graph. The first two items are
the nodes of the edge. The third item is the weight of the edge
"""
edges_tuples = []
for key in link_dic:
for item, value in link_dic[key].items():
tup = (key.title(), item.title(), value/100)
edges_tuples.append(tup)
return edges_tuples
edges_tuples = edge_tuples_f(grand_dic)
이제 networkx 패키지를 이용하여 등장인물들의 상호작용 네트워크를 만들어봅시다.
- 상호작용에는 방향성이 없기 때문에 G라는 undirected network를 생성하겠습니다.
- 여기에 G.add_nodes_from()을 이용하여 등장인물들의 이름을 가지는 노드를 추가합니다.
- 그리고 우리가 위에서 만든 edges_tuples를 이용하여 네트워크에 weighted edge를 추가해줍니다.
- pos는 네트워크를 그릴 때 노드의 위치에 대한 변수입니다. 유비, 조조, 손책에 대해서만 위치를 지정해주고, 나머지 노드는 랜덤하게 위치시키겠습니다.
- nx.draw()를 이용해서 네트워크를 그려보면 아래와 같은 plot이 출렵됩니다.
import matplotlib.font_manager as fm
from matplotlib import rc
font_name = fm.FontProperties(fname="c:/Windows/Fonts/malgun.ttf").get_name()
rc('font', family=font_name)
# Create the graph
G = nx.Graph()
#Add the nodes
G.add_nodes_from(new_chars)
# Add the edges
G.add_weighted_edges_from(edges_tuples)
plt.figure(figsize=(10,6))
pos = nx.spring_layout(G, k=0.6, seed=19935)
pos['유비'] = np.array([0.5, 0.5])
pos['조조'] = np.array([-0.5, 0.6])
pos['손책'] = np.array([-0.1, -0.5])
nx.draw(G, pos, with_labels = True, font_family=font_name,font_size=8, node_color='lightblue', node_size=400, width=0.4)
이제 위의 네트워크에서 커뮤니티를 탐지해보겠습니다. 물론, 삼국지를 읽어 본 사람이라면 각 등장인물들이 어떤 진영에 속하는지 잘 알고 계시기 때문에 커뮤니티 탐지가 별 의미가 없다고 생각할 수 있습니다. 하지만 위의 네트워크는 상호작용 네트워크라는 점을 다시 한번 강조하고 싶습니다. 상호작용은 우호적인 관계와 적대적인 관계를 모두 포함합니다. 그리고 배신과 투항 또한 하나의 상호작용입니다. 그렇다면, 여포 아래에 있다가 하비 전투에서 여포가 패한 후 조조에게 투항한 장료는 어느 진영(커뮤니티)에 속해야 할까요? 이런 오묘한 문제들이 있기 때문에 단순히 진영으로 위의 네트워크를 분할하는 것은 적합하지 않습니다. 저는 Louvain 알고리즘을 통해서 최적의 네트워크 분할을 하겠습니다.
커뮤니티 탐지(Community detection)와 Louvain 알고리즘에 대해 더 자세히 알고 싶으시면 아래의 링크를 참고해주세요.
아래의 코드는 Louvain 알고리즘을 사용하여 커뮤니티를 탐지하고, 네트워크를 분할합니다. nx.set_node_attributes()를 사용하여 'group'이라는 변수를 생성하고, 각 노드가 속하는 커뮤니티를 값으로 저장합니다. 그리고 각 커뮤니티에 색을 배정하고 네트워크를 그려보면 아래의 그림과 같이 plot이 출력됩니다. 하지만 아래의 plot은 보기에 좋지 않으며, 무엇보다 edge의 weight이 반영되지 않았다는 문제점이 있습니다.
from community import community_louvain
# Detect the communities of the graph
partition = community_louvain.best_partition(G, weight='weight')
# Set the community partition as an attribute of the nodes of the graph
nx.set_node_attributes(G, partition, 'group')
color_state_map = {0: 'green',
1: 'red',
2: 'orange',
3: 'yellow',
4: 'lightblue',
5: 'purple'}
color_values = [color_state_map[node[1]['group']]
for node in G.nodes(data=True)]
nx.draw(G, pos, with_labels = True, font_family=font_name,font_size=8, node_color=color_values, node_size=400, width=0.4, alpha=0.9)
위의 커뮤니티 탐지 결과를 보면, 흥미로운 점이 몇가지 있습니다.
- 조조(빨간색), 유비(초록색), 손권(노란색), 제갈량(하늘색), 그리고 여포(주황색), 이렇게 총 5개의 커뮤니티가 탐지되었습니다.
- 관도대전을 통해 원소 진영과 조조 진영의 인물들 사이에 많은 상호작용이 있었기 때문에 원소 쪽 인물들이 모두 조조가 속한 커뮤니티에 속하게 되었습니다.
- 연의에서 동관 전투를 자세히 다뤘기 때문인지, 마초가 조조의 커뮤니티에 속하게 되었습니다.
- 제갈량은 유비로부터 분리되어 다른 커뮤니티를 형성합니다. 이는 유비 사망 후에 사실상 주인공이었으며, 북벌을 통해 다른 인물들과 많은 상호작용을 했기 때문입니다.
2 - 2. 네트워크 분석
네트워크 내에서 각 노드의 중요성을 평가하는 centrality는 해당 노드가 얼마나 핵심적인 역할을 하는지 파악하는 데 도움을 줍니다. 저는 다음과 같은 네 가지 주요 중심성 지표를 사용해 보겠습니다:
- Degree Centrality: 노드가 가진 연결의 수를 나타내며, 네트워크 내에서의 활발한 상호작용을 반영합니다.
- Betweenness Centrality: 노드가 다른 노드 쌍의 최단 경로 위에 얼마나 자주 위치하는지를 나타내며, 네트워크 내에서의 다리 역할을 강조합니다.
- PageRank: 각 노드가 중요한 다른 노드들과 얼마나 잘 연결되어 있는지를 고려한 Google의 유명한 알고리즘입니다.
네트워크의 centrality에 대해서 더 자세히 알고 싶으시면, 아래의 링크를 참고해주시길 바랍니다.
아래의 코드는 각 열을 centrality measure로 갖는 pandas dataframe을 생성합니다.
pd.DataFrame.from_dict() 메소드를 사용할 때 orient='index' 파라미터를 설정함으로써, 각 노드를 행으로 구성하고 각 centrality measure를 열로 설정할 수 있습니다.
def calc_centralities(graph):
dgc = nx.degree_centrality(graph)
dgc = pd.DataFrame.from_dict(dgc, orient='index', columns=["DGC"])
btc = nx.betweenness_centrality(graph)
btc = pd.DataFrame.from_dict(btc, orient='index', columns=["BTC"])
evc = nx.eigenvector_centrality(graph, weight='weight')
evc = pd.DataFrame.from_dict(evc, orient='index', columns=["EVC"])
df = pd.concat([dgc, btc, evc], axis=1)
return df
df = calc_centralities(G)
위에서 얻은 데이터프레임을 이용하여 각 centrality measure 별로 최상위 10명의 등장인물의 centrality measure 값을 나타내는 plot을 만들 수 있습니다.
def plot_centrality(centr, df, title, n, col_list):
ax = plt.subplot(1, 3, n)
s = df.sort_values(centr, ascending=False)[:10]
x = list(s[centr].index)[::-1]
y = list(s[centr])[::-1]
for i, v in enumerate(y):
bars = plt.barh(x[i], v, color=col_list[n-1])
plt.title(title, size=22)
ax.get_xaxis().set_visible(False)
ax.spines['top'].set_visible(False)
ax.spines['bottom'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.tick_params(axis='y', length = 0, labelsize=14)
col_list = ["peachpuff", "plum", "orange"]
fig, ax = plt.subplots(1,3, figsize=(15, 10))
plot_centrality("DGC", df, 'Degree Centrality', 1, col_list)
plot_centrality("BTC", df, 'Betweeness Centrality', 2, col_list)
plot_centrality("EVC", df, 'Eigenvector Centrality', 3, col_list)
Plot을 보면, degree의 경우 조조가 가장 많고, 그 다음이 유비입니다. Degree는 해당 인물과 상호작용한 인물의 수를 의미하므로, 많은 장수와 책사를 거느렸으며 여러 군웅들과의 전투를 통해 세력을 키운 조조가 가장 많은 것은 당연합니다. 유비도 나관중의 삼국지에서 사실상 주인공으로, 그의 degree가 높은 것은 당연한 결과입니다. 의외인 것은 오나라를 담당한 군주 손권의 degree가 제갈량이나 관우보다 낮다는 점인데, 이는 삼국지에서 오나라의 비중이 상대적으로 낮기 때문일 것입니다.
Betweenness는 두 커뮤니티 간의 다리 역할을 하는 인물에게 높은 값을 부여합니다. 1위부터 3위는 조조, 유비, 제갈량으로 degree의 순서와 동일합니다. 손권은 오나라의 중심 인물로 위와 촉의 인물들과의 상호작용이 많아 4위에 올라섭니다. 사마염이 6위에 오른 것이 주목할 만한데, 이는 사마염이 활동하는 시기에 등장하는 인물들 대부분이 그와 상호작용하기 때문입니다.
PageRank(또는 eigenvector centrality)는 상호작용하는 대상이 얼마나 다른 인물들과 활발히 상호작용하는지를 중요시 합니다. PageRank는 edge의 weight를 고려한다는 점에서 위의 두 centrality measure와 차이가 있습니다. 이 경우 유비가 조조를 제치고 1위입니다. 활동 초반에 여러 곳으로 의탁을 자주 하면서 다양한 인물들과 관계를 맺은 결과일 수 있습니다. 관우, 원소, 여포 등은 상호작용이 많은 유비, 조조와의 교류로 인해 PageRank 값이 높은 순위에 올라 있습니다. 마초가 손권을 제치고 9위에 오른 것은 동관 전투에서의 조조와 활발한 상호작용을 하고, 이후 유비에게 투항 하여 유비와 제갈량 등과 교류 했기 때문일 것입니다. 여기에서도 손권과 오나라가 삼국지 연의에서 상대적으로 소외된 위치에 있었음을 알 수 있습니다.
아래의 코드는 weight의 분포를 보여줍니다. Plot을 보면 왼쪽으로 치우쳐진 파레토 분포가 나타납니다. 이는 현실의 네트워크에서 흔하게 관찰되는 분포로, 대부분의 노드는 낮은 weight을 가지고 있으며, 극소수의 노드만 높은 weight를 가집니다.
fig, ax = plt.subplots(figsize=(15, 10))
edge_df = nx.to_pandas_edgelist(G)
s = edge_df["weight"]*100
ax = sns.distplot(s)
이후의 시각화를 위해 각 노드의 betweenness를 계산하여 attribute로 저장하며, 네트워크를 gexf 파일로 저장합니다.
betweenness_dict = nx.betweenness_centrality(G)
nx.set_node_attributes(G, betweenness_dict, 'betweenness')
nx.write_gexf(G, 'sangu.gexf')
3. Gephi로 네트워크 시각화 하기
마지막으로 네트워크를 좀 더 보기 좋게 시각화하는 작업을 해봅시다. 이를 위해 Gephi라는 네트워크 데이터 분석과 시각화를 위한 소프트웨어를 사용할 것입니다. Gephi는 아래의 링크에서 다운로드하실 수 있습니다.
1. Gephi를 설치하고 열어서 File > open 을 통해 위에서 저장 해놓은 sangu.gexf 파일을 열어줍니다. 그러면 아래와 같이 자동으로 네트워크가 나타납니다.
2. 왼쪽 아래에 있는 Layout에서 Force Atlas를 선택하고, Repulsion strength를 400으로 설정해주시고, Adjust Sizes에 체크를 해준 뒤 Run을 클릭해 줍니다. 그러면 노드들끼리 서로 밀어내는 힘이 작용하여 네트워크가 좀 더 보기 쉬워집니다. Force Atlas가 작동하는 중에는 이 밀어내는 힘이 계속 작용되기 때문에 한 노드의 위치를 움직이면 네트워크 전체가 움직이게 됩니다. 만약, 노드의 위치를 원하는 곳으로 옮기고 싶으시면, Stop을 눌러서 Force Atlas를 종료시킨 뒤에 마우스로 노드를 드래그하여 옮겨주면 됩니다.
3. 이제 각 노드를 커뮤니티 별로 색칠해줍니다. 이를 위해서 왼쪽 위 Appearance에서 Nodes를 클릭하고, 옆의 팔렛트 모양 아이콘을 누릅니다. 그 다음 Partition을 클릭하고 아래에서 group을 선택하시면 됩니다. 마지막으로, Apply 버튼을 누르면 노드에 색상이 반영됩니다.
4. Betweenness가 높은 노드는 중요한 노드이기 때문에 이런 노드들은 크기를 키우도록 하겠습니다. 이번에도 왼쪽 위의 Appearance에서 Nodes를 클릭하고, 옆에 원 2개가 겹쳐 있는 듯한 아이콘을 클릭합니다. 그리고 아래에서 Ranking을 클릭하고, betweenness를 선택합니다. Min size와 Max size는 각각 최소와 최대 노드 키기인데, 저는 4와 10을 주었습니다. 마지막으로 Apply 버튼을 누르면 노드의 크기가 반영 됩니다.
5. 이제 노드의 betweenness 값이 클수록 노드의 label 텍스트가 더 커지도록 만들겠습니다. Appearance > Nodes에서 옆에 T가 두 개 있는 아이콘을 클릭해주시고, 아래에서 Ranking을 클릭한 후, betweenness를 선택해 주시면 됩니다. 여기서도 최소와 최대 크기를 정할 수 있는데, 저는 각각 1.5와 2.5를 주었습니다. 마지막으로 Apply 버튼을 눌러서 변화를 반영합니다.
6. 이제 위의 모든 사항들이 반영된 예쁜 네트워크를 시각화할 수 있습니다. 위에서 Preview 라는 버튼을 클릭해 주세요. 그러면 여러 옵션들이 나옵니다. 이중에서 Show Labels와 Proportional size를 체크합니다. 그리고 Font에서 옆에 ...버튼을 클릭해 주시고, 굴림체(GulimChe)를 선택합니다. 다른 폰트를 사용하면, 노드 label이 깨져서 출력됩니다. 마지막으로 맨 밑의 Refresh 버튼을 누르시면 한 눈에 들어오고 예쁜 삼국지 등장인물 상호작용 네트워크가 나타납니다. 여기서는 edge의 weight가 선의 굵기로 나타나 있어 어떤 인물들 사이에 강한 상호작용이 있었는지 파악할 수 있습니다. 또한 edge의 색은 edge가 연결하는 두 인물이 속한 커뮤니티의 색이 합쳐진 색이기 때문에 한 눈에 커뮤니티 사이의 상호작용도 파악할 수 있습니다.
위치가 마음에 안 드는 몇몇 노드가 있으면 다시 Overview로 돌아가서 Force Atlas를 종료하고, 노드의 위치를 옮겨주면 됩니다.이때, 다시 Force Atlas를 실행(Run)하시면 안 됩니다. 그러면 밀어내는 힘이 작용하여 네트워크 전체의 노드 위치가 바뀌게 됩니다. 저는 너무 동떨어진 노드 몇개의 위치를 가까이 옮겨주었습니다. 이제 네트워크를 png나 pdf 파일로 export하면 다음과 같이 완벽한 결과물을 얻을 수 있습니다.
조선왕조실록이나 승정원일기 등의 한국사 텍스트에서도 위와 같이 네트워크를 구축해보면 재미있는 결과가 나올 것 같다는 생각이 듭니다 ㅎㅎ
참고: https://towardsdatascience.com/network-analysis-of-the-romance-of-three-kingdoms-5b1c1b84601d