見出し画像

自然言語処理に歩み寄る#2

前回の記事では、形態素解析とそれらを行うツールについて紹介しました。今回の記事では、形態素解析を行った後の話を記事にします。

文章を形態素解析した後、コンピュータに理解させるためには"数値化"する必要があります。(以降、ベクトル化と表現します)

自然言語をベクトル化する方法は、主に2通りあります。
①カウントベース
 文章中の単語の出現回数をカウントする
②推論ベース
 周辺の単語から、適切な単語を推論する
今回は、「カウントベース」の方法について、いくつか例を交えて解説します。

カウントベースの手法

代表的な手法として、Bag of WordsやTF-IDF、LDA、LSIが挙げられます。
・Bag of Words:文書中に出現する単語の数を数える
・TF-IDF:単語の重要度を考慮
・LSI:大量の文書に対応して、単語意味行列を集約
・LDA:文書に存在するトピックという概念を考慮

Bag of Words

文書に出現する単語をbag(かばんや袋)に入れる、というイメージからこの名前が付けられたそうです。

どのように数値化するかというと、次の文章を例にして説明します。
例:「これはペンです。」
この文章を形態素解析(最小の単語単位に分割)すると
「これ は ペン です 。」
となります。これを、One-hotベクトルに変換します。

句点を含めて5つの単語があり、0と1を使って各単語が一意となるようにします。
 これ  [1, 0, 0, 0, 0]
 は   [0, 1, 0, 0, 0]
 ペン  [0, 0, 1, 0, 0]
 です  [0, 0, 0, 1, 0]
 。   [0, 0, 0, 0, 1]
これらを足し合わせた結果がもとの例文を表現しており、文章をコンピュータに入力することができます。
「これはペンです」→ [1, 1, 1, 1, 1]

1文では違いが分かりにくいため、次の3文をBag of Wordsで表現します。
 例1)私 は ペン 収集 が 好き です 。
 例2)私 は 赤い ペン と 青い ペン を 持っ て い ます 。
 例3)私 は 黒い ペン が 欲しい です 。

表1 Bag of Wordsで表現した例

3つの文から得られる単語の集合をつなげて、異なる文章を一意に表現することができます。

しかしこの方法では、単語の並びを考慮できない点や「です」や「私」など文書で出現しやすい単語をコンピュータが注目してしまう、という課題があります。

TF-IDF

Bag of Wordsでは、すべての単語の出現度のみを表現していましたが、「です」や「私」のように、あまり意味を持たない単語は、多く出現していても重要ではありません。
そこで、よく出現する単語の重要度は下げて、稀に出現する単語の重要度を上げる手法がTF-IDF(Term Frequency - Inverse Document Frequency)です。
単語の出現回数(TF)と文書内での出現回数(DF)の関係から、文書内における単語の重要度を考慮して、ベクトル化します。

$$
tfidf_i,_j = tf_i,_j・idf_i \\ tf_i,_j = \frac{n_i,_j}{\sum_kn_k,_j} \\ idf_i = log \frac{\lvert D \rvert}{\lvert \lbrace d: d  ∋  t_i\rbrace \rvert}
$$

フリー百科事典『ウィキペディア(Wikipedia)』

$${n_i,_j}$$は文書$${d_j}$$における単語$${t_i}$$の出現回数
$${\sum_kn_k,_j}$$は文書$${d_j}$$におけるすべての単語の出現回数の和
$${\lvert D \rvert}$$は総文書数
$${\lvert \lbrace d: d  ∋  t_i\rbrace \rvert}$$は単語$${t_i}$$を含む文書数

普段の文章で使うような単語「です」「私」と、本記事特有で重要そうな単語「ベクトル化」「Python」のTF-IDFの関係は次の通りです。

$$
\begin{array}{l|l|l|l}
\textbf{単語例} & \textbf{TF} & \textbf{IDF} & \textbf{TF-IDF} \\ \hline
「です」「私」 & 大 & 小 & 小 \\
「ベクトル化」「Python」 & 大 & 大 & 大 \\
\end{array}
$$

