【Rで自然言語処理】Term-co-occurrence matrixから距離行列とグラフをつくる実験

前にこんな記事を書きました。

wanko-sato.hatenablog.com

構文解析の結果から語のつながりを抽出して意味あるいは概念のネットワークを構築しよう!というアイディアです。が、進んでいません。というのも。

KNPの結果の解析がめんどくさい。

というごくごくしょーもない理由だったりするわけです。
じゃあ、別の入り口はなかろうか?と考えていたときに、お気に入りパッケージ"text2vec"のTerm-co-occurance matrixが面白いことに使えそうな気がしてきたので、ちょっと実験してみた、という話です。

Term-co-occurrence matrixtとは?

自然言語処理をやっている方には今更な概念ですが、Term-co-occurrence matrixまたは「共起行列」は文書内である単語のペアがどれだけの頻度で出現するか、を表す指標です。

qiita.com

ここに具体的な例示があるのでそれをご参照ください。
なんでこれが重要な指標になるかというと、あるペアが高頻度で出現するということは、そのペアが何らかの関係をもつ、ということに等しいからです。例えば「イチロー」と「野球」は同一文書内に高頻度で出現する可能性が高いですが、「イチロー」と「フランス革命」が同時に現れることはほぼないでしょう。つまり、語と語の関係の深さが、そのペアの現れる頻度として抽出できる、と言えるのです。

下準備

今回もWikipediaフランス革命の項を使います。

wanko-sato.hatenablog.com

詳細は上記記事を参照してください。
テキストファイルに落としてRで読み込み、形態素解析をする、という流れは変わりません。
上記記事では一部、分割されると都合の悪い単語を変換するテーブルを作っていますが、今回は変換テーブルは使用せず、形態素解析された結果をそのまま使います。その代わり、解析にしようした単語は名詞のみとしました。詳しくはコードをごらんください。

実験した環境は次の通り。

加えて、Mecabの辞書は上記記事と同様、Neologdにしています。

以下、形態素解析までのコードです。

# パッケージ読み込み
library(text2vec)
library(textmineR)
library(dplyr)
library(linkcomm)
library(igraph)
library(rgl)

##########################################################################
# 準備

# テキスト読み込み
path <- file.path(getwd(),"R","test_codes")

txt <- scan(file=file.path(path,"FrenchRevolution.txt"),
            what = character(),
            sep = "\n",
            blank.lines.skip = T)


##########################################################################
# 形態素解析

morph_check <- sapply(txt, function(x){
  command <- paste("echo", x, "| mecab")
  out_mec <- system("cmd.exe", input=command, intern=T)
  #余計な行を削除
  out_mec_red <- out_mec[seq(5, (1:length(out_mec))[out_mec=="EOS"]-1)]
})

morph_checkOut <- unlist(morph_check)
write.csv(morph_checkOut,file=file.path(path,"check.csv"))

morph_out <- sapply(txt, function(x){
  command <- paste("echo", x, "| mecab")
  out_mec <- system("cmd.exe", input=command, intern=T)
  #余計な行を削除
  out_mec_red <- out_mec[seq(5, (1:length(out_mec))[out_mec=="EOS"]-1)]
  #結果を分割
  out_mec_list <- lapply(out_mec_red, function(x){
    out <- unlist(strsplit(x, split="\t")) %>% strsplit(split=",")
    return(unlist(out))
  })
  #名詞の一部を使用する
  out_mec_noun <- unlist(sapply(out_mec_list, function(x){
    if(x[2] =="名詞" & x[3]!="接尾" & x[3]!="数" & x[3]!="接頭" &
       x[3]!="非自立" & x[3]!="代名詞" & x[3] != "副詞可能" &
       x[3] != "接続詞的" & x[3] != "ナイ形容詞語幹"){
    #if(x[2] =="名詞" & x[3]!="固有名詞"){
      return(x[1])
    }
  }))
  #半角スペースを空けてひとつにつなげる←ここがポイント
  out_sentence <- paste(out_mec_noun, collapse=" ")
  
  return(out_sentence)
})

names(morph_out) <- seq(1,length(morph_out))

これで形態素解析まで済んだ半角スペース区切りの文字列の集合ができました。ここからTerm-co-occurrence matrixを作っていきます。

text2vecのTerm-co-occurrence matrixのクセ

お気に入りパッケージ"text2vec"はWord Embeddingのための自然言語処理総合パッケージです。もちろん、Term-co-occurrence matrixを生成する関数も含まれています。次のように、TCMを生成できます。

どんなものができるか、テストでちいさめの文字列集合で試してみましょう。テストで使用するのはWikipediaフランス革命の項の、一番最初のパラグラフです。

