julia で、PyCall 経由の MeCab と gensim を使って word2vec をしてみる (おまけの LDA)

自然言語処理は、for ループを使うことが多いので、julia に実は向いている処理だと考えます。

python で、三四郎全部を15分でという@makaishi2さんのブログ があります。良くまとまっていますので、python 使いの方は、このブログよりも@makishi2 さんのブログを読む方が良いと思います。よほどの処理でなければ、python で、良いでしょう。理由は結局のところは、gensim に頼るので、python でも julia でも大きな違いにはならないでしょう。ただし、MeCab が律速段階になっている人には、julia は良いと考えます。

今回は、PyCall 経由の MeCab と gensim を使って word2vec の解析をする方法をまとめてみます。例は、実際にコピー&ペーストなどで、形や内容を確かめて頂けるように、青空文庫から、夏目漱石の「坊ちゃん」の冒頭のみを使います。

julia Version 1.1.0 (2019-01-21) と PyCall v1.91.2 と、Python は Python 3.7.3 (default, Mar 29 2019, 21:57:59) とgensim 3.7.3 が動いているのが前提です。

概略は下記のとおり

  1. 文章を読み込む
  2. 改行を取り除く
  3. split() で、1文1行の Array にする
  4. MeCab を使って名詞と形容詞を取りだす関数を定義する
  5. 先程の、Array に、ブロードキャストで、名詞と形容詞を取り出して、Array 内に Array で格納する
  6. gensim.models.word2vec.Word2Vec() でモデルを作る

およそ以上です。

文章を読み込む

テキストファイルなどからの入力もありましょうが、今回はコピ&ペーストします。 ここは長くてゴメンナさい。この位の量が無いと、word2vec が心配なので、大目に見て下さい。pre タグで読めるように「。」の後に改行を挿入しています。



using PyCall, StatsBase

array_text ="親譲《おやゆず》りの無鉄砲《むてっぽう》で小供の時から損ばかりしている。
小学校に居る時分学校の二階から飛び降りて一週間ほど腰《こし》を抜《ぬ》かした事がある。
なぜそんな無闇《むやみ》をしたと聞く人があるかも知れぬ。
別段深い理由でもない。
新築の二階から首を出していたら、同級生の一人が冗談《じょうだん》に、いくら威張《いば》っても、そこから飛び降りる事は出来まい。
弱虫やーい。
と囃《はや》したからである。
小使《こづかい》に負ぶさって帰って来た時、おやじが大きな眼《め》をして二階ぐらいから飛び降りて腰を抜かす奴《やつ》があるかと云《い》ったから、この次は抜かさずに飛んで見せますと答えた。

 親類のものから西洋製のナイフを貰《もら》って奇麗《きれい》な刃《は》を日に翳《かざ》して、友達《ともだち》に見せていたら、一人が光る事は光るが切れそうもないと云った。
切れぬ事があるか、何でも切ってみせると受け合った。
そんなら君の指を切ってみろと注文したから、何だ指ぐらいこの通りだと右の手の親指の甲《こう》をはすに切り込《こ》んだ。
幸《さいわい》ナイフが小さいのと、親指の骨が堅《かた》かったので、今だに親指は手に付いている。
しかし創痕《きずあと》は死ぬまで消えぬ。

 庭を東へ二十歩に行き尽《つく》すと、南上がりにいささかばかりの菜園があって、真中《まんなか》に栗《くり》の木が一本立っている。