このようにして、Bag of Wordsでは考慮できなかった、文書内での重要な単語とそうでない単語をコンピュータに理解させることができます。

LSI

Bag of WordsやTF-IDFでは、入力とする文書内における単語が多いほど、文章を表現するための配列(表1の列)が膨大な長さになってしまう恐れがあります。
そこで、表現を抽象化(技術的には、次元削減)する考え方を取り入れた手法がLSIです。

LDA

Bag of WordsやTF-IDFのように単純な単語の出現頻度だけでなく、単語の使われ方や文書全体での関連性といった傾向をトピック(note記事のタグのようなもの)として表現する手法がLDAです。
※LDAはこの手法だけで、1冊の本が存在するくらい奥が深いため、理論は割愛します。

実際に触ってみる
(livedoorニュースコーパス×gensim)

いくつか理屈や例を交えてみましたが、実際の出力や流れを見るとイメージがつきやすいかと思うので、Pythonを使って各手法を試してみます。

使用するデータセットは、日本語ニュース記事でお馴染みの「livedoor ニュースコーパス」を利用します。

<動作環境>
Python 3.6.8
Jupyter Notebook 5.6.0
gensim 3.8.3
mecab-python3 1.0.3

# データ準備
# livedoorニュースのデータをダウンロード
!wget "https://www.rondhuit.com/download/ldcc-20140209.tar.gz"

import tarfile
import os 

# ダウンロードしたファイルをPython処理にて解凍
tar = tarfile.open('ldcc-20140209.tar.gz', 'r:gz')
tar.extractall('./data/livedoor/')
tar.close()

ダウンロードしたデータセットは、全9種のカテゴリごとにファイル(記事)が分かれているので、各カテゴリから5記事を取得します。

import MeCab
import re

files = []
categories = os.listdir('data/livedoor/text')

# 各カテゴリの先頭5ファイルを取得する
for category in categories:
    match = re.search('.txt', category)
    if not match:
        file_list = os.listdir(f'data/livedoor/text/{category}')[:5]
    files.append(file_list)

取得した45ファイル(9カテゴリ×5ファイル)を読み込みます。

# 各ファイルを開き、配列に格納する
parent_path = 'data/livedoor/text'
docs = []

for category, f_list in zip(categories, files):
    text = ""

    # data/livedoor/text/ にはカテゴリのディレクトリ以外に、
    # テキストファイルが存在するので、対象ファイルの場合、処理をスキップします。
    match = re.search('.txt', category)
    if match:
        continue
    
    for fi in f_list:
        with open(f"{parent_path}/{category}/{fi}", 'r', encoding='utf-8') as f:
            lines = f.read().splitlines()

            url = lines[0]
            datetime = lines[1]
            subject = lines[2]
            body = "¥n".join(lines[3:])
            text = subject + "¥n" + body
        docs.append(text)

読み込んだファイルそれぞれに対して、MeCabを使って形態素解析を行います。この時、「名詞」「動詞」「形容詞」のみを文書内から取り出すこととします。

# ある文書を形態素解析し、指定品詞の語句を取り出す関数
mecab = MeCab.Tagger()

def tokenize(text):
    node = mecab.parseToNode(text)
    lists = []
    while node:
        if node.feature.split(',')[0] in ['名詞', '動詞', '形容詞']:
            lists.append(node.surface)
        node = node.next
    
    return lists

# 取り出した45文書(9カテゴリ×5文書)を形態素解析し、tokenize処理
words = []
words_separate = []
for doc in docs:
    res = tokenize(doc)

    # 文書ごとに単語リストを保持
    words_separate.append(res)

    # 45ファイル全体での単語リストを作成
    words.extend(res)

