GitHub地址:github.com/weijie-he/ji

一、緣起

2018年10月30日,金庸在香港逝世,享年94歲。

知道這個消息之後,我的情緒很低落,講臺上老師在講什麼彷彿也聽不見了,腦海中一直在回想著先生寫過的關於離別的句子。

程英道:「三妹,你瞧這些白雲聚了又散,散了又聚,人生離合,亦復如斯。你又何必煩惱?」 她話雖如此說,卻也忍不住流下淚來。

卻聽得楊過朗聲說道:「今番良晤,豪興不淺,他日江湖相逢,再當杯酒言歡。咱們就此別過。」

金庸先生告訴了我什麼是「俠」。作為先生的忠實讀者,我覺得自己該做點什麼來緬懷先生,以我自己的方式。

正好,我在學Spark,便想到了利用Spark GraphX 做金庸小說人物關係分析圖。

二、需求分析

金庸先生給我們留下了什麼呢?最著名的無非是「飛雪連天射白鹿,笑書神俠倚碧鴛」這14本小說了。最容易想到的便是對這14本書做一張人物關係分析圖。但這一來人物太多,最後畫出的圖會很大;二來不同書之間的人物很多也沒什麼關聯,硬把他們放在同一張圖裡並不妥當。最終我決定只選取人物聯繫最緊密的「射鵰三部曲」(《射鵰英雄傳》、《神鵰俠侶》、《倚天屠龍記》)來進行分析。

但是隻分析人物又感覺略顯單薄。金庸小說中還有一些其他的元素,比如如雷貫耳的稱號(東邪西毒)、高深莫測的武功(黯然銷魂掌)、神兵利器(倚天劍、屠龍刀)。我想把這些元素也加入到分析之中。

同時還要考慮怎麼利用Spark GraphX 的圖計算功能,做一些有意義的分析。

最終確立了以下需求:

  1. 分析人物之間的親密度關係
  2. 找出「專屬暱稱」 (很多人物之間的交流並不會直呼其名,比如黃蓉會叫郭靖「靖哥哥」,我想找出類似的「專屬暱稱」)
  3. 探索小說人物中「孤島羣體」(即「小圈子」)

  4. 有沒有誰經常被某種武功/兵器揍

三、工作流程

3.1 獲取"射鵰三部曲"小說原文、人物名冊、稱號武功武器大全等初始數據

? 小說原文很容易獲取,人物名冊、稱號武功武器大全 等也可以在網上搜到。

3.2 數據預處理,將數據轉換成GraphX需要的格式

? GraphX需要的是頂點集和邊集的信息。

? 在人物親密度圖中,我將人名、暱稱作為頂點;在人物—武器關係圖中,我將人名、武器、武功作為頂點。

至於邊集信息,是這樣確定的:以原文中每一句話為單位。如果在這句話中,出現了兩個上述的「頂點」,則認為他們產生了一次聯繫。如果在這句話中,出現了三個「頂點」,則認為他們兩兩之間都有一次聯繫。以此類推。

? 處理完的結果保存在resources文件夾中。結果如下所示

3.3 使用Spark GraphX 生成圖

我想把聯繫的次數作為邊的權重。首先就要統計同一個聯繫出現的次數。這一步有點像WordCount,由於不想讓一些打醬油的人物出現,所以還用了個filter函數過濾。

/**
* 統計關係出現的次數
* @param sc
* @param path:邊文件
* @param num:關係數量閾值
* @return
*/
def edgeCount(sc:SparkContext,path:String,num:Int) ={
val textFile = sc.textFile(path)
val counts = textFile.map(word => (word, 1))
.reduceByKey(_ + _).filter(_._2>num)
// counts.collect().foreach(println)
counts
}

使用頂點集和邊集構建圖

