Juliaでk-mean法(1) Bag of Words / ニュース記事

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

はじめに

 Juliaでk-means法でのクラスタリングを行ってみます。
 Bag of Wordsとは、文書中に出現する単語数をその文書の特徴とする方法で、単語の出現順序は考慮しません。具体的には、単語を各要素のラベルとして、その出現数の列としてベクトルを作成します。このベクトルが、各文書の特徴を表しているとします。
 (なお、以下では「形態素」「単語」を混在して使っています。厳密な定義に基づくと違うのですが、ここでは「単語」も「形態素」の意味で使っています。)

※実行環境は SageMaker Studio Lab、Juliaは Version 1.7.2 です。

ニュース記事

 対象とするのはニュース記事です。ニュース記事にはカテゴリ情報があるので、目的のクラスタはこのカテゴリということになります。
 これまでに、Yahoo!ニュースをスクレイピングする方法を紹介しました。一日分だけでは量が少ないので、複数日にわたってスクレイピングされたデータを統合して使用します。統合の方法は、前回の記事をご覧ください。重複を除去したJSONデータが得られます。

 以下の実験では、2022年6月21日~7月13日の記事1562件を使っています。なお、必ずしもその日のすべての記事が取得できているわけではありません。

テキストデータのクレンジング、形態素解析&数え上げ

 クレンジング、形態素解析&数え上げも基本的に前回と同じですが、前回は全体をまとめて処理していたのに対して、今回は記事ごとに処理を行う必要があります。そのため、コードもそれに対応するように調整しました。

# クレンジング
# 記事文字列からテキストデータを抽出し、形態素解析できるように加工する
#   ・句点で改行させ、不要な空白・空行を除去
function getlines(article::String)
    new_lines = []
    # 形態素解析に長文を渡したり、不要な呼び出しをしないように、文字列を調整
    ## 句点「。」の後で分割する
    lines = split(replace(article, r"。" => "。\n"), "\n")
    for ll in lines
        ## 行頭の空白文字列を削除
        ll = replace(ll, r"^[  ]+" => "")
        ## 空行は削除
        if length(ll) == 0
            continue
        end
        # 処理済み文字列を格納
        push!(new_lines, ll)
    end
    new_lines
end
# 形態素解析&数え上げ
# 形態素解析して、語の一覧を返す
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)

function countword(tokenizer, lines)
    # 数え上げ格納領域
    word_counts = Dict{String, Int}()

    # 形態素解析&数え上げ
    for line in lines
        # 1文を形態素解析
        tokens = tokenize(tokenizer, line)
        new_tokens = []
        for token in tokens
            attr = split(token[2], ",")
            hinsi = attr[1]
            surface = token[1] # 表記
            basic = (attr[7] != "*") ? attr[7] : surface   # 形態素の基本形
            ## 
            if hinsi in ["名詞", "動詞", "形容詞", "副詞"] 
                push!(new_tokens, basic)
            end
        end
        # 形態素数を数え上げ
        for surface in new_tokens
            word_counts[surface] = get(word_counts, surface, 0) + 1
        end
    end
    word_counts
end

特徴ベクトル(Bag of Words)の作成

 特徴ベクトルは、各記事のベクトルの同じインデックスに同じ単語の情報を割り当てる必要があるので、まず、すべての記事の単語を集めて、全体の単語一覧を作成します。この要素数(=単語数)が特徴ベクトルの長さになります。各記事ごとに特徴ベクトルを作成し、出現単語位置に出現数を設定します。

# 語の頻度を表すDictの配列 → 一つのDictにマージ
function mergeword(list_word_counts)
    all_word_counts = Dict{String, Int}()
    for wc in list_word_counts
        mergewith!(+, all_word_counts, wc)  # Dictの合成、値は+演算
    end
    all_word_counts
end