これでようやく、ニュース記事から単語リストを抽出することができました。ここからは、gensimというPythonにおける自然言語処理ライブラリを使って、4つの手法の出力結果例を見てみます。

まずは、今回対象とする45ファイル全体で使われている単語から辞書を作成します。

# gensimを使って、特徴後辞書を作成
from gensim import corpora, matutils, models

dictionary = corpora.Dictionary([words])

次に、文書単位で単語にID付け、出現回数のカウントを行います(Bag of Words)。また、もとの単語リストと各表現による出力結果例を確認します。

# 文書単位にBag of Wordsで表現
bow_list = []
for w in words_separate:
    bow = dictionary.doc2bow(w)
    bow_list.append(bow)

# もとの文書の単語IDを配列から検索し、配列のインデックスを返す
def search_index(lists, ids):
    res = []
    for i in ids:
        for idx, val in enumerate(lists):
            if val[0]==i: res.append(idx)

    return res

word_id = [dictionary.token2id[w] for w in words_separate[0][:10]]
list_index = search_index(bow_list[0], word_id)

# 結果確認
print(words_separate[0][:10])
print([bow_list[0][index] for index in list_index])
Output
# 単語リストの先頭10個を取り出し(*)
['DVD', 'エンター', '誘拐', '育て', '女', '目', 'し', '真実', '孤独', '幸福']

# 単語リスト(*)に対応するBoW形式の結果を取得
[(206, 3), (975, 1), (4110, 4), (3864, 3), (2553, 5), (3645, 3), (669, 12), (3663, 2), (2608, 2), (2775, 1)]

結果を確認すると、(206, 3)=(辞書内での単語ID, 文書内での出現回数)を表しており、DVDはID:206、文書内で3回出現していることがわかります。

BoWでは、単にカウントした結果であるため、TF-IDFによって文書全体での出現度を考慮して、重みづけを行います。

import math

# 単語によっては、重要度が0となるので、内部処理を変更します
# 参考:https://qiita.com/tatsuya-miyamoto/items/f1539d86ad4980624111
def new_idf(docfreq, totaldocs, log_base=2.0, add=1.0):
    return add + math.log(1.0 * totaldocs / docfreq, log_base)

# TF-IDFで重みづけ
tfidf = models.TfidfModel(bow_list, wglobal=new_idf, normalize=False)
tfidf_corpus = tfidf[bow_list]

# 結果確認
print(words_separate[0][:10])
print([tfidf_corpus[0][index] for index in list_index])
Output
# 単語リストの先頭10個を取り出し(*)
['DVD', 'エンター', '誘拐', '育て', '女', '目', 'し', '真実', '孤独', '幸福']

# 単語リスト(*)に対応するTF-IDFの結果を取得
[(206, 16.475559288989025), (975, 6.491853096329675), (4110, 25.9674123853187), (3864, 16.475559288989025), (2553, 20.84962500721156), (3645, 9.965784284662087), (669, 12.0), (3663, 12.98370619265935), (2608, 12.98370619265935), (2775, 6.491853096329675)]

例えば、BoW形式では、"DVD(ID:206)"と"目(ID:3645)"では出現回数は3回と同じでしたが、TF-IDFでの値を比較すると、DVD>目という結果となり、目は他の文書でも出現しているため、数値が小さくなったと考えられます。

ここまでで、結果例の配列長は"253"ですが、単語数が多ければ多いほどこの長さは大きくなります。そこで、LSIという次元圧縮が可能な手法により文書のベクトル化を行います。

# LSIを使って、次元圧縮してベクトル化
lsi = models.LsiModel(tfidf_corpus, id2word=dictionary, num_topics=300)
lsi_corpus = lsi[tfidf_corpus]