"フランス革命 フランス かく めい 仏 Revolution francaise 英 French Revolution 18世紀 フランス王国 ブルボン朝 市民革命 世界 史上 代表 市民革命 前近代 社会体制 変革 近代 ブルジョア 社会 樹立 革命 1787年 ブルボン朝 王権 貴族 反抗 擾乱 1789年 社会 本格 革命 政治体制 絶対王政 立憲王政 共和制 テルミドール 反動 退潮 1799年 ナポレオン・ボナパルト クーデター 帝政 樹立 1799年 11月 ブリュメール クーデター 一般的 貴族 反抗 1799年 ナポレオン クーデター 革命 フランス 王政 アンシャン・レジーム 崩壊 過程 封建的 特権 撤廃 近代 所有権 確立 アッシニア 紙幣 混乱"

形態素解析を行い、名詞の一部を取り出したものがこんな感じです。さて、この文字列集合をTCMの変換してみましょう。

# test
tokenizerT <- itoken(morph_out[1], ids=c(1,length(morph_out[1])))
vocabTC <- create_vocabulary(tokenizerT)
vectorizerTC <- vocab_vectorizer(vocabTC, skip_grams_window = 20L)
tcmT <- create_tcm(tokenizerT, vectorizerTC)

# ラベルの文字コードを変換
rownames(tcmT) <- iconv(rownames(tcmT),"cp932")
colnames(tcmT) <- iconv(colnames(tcmT),"cp932")

ちなみに"skip_grams_window"は何単語先までを「共起している」と判定するかの指標です。20Lとすると、前後20単語分を「共起している」と判定します。なぜそうするかというと、text2vecでは単語間の距離も重要な要素として考えているからです。TCMをみればどういうことかわかるでしょう。

> head(tcmT)
6 x 58 sparse Matrix of class "dgTMatrix"
   [[ suppressing 58 column names ‘紙幣’, ‘アッシニア’, ‘確立’ ... ]]
                                                                    
紙幣       . 1 0.5 0.3333333 0.2000000 0.1666667 0.1428571 0.1250000
アッシニア . . 1.0 0.5000000 0.2500000 0.2000000 0.1666667 0.1428571
確立       . . .   1.0000000 0.3333333 0.2500000 0.2000000 0.1666667
所有権     . . .   .         0.5000000 0.3333333 0.2500000 0.2000000
撤廃       . . .   .         .         1.0000000 0.5000000 0.3333333
特権       . . .   .         .         .         1.0000000 0.5000000

一行目をみてください。
「紙幣」と「アッシニア」が1になっています。これは上の元の文字列集合を見ると、確かに「紙幣」と「アッシニア」は隣り合っています。また、そのペアは一回しか現れていません。一方、「紙幣」と「確立」は0.5になっています。

あれ?TCMって頻度を表すのではなかったのでしょうか?

もう一度、元の文字列集合を見てみましょう。最後の方に「確立 アッシニア 紙幣」とあります。「確立」は「紙幣」の二単語となりにあります。「アッシニア」に比べてちょっと離れた位置にある、ということです。text2vecでは、同一文書内に出現するペアであっても、遠く離れていればそのペアはあまり重要ではない、と判定するのです。そして、その重要度の値は単語間の距離の逆数になっています。ということは、

tcmTRev <- 1/tcmT

とTCMの値の逆数をとってあげれば、

> head(tcmTRev)
6 x 58 Matrix of class "dgeMatrix"
           紙幣 アッシニア 確立 所有権 撤廃 特権 封建的 過程 崩壊 王政 変革
紙幣        Inf          1    2      3    5    6      7    8    9   11  Inf
アッシニア  Inf        Inf    1      2    4    5      6    7    8   10  Inf
確立        Inf        Inf  Inf      1    3    4      5    6    7    9  Inf
所有権      Inf        Inf  Inf    Inf    2    3      4    5    6    8  Inf
撤廃        Inf        Inf  Inf    Inf  Inf    1      2    3    4    6  Inf
特権        Inf        Inf  Inf    Inf  Inf  Inf      1    2    3    5  Inf

という風に「単語間の距離行列」を得ることができる、というわけです。
※Infはゼロ割になってしまっているところです。
距離行列が得られるのならば、多次元尺度法にもっていきたくなるのが人情というもの。

さぁ、やってみよう。

多次元尺度法は筋が悪かった

距離行列を多次元尺度法で次元を落とすと面白い結果が得られる、というのはいろいろな方が実験をしています。個人的に好きなのは山手線の駅の配置問題。

d.hatena.ne.jp

いいなぁ。こんな風にできるのかなぁ。

wktkしながら実験してみました。

##########################################################################
# TCMを作る

tokenizer <- itoken(morph_out, ids=c(1,length(morph_out)))
vocabC <- create_vocabulary(tokenizer)
vocabCP <- prune_vocabulary(vocabC, term_count_min = 1)
vectorizerC <- vocab_vectorizer(vocabCP, skip_grams_window = 1000L)
tcm <- create_tcm(tokenizer, vectorizerC)

rownames(tcm) <- iconv(rownames(tcm),"cp932")
colnames(tcm) <- iconv(colnames(tcm),"cp932")