これは命より大事な栗だ。
実の熟する時分は起き抜けに背戸《せど》を出て落ちた奴を拾ってきて、学校で食う。
菜園の西側が山城屋《やましろや》という質屋の庭続きで、この質屋に勘太郎《かんたろう》という十三四の倅《せがれ》が居た。
勘太郎は無論弱虫である。
弱虫の癖《くせ》に四つ目垣を乗りこえて、栗を盗《ぬす》みにくる。
ある日の夕方|折戸《おりど》の蔭《かげ》に隠《かく》れて、とうとう勘太郎を捕《つら》まえてやった。
その時勘太郎は逃《に》げ路《みち》を失って、一生懸命《いっしょうけんめい》に飛びかかってきた。
向《むこ》うは二つばかり年上である。
弱虫だが力は強い。
鉢《はち》の開いた頭を、こっちの胸へ宛《あ》ててぐいぐい押《お》した拍子《ひょうし》に、勘太郎の頭がすべって、おれの袷《あわせ》の袖《そで》の中にはいった。
邪魔《じゃま》になって手が使えぬから、無暗に手を振《ふ》ったら、袖の中にある勘太郎の頭が、右左へぐらぐら靡《なび》いた。
しまいに苦しがって袖の中から、おれの二の腕《うで》へ食い付いた。
痛かったから勘太郎を垣根へ押しつけておいて、足搦《あしがら》をかけて向うへ倒《たお》してやった。
山城屋の地面は菜園より六尺がた低い。
勘太郎は四つ目垣を半分|崩《くず》して、自分の領分へ真逆様《まっさかさま》に落ちて、ぐうと云った。
勘太郎が落ちるときに、おれの袷の片袖がもげて、急に手が自由になった。
その晩母が山城屋に詫《わ》びに行ったついでに袷の片袖も取り返して来た。

 この外いたずらは大分やった。
大工の兼公《かねこう》と肴屋《さかなや》の角《かく》をつれて、茂作《もさく》の人参畠《にんじんばたけ》をあらした事がある。
人参の芽が出揃《でそろ》わぬ処《ところ》へ藁《わら》が一面に敷《し》いてあったから、その上で三人が半日|相撲《すもう》をとりつづけに取ったら、人参がみんな踏《ふ》みつぶされてしまった。
古川《ふるかわ》の持っている田圃《たんぼ》の井戸《いど》を埋《う》めて尻《しり》を持ち込まれた事もある。
太い孟宗《もうそう》の節を抜いて、深く埋めた中から水が湧《わ》き出て、そこいらの稲《いね》にみずがかかる仕掛《しかけ》であった。
その時分はどんな仕掛か知らぬから、石や棒《ぼう》ちぎれをぎゅうぎゅう井戸の中へ挿《さ》し込んで、水が出なくなったのを見届けて、うちへ帰って飯を食っていたら、古川が真赤《まっか》になって怒鳴《どな》り込んで来た。
たしか罰金《ばっきん》を出して済んだようである。

 おやじはちっともおれを可愛《かわい》がってくれなかった。
母は兄ばかり贔屓《ひいき》にしていた。
この兄はやに色が白くって、芝居《しばい》の真似《まね》をして女形《おんながた》になるのが好きだった。
おれを見る度にこいつはどうせ碌《ろく》なものにはならないと、おやじが云った。
乱暴で乱暴で行く先が案じられると母が云った。
なるほど碌なものにはならない。
ご覧の通りの始末である。
行く先が案じられたのも無理はない。
ただ懲役《ちょうえき》に行かないで生きているばかりである。

 母が病気で死ぬ二三日《にさんち》前台所で宙返りをしてへっついの角で肋骨《あばらぼね》を撲《う》って大いに痛かった。
母が大層|怒《おこ》って、お前のようなものの顔は見たくないと云うから、親類へ泊《とま》りに行っていた。
するととうとう死んだと云う報知《しらせ》が来た。
そう早く死ぬとは思わなかった。
そんな大病なら、もう少し大人《おとな》しくすればよかったと思って帰って来た。
そうしたら例の兄がおれを親不孝だ、おれのために、おっかさんが早く死んだんだと云った。
口惜《くや》しかったから、兄の横っ面を張って大変|叱《しか》られた。

 母が死んでからは、おやじと兄と三人で暮《くら》していた。
おやじは何にもせぬ男で、人の顔さえ見れば貴様は駄目《だめ》だ駄目だと口癖のように云っていた。
何が駄目なんだか今に分らない。
妙《みょう》なおやじがあったもんだ。
兄は実業家になるとか云ってしきりに英語を勉強していた。
元来女のような性分で、ずるいから、仲がよくなかった。
十日に一遍《いっぺん》ぐらいの割で喧嘩《けんか》をしていた。
ある時|将棋《しょうぎ》をさしたら卑怯《ひきょう》な待駒《まちごま》をして、人が困ると嬉《うれ》しそうに冷やかした。
あんまり腹が立ったから、手に在った飛車を眉間《みけん》へ擲《たた》きつけてやった。
眉間が割れて少々血が出た。
兄がおやじに言付《いつ》けた。
おやじがおれを勘当《かんどう》すると言い出した。"