# 結果確認
print([tfidf_corpus[0][index] for index in list_index])
[(0, 0.16399496085651827), (1, 0.08467048938607193), (2, -0.1443131021533239), (3, 0.052578556243655226), (4, -0.24963441512499787), (5, 0.12695976530707262), (6, -0.0030275009384929013), (7, -0.147359398150602), (8, 0.07918407993321855), (9, -0.1903271983396416), (10, -0.11673662988338031), (11, 0.12677043805339377), (12, 0.16354781847906535), (13, -0.0833314450273783), (14, -0.1374163456304284), (15, -0.177529032911253), (16, 0.09203570056614672), (17, 0.08962466612148144), (18, 0.011735081094585898), (19, 0.22468100870452687), (20, 0.19300467320340242), (21, -0.031189330297801625), (22, -0.13712463261017102), (23, -0.15106618911885028), (24, -0.002514342996658877), (25, -0.23098527691960521), (26, 0.41168665669771226), (27, 0.3255391396766488), (28, 0.07317331012385853), (29, 0.11888897292320137), (30, -0.31097790302099126), (31, -0.08891277902383117), (32, -0.17501192745235788), (33, 0.07226821636253788), (34, 0.07652008244766201), (35, 0.04604772739962448), (36, -0.177677158746737), (37, -0.024941221903293957), (38, 0.05518284925849787), (39, 0.005039027975695949), (40, -0.04131832383624069), (41, 0.06875131573938006), (42, -0.013700164345928108), (43, 0.012816088200476779), (44, -0.011484954462363053)]

LSIの結果は、単語とIDが1:1の関係ではないため、インデックスは意味を持ちません。TF-IDFでは、"253"という配列長でしたが、LSIを行うことで、"45"という配列長まで削減できました。

最後に、LDAを使って文書のトピックを推定してみます。今回は、15トピックに分かれるようにnum_topis=15と指定しています。

# LDAを使って文書のトピックを推定
lda = models.LdaModel(bow_list, num_topics=15, id2word=dictionary)
lsi_result = lda[bow_list]

print(lsi_result[0])
Output
[(8, 0.4267702), (11, 0.5712217)]
# 単語'DVD'と各トピックでの出現確率を表示
lda.get_document_topics(dictionary.doc2bow([‘DVD’]))

Output
[(0, 0.03333538),
 (1, 0.03333538),
 (2, 0.53330445),
 (3, 0.03333538),
 (4, 0.033335384),
 (5, 0.033335384),
 (6, 0.03333538),
 (7, 0.03333541),
 (8, 0.033335496),
 (9, 0.03333538),
 (10, 0.033335395),
 (11, 0.033335432),
 (12, 0.03333538),
 (13, 0.03333538),
 (14, 0.03333538)]

LDAでは、単語や文書がベクトル化されるイメージよりも、分類されるよううなイメージで、今回はトピック8とトピック11に属するという結果が得られました(数値は確率を表しており、トピック11の方が微かに適しているようです)
また、ある単語とトピックとの関連性を確認することもできます。
'DVD'はトピック2で出現する確率が高いようです。

Bag of WordsやTF-IDFを使って得られたベクトルを分類モデルの学習データとしたり、学習の効率化のためにLSIを使って次元削減したりすることに活用できます。
また、LDAのように単語出現具合を考慮して、トピックという抽象概念で文書を扱うことができます。SNSデータやアンケートなどをグループ分けする方法の1つとして、活用できます。

参考記事

下記の記事を参考にさせていただきました。
scikit-learnとgensimでニュース記事を分類する(Qiita)
SVMによる文書分類とその応用(Qiita)
gensimのtfidfあれこれ【追記あり】(Qiita)

まとめ

BERTやGPT-3のような巨大なモデルではなくても、従来手法でも十分に自然言語を処理することができる手法があります。ただし、カウントベースの手法では計算量の増大や、文書内における単語の順序などの関連性を考慮できないという課題が挙げられます。

この課題を解決するために考えられた手法がword2vecに代表される「分散表現ベース」です。
次回は、この「分散表現ベース」の手法について記事にします。
最後まで読んでいただきありがとうございました。

この記事が気に入ったらサポートをしてみませんか?