/**
* 構建圖
* @param sc
* @param path1:頂點文件
* @param path2:邊文件
* @param num:關係數量閾值
*/
def creatGraph(sc:SparkContext,path1:String,path2:String,num:Int) ={
val hero = sc.textFile(path1)
val counts = edgeCount(sc,path2,num)

val verticesAll = hero.map { line =>
val fields = line.split( )
(fields(0).toLong, fields(1))
}

val edges = counts.map { line =>
val fields = line._1.split(" ")
Edge(fields(0).toLong, fields(1).toLong, line._2)//起始點ID必須為Long,最後一個是屬性,可以為任意類型
}
val graph_tmp = Graph.fromEdges(edges,1L)
// 經過過濾後有些頂點是沒有邊,所以採用leftOuterJoin將這部分頂點去除
val vertices = graph_tmp.vertices.leftOuterJoin(verticesAll).map(x=>(x._1,x._2._2.getOrElse("")))
val graph = Graph(vertices,edges)

graph
}

至此,需求中的第一點:人物親密度關係圖已經生成。

類似的,我們更換一下頂點集和邊集,就可以生成人物——武器武功的關係圖,從而找出有沒有誰經常被某種武功/兵器揍。

3.4 使用Spark GraphX 處理圖

可以通過找出度為1或2的點,來尋找「專屬暱稱」。

/**
* 找出度為1或2的點
* @param g
* @tparam VD
* @tparam ED
* @return
*/
def minDegrees[VD,ED](g:GraphOps[VD,ED])={
// g.degrees.filter(_._2<3).map(_._1).collect().mkString("
")
g.degrees.filter(_._2<3).map(_._1).collect().map(a =>a.toInt)
}

通過使用內置函數connectedComponents()可以找到小說人物中「孤島羣體」(即「小圈子」)。

/**
* 使用連通組件找到孤島人羣
* @param g
* @tparam VD
* @tparam ED
* @return
*/
def isolate[VD,ED](g:GraphOps[VD,ED]) ={
g.connectedComponents.vertices.map(_.swap).groupByKey().map(_._2).collect().mkString("
"
)
}

由於之前我們是每本書都生成一張圖,最後我們還需要把這幾張圖合併為一張圖。

思路就是先取得所有頂點信息,去除,再對這些頂點重新編號。再對這些新生成的點重新構建邊。

/**
* 合併2張圖
* @param g1
* @param g2
* @return
*/
def mergeGraphs(g1:Graph[String,Int],g2:Graph[String,Int]) ={
val v = g1.vertices.map(_._2).union(g2.vertices.map(_._2)).distinct().zipWithIndex()

def edgeWithNewVid(g:Graph[String,Int]) ={
g.triplets.map(et=>(et.srcAttr,(et.attr,et.dstAttr)))
.join(v)
.map(x => (x._2._1._2,(x._2._2,x._2._1._1)))
.join(v)
.map(x=> new Edge(x._2._1._1,x._2._2,x._2._1._2))
}
def reduceEdge(g3:Graph[String,Int],g4:Graph[String,Int])={
edgeWithNewVid(g3).union(edgeWithNewVid(g4)).
map(e=>((e.dstId,e.srcId),e.attr)).
reduceByKey(_+_).
map(e=>Edge(e._1._1,e._1._2,e._2))
}
Graph(v.map(_.swap),reduceEdge(g1,g2))
}

3.5 導出到Gephi

我們可以把圖像按照gexf格式輸出,然後在Gephi中打開,就可以進行圖形化展示。

/**
* 輸出為gexf格式
* @param g:圖
* @tparam VD
* @tparam ED
* @return
*/
def toGexf[VD,ED](g:Graph[VD,ED]) ={
"<?xml version="1.0" encoding="UTF-8"?>
"
+
"<gexf xmlns="http://www.gexf.net/1.2draft" version="1.2">
"
+
" <graph mode="static" defaultedgetype="directed">
"
+
"<nodes>
"
+
g.vertices.map(v => " <node id=""+v._1+"" label=""+v._2+"" />
"
).collect().mkString+
"</nodes>
"
+
"<edges>
"
+
g.edges.map(e => " <edge source=""+e.srcId+"" target=""+e.dstId+"" weight=""+e.attr+""/>
"
).
collect().mkString+
"</edges>
</graph>
</gexf>"

}

