Juliaで複雑ネットワークを可視化(2) 共起情報の可視化

技術
この記事は約14分で読めます。

はじめに

 ここでは、Julia上で、日本語テキストから形態素間の共起関係を抽出し、それをグラフ表現で表示する方法を紹介します。グラフの表示に関しては、前回の記事に準じます
 使用するのは次のパッケージです。

HTTP.jlhttpアクセス
ZipFile.jlzipファイル操作
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を探してきます。小説一覧や作家一覧のページが用意されているので、そこからリンクをたどります。今回は、夏目漱石の「こころ」を探してみましょう。

  1. トップページの「公開中 作家別」の[な行]をクリックします。
  2. 「作家リスト:ナ行」のページが開いて、一覧が出るので[夏目漱石]をクリックします
  3. 「作家別作品リスト:No.148」のページ(作家名:夏目漱石)が開いて、夏目漱石の作品一覧が出るので、[こころ]をクリックします。
  4. 「図書カード: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ファイルを、下記に格納してあります。

コメント

タイトルとURLをコピーしました