Juliaでジップの法則(Zipf’s law)を確認:小説の場合

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

はじめに

 前回の記事で、青空文庫からテキストを取得して、操作できる形式に変換することができました。
 せっかく、自然言語のテキストが大量に扱えるようになったので、自然言語処理関連でのいろいろを考えていきたいと思います。
 今回は、自然言語の経験則であるジップの法則(Zipf’s law)についての確認を行います。
 実行環境は、SageMaker Studio Labですが、他の環境でも問題なく動作します。。

使用するパッケージ

 テキストの読み込みから解析までは、前回に使用したものの一部です。表示に関しては、前回がネットワーク構造の可視化だったのに対して、今回は数値データのグラフ表示ですので違うパッケージを用います。

HTTP.jlhttpアクセス前回と同じ
ZipFile.jlzipファイル操作前回と同じ
StringEncodings.jl文字エンコード処理前回と同じ
Awabi.jl形態素解析前回と同じ
Plots.jlグラフ表示

ジップの法則(Zipf’s law)

 簡単に言えば
「自然言語のテキストにおける単語の出現頻度は、頻度の大きい単語から順に並べた場合に、k番目の単語は1/kの頻度になっている」
というものです。
 詳細については「ジップの法則」を参照してください。

 実際のデータをジップの法則に当てはめてみると、出現頻度の大きい単語(kが小さい場合)の場合は、うまく一致しないこともあります。以下では、それも含めて確認します。

形態素解析器Awabiでの問題点 ← Awabi.jl 0.1.2で修正済みです!

 ここに書かれている不具合は「Awabi.jl 0.1.1」の時点のものです。
 すでに「Awabi.jl 0.1.2」では改修済みです。(素早い対応ありがとうございました。)
  → Awabi.jl 0.1.2 リリース

 なお、下に書いてあるコードは、そのまま動作するので、そのまま残しておきます。
 エラーをcatchでとらえているので、エラーが発生しなければ、通常の処理が行われます。エラーによるスキップがなくなった分だけ、頻度情報が増えますが、全体の出現頻度の分布をみているので問題ありません。


 さて、複数のテキストでの検証を行っていた時に、形態素解析器Awabiでエラーが出る場合を発見してしまいました。
 「吾輩は猫である」(夏目漱石)中の次の文でエラーが発生します。

「Archaiomelesidonophrunicherata と云う字だ」

 Awabiの内部でエラーが出ているので、手を出せません。おそらく、同じエラーが発生する文は他にもあるだろうと予想されます。
 今回は、エラーをtry/catchで受け止めて、対象となる文の形態素解析をスキップすることにします。
 その文字列自体を解析しなければならない場合にはこの処置では問題ありますが、今回は全体の出現頻度の分布をみているので、許容範囲内としました。(出現頻度で言えば、誤解析によって頻度が正しく取れない場合があることの方が影響が大きいかもしれません。)

青空文庫から取得したzipファイル

 以前は、圧縮されたzipファイル中には一つのテキストだけが入っているとして処理しましたが、複数のファイル(例えば、挿絵のpngファイル)が含まれるものがあり、解析に失敗することがありました。
 そこで、zipファイルに格納されているファイルのうちで最初に見つかったテキストファイルを処理することで、これを回避するようにしました。
 具体的には、後述するコードをご覧ください。

関数の定義

 今回は複数の文書を対象とするので、各処理を関数として定義して、再度の呼び出しができるようにしておきます。

 まず、テキストデータの取得です。

using HTTP
using ZipFile
using StringEncodings

# 青空文庫の小説のzipファイルのURLからデータを取得
function getaozora(url::String)
    # urlからzipファイルを読み込み
    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)
    
    # 文字コードShiftJISで記述されたファイルを読み込む:CP932を指定する
    lines = readlines(f, enc"CP932")
end

 次に、このテキストデータから、不要な情報を除去します。奥付のマッチング文字列も少し調整しました。

# 青空文庫のテキストデータを加工して、形態素解析できるようにする
#   ・注釈行・奥付やルビ・入力者注を削除
#   ・句点で改行させ、不要な空白・空行を除去
function correctaozora(lines)
    # 先頭の注釈行判定用
    note_line = r"^--------"
    is_note = false
    # 末尾の奥付判定用
    endmark = r"^(底本|底本の親本|翻訳の底本|初出):"
    # 最終結果の格納領域
    new_lines = []

    for line in lines
        # 不要行を削除する(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_lines
end

 最後に、形態素解析して、形態素の頻度情報を取得する関数です。

using Awabi

function countmorph(lines)
    # 形態素解析器の設定
    ## 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}()

    # 形態素解析&数え上げ
    for line in lines
        # 1文を形態素解析
        try
            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 surface in new_tokens
                word_counts[surface] = get(word_counts, surface, 0) + 1
            end
        catch
            # pass
        end
    end
    word_counts
end

グラフで比較

 夏目漱石の「こころ」を対象として実施してみます。

url = "https://www.aozora.gr.jp/cards/000148/files/773_ruby_5968.zip"
lines = correctaozora(getaozora(url))
# 形態素表記から頻度を得る、辞書構造
word_counts = countmorph(lines)
# 頻度だけを、降順(大きい順)に並べた配列
ordered_count = [c for (w,c) in sort(collect(word_counts), rev = true, by =x -> x[2]) ]

 上記で、変数「ordered_count」に、降順(大きい順)に頻度が格納されています。これを、グラフ表示します。グラフ表示には「Plots.jl」を使います。今回はバックエンドはデフォルト(GR)です。

 頻度の変化を表現するので、基本は棒グラフを使うところですが、ジップの法則のグラフ(y = a / x)と比較したいので、折れ線グラフを使います。
 まず、何も考えずに、グラフ表示してみます。

# ex1:何も考えずに全部表示
using Plots
base = ordered_count[1]
y_limit = base
p1 = plot(ordered_count, title="Zipf's law", markershape=:circle, linecolor=:blue, ylims=(0,y_limit), label="kokoro")
plot!(p1, x->base/x, linecolor=:red, label="zipf's law")
ex1:何も考えず全部表示

 大半が頻度1になってしまっているので、ロングテイルの部分を表示する必要はなさそうです。上位50件を表示してみます。

# ex2:上位50件を表示
using Plots
base = ordered_count[1]
y_limit = base
p2 = plot(ordered_count[1:50], title="Zipf's law", markershape=:circle, linecolor=:blue, ylims=(0,y_limit), label="kokoro")
plot!(p2, x->base/x, linecolor=:red, label="zipf's law")
ex2:上位50件を表示

 最大頻度を基準にしたので、グラフがうまく重なりません。頻度が大きいほどジップの法則からのずれは大きくなるので、20番目の値を使ってみます。20番目の値=基準の1/20と仮定して、グラフを調整します。

# ex3:20番目の値から基準を調整
using Plots
base = ordered_count[20] * 20
y_limit = base
p3 = plot(ordered_count[1:50], title="Zipf's law", markershape=:circle, linecolor=:blue, ylims=(0,y_limit), label="kokoro")
plot!(p3, x->base/x, linecolor=:red, label="zipf's law")
ex3:20番目の値から基準を調整

 それっぽくなりました。
 もう少し一致するかと思ったのですが、やはり、頻度が高いところほどずれが大きくなっています。小説でないテキスト(ニュースやブログ)で試してみると、また違った結果になるかもしれません。

まとめ

 上記をまとめたipynbファイルを、下記に格納してあります。こちらには、もっと多くのテキスト(小説)での実行結果なども掲載してあります。

参考記事

コメント

  1. nakagami より:
タイトルとURLをコピーしました