改行を取り除く

「。」で split するので、改行は無い方が良いでしょう。



array_text2 = replace(array_text, "\n" => "")



split() で、1文1行の Array にする



array_text3 = split(array_text2, "。")

これで67行の Array が出来ます。julia の出力は下記のようになるはずです。



julia> array_text3 = split(array_text2, "。")
67-element Array{SubString{String},1}:
  "親譲《おやゆず》りの無鉄砲《むてっぽう》で小供の時から損ばかりしている"
  "小学校に居る時分学校の二階から飛び降りて一週間ほど腰《こし》を抜《ぬ》かした事がある"
  "なぜそんな無闇《むやみ》をしたと聞く人があるかも知れぬ"
  "別段深い理由でもない"
  "新築の二階から首を出していたら、同級生の一人が冗談《じょうだん》に、いくら威張《いば》っても、そこから飛び降りる事は出来まい"

  ⋮

  "兄がおやじに言付《いつ》けた"
  "おやじがおれを勘当《かんどう》すると言い出した"

最後の 「。」も split() されていますので、最終行を除きます。


array_text3 = array_text3[1:end-1,:]

MeCab を使って名詞と形容詞を取りだす関数を定義する

では、MeCab さんに登場して頂きます。



MeCab = pyimport("MeCab")
m = MeCab.Tagger("")


表現ゆれの観点から、7番目の要素の「原型」を用いることにします。この際に、MeCab は、"(" なども名詞の扱いにします。7番目の「原型」は "*" で出力をしてきます。通常の名詞とこの点が異なります。MeCab の詳しい使い方は記載しません。すみませんが、他所を当って下さい。過去のブログも見て下されば幸いですが、MeCabの使い方としては実用程度の情報追加です。下記の例で、ある程度、触ったことのある方なら、お判りいただけると考えています。



julia> split(split(m.parse("("),"\n")[1:end-2][1], ",")
7-element Array{SubString{String},1}:
"(\t名詞"
"サ変接続"
"*"
"*"
"*"
"*"
"*"

julia> split(split(m.parse("無鉄砲"),"\n")[1:end-2][1], ",")
9-element Array{SubString{String},1}:
"無鉄砲\t名詞"
"一般"
"*"
"*"
"*"
"*"
"無鉄砲"
"ムテッポウ"
"ムテッポー"

そこで、"*" を取り除けるように関数を定義しておきます。omit_words を増やすと、無視したい単語を増やすことができます。["*", "無鉄砲"] などと、"*" の左に繋げて下さい。数字も



omit_words = vcat(["*"]); # 除外したい単語

function remove_omit_words(array_text3, omit_words)
  array_text4 = deepcopy(array_text3)
  for word in omit_words
    if word in array_text4
      array_text4 = array_text4[.!(array_text4.== word)]
    end
  end
  return array_text4
end

名詞と形容詞を取り出す関数は下記のようにしました。



function noun_adj_array(array_text)
  array_text1 = replace(array_text, " " => "")
  parsed_item = m.parse(array_text1);
  array_items = split(parsed_item, "\n");
  array_items = array_items[1:(length(array_items)-2)];
  row_true = findall(occursin.("\t名詞",array_items) .| occursin.("\t形容詞",array_items) ); # ここで、名詞と形容詞を抜く bool を作っています
  items_noun_adj = map(x-> split(x, ",")[7], array_items[row_true]);
  row_true2 = items_noun_adj .!= "*";
  items_noun_adj = items_noun_adj[row_true2]
  items_noun_adj = remove_omit_words(items_noun_adj, omit_words)
  return items_noun_adj
end

試運転をしておきます。



julia> noun_adj_array("きれいな花には棘がある。")
3-element Array{SubString{String},1}:
  "きれい"
  "花"
  "棘"

先程の、Array に、ブロードキャストで、名詞と形容詞を取り出して、Array 内に Array で格納する

noun_adj_array() をブロードキャストで array_text3 を処理して頂きましょう。ブロードキャストとは noun_adj_array.() のように、() のまえに、"." を入れるだけです。map() での同じ様な処理ができるはずです。



julia> noun_adj_array.(array_text3[1:2,:]) # 試運転
2×1 Array{Array{SubString{String},1},2}:
  ["譲", "ゆず", "無鉄砲", "供", "時", "損"]
  ["小学校", "時分", "学校", "二", "階", "一", "週間", "腰", "事"]