# Bag of Words 作成
function makevector(labels, list_word_counts)
    list_vector = []
    for wc in list_word_counts
        vec = zeros(Int, length(labels))
        for (w, c) in wc
            i = findfirst(==(w), labels)
            vec[i] = c
        end
        push!(list_vector, vec)
    end
    list_vector
end

# 特徴ベクトル(Bag of Words)の作成
## 記事ごとの単語と頻度の一覧
list_word_counts = []
for article in articles
    text = article["detail"]
    lines = getlines(text)
    wc = countword(tokenizer, lines)
    push!(list_word_counts, wc)
    article["word_count"] = wc
end

## 全体の単語と頻度の一覧を作成
all_word_counts = mergeword(list_word_counts)

## 全体の単語一覧
labels = sort(collect(keys(all_word_counts)))

## 各記事ごとの特徴ベクトル(Bag of Words)作成
list_vector = makevector(labels, list_word_counts)

# 行列に変換する. juliaはcolumn-major order
mat = hcat(list_vector...)

 ここの実行には、少し時間がかかります。(100秒程度)
 上記の方法は一例で、いろいろな方法があると思いますが、この際に、juliaでの行列の構造が「column-major order」であることを意識しておかないと、正しいデータにならないので注意が必要です。

Juliaの行列

 次の記事を参考にしました。

 また、hcatの引数に書かれている「…」は省略ではありません。引数(配列)を展開して関数に渡すことを表しています。次の記事の中央あたりに簡単な説明があります。(どこかに詳しい説明がないものでしょうか…)

k-means

 k-meansの実行には「Clustering.jl」が必要です。適宜、

using Pkg; Pkg.add("Clustering")

を実行しておいてください。

 行列をk-meansに渡してクラスタリングを実行します。

using Clustering

# K-meansを使って、8個(カテゴリー数)のクラスタに分類する
n_clusters = 8 #the number of clusters
result = kmeans(mat, n_clusters; maxiter=200, display=:none)
clust_numbers = assignments(result) # get the assignments of points to clusters

 変数「cluster_numbers」には、各記事の所属するクラスタの番号が格納されています。
 カテゴリーと一致するようにクラスタリングされているのが目標ですが、実際には Bag of Words でそれほどの精度ができることは期待できません。対応表を作成して、結果を確認してみます。

# 元のカテゴリーと、クラスタリングの結果を比較する
# カテゴリごとに、各クラスタに含まれる記事数を求める
check_table = Dict([(name, zeros(Int, n_clusters)) for name in keys(categories)])
for (clust_no, article) in zip(clust_numbers, articles)
    category = article["category"]
    check_table[category][clust_no] += 1
end
check_table

 今回の実験では、次のようになりました。

カテゴリー名Cluster12345678
“local”192108912151
“domestic”220128023110
“sports”960043451640
“entertainment”81316024250
“science”5300240020
“it”5900301190
“world”12100650060
“business”122005125130

 クラスタ番号がカテゴリの順に割り振られているわけではありません。また、実行するたびに、クラスタの登録数が異なることがありますが全体の傾向は大差ありません。
 上記の結果を見る限りでは、Bag of Wordsでのクラスタリングは、精度をうんぬんする状況ではないことがわかります。ほとんどの記事が一部のクラスタに集中してしまっています。もう少し分散するかと思っていたのですが、ダメでした。
 原因は、すべての単語(形態素)を同じ重みで扱っているからです。一般的な単語=どのカテゴリーにも同程度に出現するような単語の扱いを低くすれば、もう少し良い結果になるかもしれません。(なお、今回は名詞・動詞・形容詞・副詞のみを使っていて、明らかにどの記事にも出現する助詞・助動詞などは使っていません。)
 次回は、この単語の重みづけとして、「tf-idf」を使ってみます。

まとめ

 上記をまとめたコードを下記に公開しています。ipynb形式です。

 なお、この記事で使用したニュース記事データは本サイトでは公開しておりません。必要に応じて、自分で用意してご確認ください。

コメント

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