はじめに
ここでは、Julia上で、日本語テキストから形態素間の共起関係を抽出し、それをグラフ表現で表示する方法を紹介します。グラフの表示に関しては、前回の記事に準じます
使用するのは次のパッケージです。
HTTP.jl | httpアクセス | |
ZipFile.jl | zipファイル操作 | |
StringEncodings.jl | 文字エンコード処理 | |
Awabi.jl | 形態素解析 | |
Graphs.jl | ネットワーク構造の表現 | 前回と同じ |
GraphPlot.jl | ネットワーク可視化 | 前回と同じ |
Compose.jl | グラフの保存(SVG) | 前回と同じ |
Cairo.jl | グラフ保存の追加機能(PNG, PDF) | 前回と同じ |
Fontconfig.jl | 同上 | 前回と同じ |
必要に応じてパッケージを追加してください。
using Pkg
Pkg.add("HTTP")
Pkg.add("ZipFile")
Pkg.add("StringEncodings")
Pkg.add("Awabi")Pkg.add("GraphPlot")
Pkg.add("Graphs")
Pkg.add("Compose")
Pkg.add("Cairo")
Pkg.add("Fontconfig")
今回も、実行環境は、SageMaker Studio Lab上のJulia1.7.2を使います。ただし、他の環境でも動作するはずです。ただし、形態素解析器の設定は、それぞれの環境に合わせて変更してください。Linux/Mac/Winodwsでの設定例はこちらを参照してください。
共起情報について
自然言語処理において、文書を分析して重要な情報を抽出する場合に、形態素(あるいは単語)の共起情報の抽出は基本的な処理になります。
共起とは、ある区間(例えば同一文中)において、形態素Aと形態素Bが同時に出現することを言います。
以下では、形態素Aと形態素Bの間の共起頻度をもとめ、共起強さを計算して、ネットワーク構造を作成し、表示する方法を紹介します。
テキストデータの取得
今回使用するテキストは、青空文庫のデータを使います。
青空文庫は、著作権の切れた小説などのテキストデータを公開しています。
青空文庫に格納されているテキストは、ShiftJISコードで記述されていて、そのファイルをzip形式で圧縮したものが公開されています。
そのため、対象のURLからzipファイルを取得して、それを解凍して、文字コードをShiftJISからutf-8に変換することで、Juliaで扱うことができるテキストデータを得ることができます。実は、それぞれの処理は、Linux上のコマンドでできる処理ではありますが、今回はこれらをすべてJulia上で行うことにします。
まず、青空文庫から小説のzipファイルのURLを探してきます。小説一覧や作家一覧のページが用意されているので、そこからリンクをたどります。今回は、夏目漱石の「こころ」を探してみましょう。
- トップページの「公開中 作家別」の[な行]をクリックします。
- 「作家リスト:ナ行」のページが開いて、一覧が出るので[夏目漱石]をクリックします
- 「作家別作品リスト:No.148」のページ(作家名:夏目漱石)が開いて、夏目漱石の作品一覧が出るので、[こころ]をクリックします。
- 「図書カード:No.773」のページ(作品名:こころ)が開きます。下の方に[ファイルのダウンロード]とあるので、そこからテキストファイルをzip圧縮したファイルのリンクを取得します。
このリンクは
https://www.aozora.gr.jp/cards/000148/files/773_ruby_5968.zip
となります。
上記のURLを指定してダウンロード→zipファイルからテキスト取り出し→ShiftJISをutf-8に変換して読み込み、を行うのが次のコードです。
using HTTP
using ZipFile
using StringEncodings
# 夏目漱石の「こころ」のzipファイルのURL
url = "https://www.aozora.gr.jp/cards/000148/files/773_ruby_5968.zip"
# URLからデータを取得
dat = HTTP.get(url)
# zipファイルから圧縮前の元データを取得
r = ZipFile.Reader(IOBuffer(dat.body))
# 最初に見つかったテキストファイルを取得する
f = nothing
for file in r.files
if findlast(".txt", file.name) != nothing
f = file
break
end
end
close(r)
# ファイル名を確認する場合は f.name を参照する
# 文字コードShiftJISで記述されたテキストファイルを読み込む:CP932を指定する
list = readlines(f, enc"CP932")
以上で、配列listの中にテキストの一覧が読み込まれました。
テキストデータのクレンジング
青空文庫で作成されたテキストファイルの中には、小説として公開する際には必要ですが、テキストとして処理する際には不要な情報が存在します。それらを除去して、きれいなテキストに変換します。
- 先頭の注釈行の削除
- 末尾の奥付(「底本」「初出」などの記述)の削除
- ルビの削除
- 入力者注の削除
また、小説の記述のままではテキスト処理(ここでは形態素解析)する際に都合が悪い、あるいは、効率が悪い場合があります。それを回避するために文字列を調整します。
- 句点「。」の後で分割
- 行頭の空白文字列を削除
- 空行は削除
# 先頭の注釈行判定用
note_line = r"^--------"
is_note = false
# 末尾の奥付判定用
endmark = r"^(底本|底本の親本|翻訳の底本|初出):"
# 最終結果の格納領域
new_lines = []
for line in list
# 不要行を削除する(new_lineへの登録をskipすることで実現)
## 先頭の注釈行の削除
if is_note
if occursin(note_line, line)
is_note = false
end
continue
else
if occursin(note_line, line)
is_note = true
continue
end
end
## 末尾の奥付を削除
if occursin(endmark, line)
break
end
# 不要行削除ここまで
# ルビ、および、入力者注の削除
line = replace(line, r"[#[^]]+]" => "")
line = replace(line, r"《[^》]+》" => "")
# 形態素解析に長文を渡したり、不要な呼び出しをしないように、文字列を調整
## 句点「。」の後で分割する
slines = split(replace(line, r"。" => "。\n"), "\n")
for ll in slines
## 行頭の空白文字列を削除
ll = replace(ll, r"^[ ]+" => "")
## 空行は削除
if length(ll) == 0
continue
end
# 処理済み文字列を格納
push!(new_lines, ll)
end
end
以上で、配列new_listの中にクレンジング済みのテキストの一覧が読み込まれました。
【※注意※】青空文庫に格納されている小説データは、上記のルールに基づいて作成されています。しかし、人間が作成したものですから間違いが入り込む場合もあります。例えば、ルビや入力者注のかっこの閉じ忘れがあるかもしれません。
共起情報の統計的データでは、無視してもほとんど影響しない数ではありますが、気になる場合は、ここのnew_lineに格納された文書を確認することができます。
形態素解析~数え上げ~共起強度の計算
まず、テキストを各行単位で形態素解析し、必要な情報のみを取り出します。
今回は、形態素の基本形(原形)を用いることで活用による表記の揺れを統一し、品詞が「名詞」「動詞」「形容詞」のいずれかで自立語のもの(「接尾」「非自立」でないもの)を対象とします。
ただし、形態素解析結果の表記が一文字のひらがな、カタカナ、および、長音「ー」、波線「~」は、除外します。これは形態素解析の誤解析の場合に頻出するパターンであり、数え上げのじゃまになるからです。
(Juliaでの形態素解析については、別記事「Juliaで形態素解析」「Juliaで形態素解析(2) SageMaker Studio Lab」を参照してください。)
using Awabi
# 形態素解析器の設定
## Linux / Mac
#tokenizer = Tokenizer()
## Windows:
#dic = Dict("dicdir" => "C:\\Program Files (x86)\\MeCab\\dic\\ipadic")
#tokenizer = Tokenizer(dic)
## SageMaker Studio Lab
rcfile = "/home/studio-lab-user/mecab/etc/mecabrc"
tokenizer = Tokenizer(rcfile)
# 数え上げ格納領域
word_counts = Dict{String, Int}()
coll_counts = Dict{Tuple{String, String}, Int}()
# 形態素解析&数え上げ
for line in new_lines
# 1文を形態素解析
tokens = tokenize(tokenizer, line)
new_tokens = []
for token in tokens
attr = split(token[2], ",")
hinsi = attr[1]
hinsi2 = attr[2]
surface = token[1] # 表記
basic = (attr[7] != "*") ? attr[7] : surface # 形態素の基本形
# 一文字のひらがな・カタカナ・長音「ー」・波線「〜」を対象外にする
if hinsi in ["名詞", "動詞", "形容詞"] && !(hinsi2 in ["接尾", "非自立"]) &&
(length(surface) > 1 || ((surface < "ぁ" || surface > "ヶ") && !(surface in ["ー", "~"])))
push!(new_tokens, basic)
end
end
# 行中の形態素の重複を除去
unique!(new_tokens)
# 形態素数と共起数を数え上げ
for (index, surface) in enumerate(new_tokens)
word_counts[surface] = get(word_counts, surface, 0) + 1
if index < length(new_tokens)
for surface2 in new_tokens[index+1:end]
if surface < surface2
pair = (surface, surface2)
coll_counts[pair] = get(coll_counts, pair, 0) + 1
elseif surface > surface2
pair = (surface2, surface)
coll_counts[pair] = get(coll_counts, pair, 0) + 1
end
end
end
end
end
ここまでで、単語の出現数(word_counts)、共起の出現数(coll_counts)が求められます。
ただ、それぞれの内容を見てみると、極端に出現数が小さいものが出てきているのがわかります。今回は、出現数10回未満のものは除外することにします。
# 最小出現数:出現数が極端に小さいものを除外するため
mincount = 10
# 出現数一覧から指定に基づいて除外
word_counts = Dict((k, v) for (k, v) in collect(word_counts) if v >= mincount)
coll_counts = Dict(((w1, w2), v) for ((w1, w2) , v) in collect(coll_counts) if v >= mincount)
ここまでの計算で、形態素出現数と共起出現数が得られました。これらの数値から、Jaccard係数を用いて、共起の強度を計算します。
$Jaccard(w_{1}, w_{2}) = \rm\frac{\Large 共起出現数(w_{1}, w_{2})}{\Large 形態素出現数(w_{1})+形態素出現数(w_{2})-共起出現数(w_{1}, w_{2})}$
共起強度は、グラフに表示させるエッジ数を上位N件に絞る時などに使用します。表示数以外ではグラフの表現(例えば、エッジの長さや太さ)への影響はありません。
#Jaccard係数の定義
function jaccard(coll_count, w1_count, w2_count)
coll_count / (w1_count + w2_count - coll_count)
end
# エッジに対して共起強度を計算し、共起強度の大きい順に並べる
edge_val_list = [(w1, w2, jaccard(v, word_counts[w1], word_counts[w2])) for ((w1, w2), v) in collect(coll_counts)]
sort!(edge_val_list, rev = true, by =x -> x[3])
グラフ表示
まず、共起強度の上位100件のエッジと、それにつながるノードを表示してみます。
using GraphPlot
using Graphs
# 除外する形態素の一覧
ngwords = ["する", "やる", "できる", "ある"]
# 表示するエッジ数
disp_edges = 100
if length(edge_val_list) < disp_edges
disp_edges = length(edge_val_list)
end
# 表示する形態素・エッジを抽出
disp_word_list = Vector{String}()
disp_edge_list = []
for (w1, w2, v) in edge_val_list[1:disp_edges]
if ! (w1 in ngwords || w2 in ngwords)
push!(disp_edge_list, (w1, w2))
push!(disp_word_list, w1)
push!(disp_word_list, w2)
end
end
unique!(disp_word_list)
word2num = Dict((word, num) for (num, word) in enumerate(disp_word_list))
edges = [(word2num[w1], word2num[w2]) for (w1, w2) in disp_edge_list]
# グラフ作成
g = SimpleGraph(length(disp_word_list))
map(x -> add_edge!(g, x[1], x[2]), edges)
fig = gplot(g, nodelabel=disp_word_list, nodelabeldist = 3.0, NODESIZE = 0.02, NODELABELSIZE = 3)
見づらい箇所もありますが、それっぽい共起関係が取れているのがなんとなくわかります。
エッジ数を調整して全エッジを表示させると、次のようなグラフが表示されます。中央に「私」がいるのがわかります。少しずれた位置にある固まりは、わかりにくいですが中心は「先生」のようです。
共起の分析を行うには、さらに表示数を変えたり、キーワードを指定したりしながら、適切なグラフを表示させていきます。ノードの大きさや色も変化をつけたらわかりやすくなります。
なお、グラフをPNG形式のファイルとして保存する場合は、次のコードを実行します。ファイル名は適宜指定してください。また、出力サイズ(10cm, 10cm)はグラフの形状によっては縦横のサイズを変更してください。
# PNGファイルとして保存
using Compose
using Cairo
#using Fontconfig
Compose.draw(PNG("sample.png", 10cm, 10cm), fig)
まとめ
上記をまとめたipynbファイルを、下記に格納してあります。
コメント