julia> @time A_noun_adj = noun_adj_array.(array_text3)
  0.103730 seconds (60.71 k allocations: 2.814 MiB)
66×1 Array{Array{SubString{String},1},2}:
  ["譲", "ゆず", "無鉄砲", "供", "時", "損"]
  ["小学校", "時分", "学校", "二", "階", "一", "週間", "腰", "事"]
  ["無闇", "むやみ", "人"]

  ⋮

  ["眉間", "血"]
  ["兄", "おやじ", "言", "付", "けた"]
  ["おやじ", "おれ", "勘当", "かんどう"]

出力を抑制したい時は、`A_noun_adj = noun_adj_array.(array_text3);` というように、";" を付けます



julia> @time A_noun_adj1 = noun_adj_array.(array_text3);
  0.189936 seconds (2.67 M allocations: 123.021 MiB, 11.86% gc time)  

gensim.models.word2vec.Word2Vec() でモデルを作る

ここで、"66-element Array{Array{SubString{String},1},1}:" という形式にしないと gensim に通らないので処理をします。`A_noun_adj1[:,2]` はエラーが出るので現時点でのなんらかのバグの類だと考えます。



A_text_gensim = A_noun_adj1[:,1] # 1列だけにする。

gensim = pyimport("gensim")

@time model = gensim.models.word2vec.Word2Vec(
      A_text_gensim,
      size=100, 
      min_count=3, 
      window=3,
      iter=5
)


ここで、今回程度のサンプルではサンプル数が少いので、"size = 100" と、小さくしています。それでも、下記の様に動きました。



julia> @time model = gensim.models.word2vec.Word2Vec(
    A_text_gensim,
    size=100,
    min_count=3,
    window=3,
    iter=5
)
  0.009031 seconds (925 allocations: 22.734 KiB)
PyObject 

julia> model.most_similar(positive=["おやじ"], topn=5)
5-element Array{Tuple{String,Float64},1}:
  ("勘太郎", 0.22501419484615326)
  ("ない", 0.10653357207775116)
  ("袷", 0.09472349286079407)
  ("袖", 0.026080794632434845)
  ("兄", 0.008964017033576965)

julia> model.most_similar(positive=["母"], topn=5)
5-element Array{Tuple{String,Float64},1}:
  ("袖", 0.25410038232803345)
  ("十", 0.1183260828256607)
  ("手", 0.08814918249845505)
  ("屋", 0.06998808681964874)
  ("山城", 0.06646429002285004)

julia> model.most_similar(positive=["おれ"], topn=5)
5-element Array{Tuple{String,Float64},1}:
  ("親指", 0.10500822961330414)
  ("人参", 0.05874568969011307)
  ("菜園", 0.04518549144268036)
  ("山城", 0.02461116574704647)
  ("三", 0.01480960100889206)


サンプルが少ないので、最後のところは、あくまで参考というところです。

なお、「ルビ」は、`replace(array_text, r"《.+?》"=>"")` で抜く事が可能です。

おまけの LDA

潜在的ディリクレ配分法 Latent Dirichlet Allocation, LDA というトピックモデルも、もちろん動きます。



dict1 = gensim.corpora.Dictionary(A_noun_adj)

dict1.filter_extremes(no_below=20, no_above=0.3)
# no_berow: 使われてる文の数が no_berow 個以下の単語を除外 (稀すぎるもの)
# no_above: 使われてる文の割合が no_above 以上の単語を除外 (一般的な用語)

corpus1 = dict1.doc2bow.(A_noun_adj);
lda1 = gensim.models.ldamodel.LdaModel(corpus=corpus1, num_topics=5, id2word=dict1)
lda1.show_topics()

こんな感じで動くはずです。上記の例で、トピックモデルは向かないでしょうから、例示はしません。あしからず

gensim って上記の様に、使うだけなら、かなり敷居の低いライブラリーです。現役で Tshitoyan V, Dagdelen J, Weston L et al.Unsupervised word embeddings capture latent knowledge from materials science literature. Nature. 2019 Jul;571(7763):95-98. なんて論文で使われていますね。使ってみて、速度には驚きましたが、簡便なだけではない、優秀なライブラリーのようです。

おしまい

B! LINE