四、結果展示與分析

以下圖片的高清完整版可在output/pics中找到

4.1 人物親密度關係分析

可以看出郭靖和黃蓉的顏色是最深的(聯繫是最緊密的)。這是因為他們在《射鵰》和《神鵰》中都有很多戲份。《神鵰》中的男女主小龍女和楊過聯繫也很緊密。相比之下《倚天》中的男女主張無忌和趙敏直接的線就淡的多了。一方面,這是因為趙敏的出場時間太晚(全書40章,趙敏在第23章纔出場)。另一方面,張無忌優柔寡斷,情感方面也一直在趙敏和周芷若之間猶豫不決,導致張無忌的情感線被周芷若分流了許多。

4.2 「專屬暱稱」分析

由於我只是篩選出了度為1和2的點,但有些點是人名,而不是暱稱,不必看。

我原來以為「專屬暱稱」只出現在情侶之間,但發現有兩個例外。

  • 洪七公——靖兒

這兩人情同父子。郭靖自幼喪父,洪七公也沒有子嗣。俗話說,「一日為師終身為父」,我覺得這兩個人不是父子,甚是父子。所以有這樣的「專屬暱稱」也不奇怪。

也許江南七怪也和郭靖情同父子,但可能是因為出現的頻率不夠高,所以被過濾掉了,這張圖上並沒有出現。

  • 陸無雙——傻蛋

全書只有陸無雙一人可以叫楊過」傻蛋「,因為當初楊過騙陸無雙自稱傻蛋。

那道姑道:「我幾時騙過你了?喂,小子,你叫甚麼名字?」楊過道:「人人都叫我傻蛋,你不知道麼?你叫甚麼名字?」那道姑道:「傻蛋,你只叫我仙姑就得啦。」

? 摘錄了一下原文,發現短短几句話,這道姑(陸無雙)就笑了2次,足見他們相處的多麼愉快。過兒一生孤苦,和陸無雙在一起的日子也算是為數不多的快樂時光。我覺得他們倆很有成為情侶的可能,只可惜過兒心裡已經有了小龍女。最後他們倆結為了兄妹,也算是一段「有情人終成兄妹」的悲劇故事。

4.3 「孤島人羣」分析

發現只有3個「孤島人羣」(小團體)。

簡捷和薛公遠是《倚天屠龍記》中被金花婆婆打傷,找胡青牛治病的人。和他們有交集的人確實很少。

李萍被段天德綁架,很長一段時間內只有他們兩個在一起,別人都不知道他們去了哪。

朮赤和察合臺是成吉思汗的兩個兒子。和他們有交集的人也很少。

這三本書中涉及到的人物,即使過濾完,也有將近200號人。如果在現實生活中,200人中應該會有更多的小團體,而且也不會全是2人組,可能有3~5人小團體。

以下是我認為可能的兩點原因:

  1. 小說中,配角是為主角服務的,一般不會獨立於主線人物之外去寫小團體
  2. 即便需要,寫2人也夠了,沒必要花筆墨寫

4.4 人物——武功兵器分析

主要想看誰經常被哪種武功兵器揍。

  • 無忌——玄冥神掌

無忌小時候就因為中了玄冥神掌差點死掉,長大後也經常和玄冥二老斗。

  • 郭靖——蛤蟆功

蛤蟆功可以說是郭靖發明的,就是因為他篡改了《九陰真經》,寫了本「九陰假經」,才讓歐陽鋒練成了蛤蟆功。後來也數次和歐陽鋒的蛤蟆功交手。《神鵰》中小楊過也學了點蛤蟆功,被郭靖發現了,這又產生了一次交集。

  • 楊過——金輪、浮塵

這是書中兩大反派金輪法王和李莫愁的武器。

五、後記

人人都知道金庸,可大多是通過影視作品,讀過原著的人少的可憐。做這個項目,在緬懷先生的同時,也希望有更多的人能去讀一讀原著,體會一下先生筆下原汁原味的江湖。

推薦閱讀:

相關文章