これでTCMができました。
三行目のterm_count_minは「これより出現頻度の低い単語は集計に含めない」という意味になります。今回は1としているので、すべての単語を含んでいます。あまりにも頻度の低い単語が多い場合、これを設定することでノイズを減らすことができます。
skip_grams_windowは1000Lでほぼすべての単語ペアの頻度と距離をTCMに出力します。といっても、さすがに全部は無理なようですが。

これを距離行列に直すため、TCMの値の逆数をとります。

# tcmの逆数をとる
# tcmは単語間の距離の逆数を指標にしているため
tcmRev <- 1/tcm
tcmRev[tcmRev==Inf] <- c(10000)

# 三次元まで計算する
loc <- cmdscale(tcmRev,k=3)

# プロットしてみる
plot(loc)
text(loc, rownames(loc), cex=0.5)

plot3d(loc)

逆数をとって距離行列にした後、cmdscale関数で多次元尺度法で計算し、次元を圧縮します。今回はk=3にして三次元まで計算し、二次元プロットと三次元プロットの両方を出力してみます。

f:id:wanko_sato:20170409183706p:plain

ん~・・・なんかおかしいな・・・
なんで「フランス」が右上端に出てきちゃうんだろう?
「フランス」なんてフランス革命ではいろんな単語と関わりを持ちそうなのに・・・

f:id:wanko_sato:20170409183950p:plain

塊になっているところもあるんだろうけれど、やっぱり「フランス」が端っこにでてしまうのは変わりがないようで。
いや、逆にペアの多い方がはずれにいってしまうのか・・・?
どうもよくわからない結果だし、なんというか、感覚的にいまいち筋が悪そうだなぁ、と感じるので、すこし寝かせておくことにします。

グラフにする

多次元尺度法は今後改めて考えるとして。

大本のTCMは単語ペア間の距離が近ければ近いほど、大きな値になるのでした。ということは、その値を利用して、ペアの重みを出すことができるはずです。そして、その重みが大きければ多きいほど、より深い関わりのある単語ペアである、ということになるわけです。
ならば、その重みを利用して「グラフ」にしてしまってはどうか、と考えました。

Rには要素間の関係を「グラフ」として表現するパッケージがいくつかあります。
代表的なものに「igraph」があります。基本的な機能はそれで十分なのですが、コミュニティ検出に便利な「linkcomm」というパッケージがあります。なかなか面白いパッケージです。

tjo.hatenablog.com

上記リンク先で紹介されているように、「linkcomm」ではソフトクラスタリングができます。通常のクラスタリング(=ハードクラスタリング)では一つの要素が所属できるのはひとつのクラスタに限られますが、ソフトクラスタリングは複数のクラスタに所属することを許容します。それにより、より感覚に近い結果を得ることができると考えます。

と、能書きはおいといて、実際にやってみましょう。

##########################################################################
# TCMからネットワーク図を作る

g <- graph_from_adjacency_matrix(tcm,
                                 diag = F,
                                 weighted = T)

# weightが1.5以下のエッジを削除する
delEdge <- delete.edges(g,E(g)[E(g)$weight<=1.5])
eList <- get.edgelist(delEdge)

lc <- getLinkCommunities(eList)
plot(lc, type="graph",vlabel.cex=0.6,
     layout = layout.kamada.kawai)

graph_from_adjacency_matrix関数のweightedをTRUEにすることで、距離行列の値を重みとしてエッジに持たせることができます。
で、delete.edges関数で重みの小さいエッジを削除しています。今回の例では重み1.5以下のエッジを削除していますが、この値は結果を見ながら調整します。
さて、どんなグラフができたでしょうか?

f:id:wanko_sato:20170409185904p:plain

じゃーん。

真ん中上あたりに「ロベスピエール」がいます。ロベスピエールジャコバン派フランス革命の一翼を担った派閥の頭です。が、フランス革命後に実権を握り、恐怖政治を行ったため処刑されます。という感じのことが周囲の単語から読み取れます。
左下には「サン=ドマング」があります。現在のハイチ共和国にあたる国で、フランス革命の余波を受けて独立運動が起こりました。それに関連して、奴隷制が撤廃されています。
左端には「ナポレオン・ボナパルト」が出てきます。ナポレオンはフランス革命の10年後にあたる1799年にクーデターを起こし、フランス総裁政府を転覆、後に皇帝となり、ヨーロッパ全土を巻き込むナポレオン戦争を引き起こします。
真ん中の方はちょっとごちゃごちゃしてわかりにくいですが、それなりに記述された単語間の関係をちゃんと拾ってきているようです。

これはこれでなかなか良いじゃないか。

今後考えていること。

「KNPの結果を解読するのがめんどくさい」というしょうもない理由で始めたことでしたが、これはこれでうまくいきそうでした。
とはいえ、単語同士の直接的なつながり、そのつながり方の在りようまでは抽出できていないため、やはり構文解析ができた方が良いんだろうな、と思っています。
というわけで、KNPの結果を解読し、そこからグラフを描くということを近々なんとかやってみたいと思います。大変そうだけどな。