はじめに
機械学習のアルゴリズムを利用した自然言語処理を日本語で行うにあたって、初学者(特に、独学中の方)がもっとも苦労するのはコーパス(大量の文章)の収集ではないでしょうか?
今回題材にする「ゼロから作るDeepLearning❷〜自然言語処理編〜」でも、その他の書籍でも、基本的には英語のコーパスの取り扱いが中心となっており、英語と違った癖を持つ日本語のコーパスの処理についてはなかなか経験しづらいのが現状かと。(少なくとも自分は、日本語のコーパスが全く集められずかなり苦労しました。。)
そこで今回は、「livedoor ニュース」の記事(独女通信)を活用させていただき、おそらく機械学習をやる方なら一度は手にしたことのあるであろう良書「ゼロから作るDeepLearning❷〜自然言語処理編〜」を日本語で実装していきたいと思います。
カウントベースでの自然言語処理
今回は、「ゼロから作るDeepLearning❷」の以下の範囲についてコーパスを日本語に置き換えて実装してみます。日本語の場合、英語と違って前処理がめんどくさいので、その辺りを中心に見ていただければと。
<対象>
題材:「ゼロから作るDeepLearning❷」
今回の範囲:2章 自然言語と単語の分散表現 2.3カウントベースの手法〜2.4.5PTBデータセットでの評価まで
※PTBデータセットは英語のデータセットなので、今回はPTBデータセットの代わりに日本語のコーパスを使用するイメージです。
###環境
Mac OS(Mojave)
Python3(Python 3.7.4)
jupiter Notebook
0.事前準備
もともとのデータは、記事の配信日ごとにテキストファイルが作成されており、このままでは扱いにくい(たぶん100以上のファイルがある)ため、まずはすべてのテキストファイルを結合し、1つの新しいテキストファイルにします。
複数のテキストファイルの結合については以下のコマンドで実行できます(macの場合)。
※windowsの場合は「cat」のところを「copy」に置き換えればよいかと。
$ cat ~/ディレクトリ名/*.txt > 新しいテキストファイル名.txt
<参考>
https://ultrabem-branch3.com/informatics/commands_mac/cat_mac
【余談】
本当にただの余談ですが、個人的には何気に上記の処理に結構苦戦しました。。
最初は、当該ファイルまで移動して、「cat *.txt > 新しいテキストファイル名.txt
」というコマンドを実行しましたが、ディレクトリ名を指定していなかったためか全く処理が終わらず(多分ワイルドカードでPCにあるすべてのテキストファイルが読み込まれようとしていた?)、終いには「容量が足りません!」という警告がバンバン出て、色々と大変なことになりました。。皆様お気をつけください。
1.データの前処理
⑴ 文章の分かち書き
import sys
sys.path.append('..')
import re
import pickle
from janome.tokenizer import Tokenizer
import numpy as np
import collections
with open("corpus/dokujo-tsushin/dokujo-tsushin-half.txt", mode="r",encoding="utf-8") as f: # 注1)
original_corpus = f.read()
text = re.sub("http://news.livedoor.com/article/detail/[0-9]{7}/","", original_corpus) # 注2)
text = re.sub("[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\+[0-9]{4}","", text) # 注3)
text = re.sub("[\f\n\r\t\v]","", text)
text = re.sub(" ","", text)
text = re.sub("[「」]","", text)
text = [re.sub("[()]","", text)]
# <ポイント>
t = Tokenizer()
words_list = []
for word in text:
words_list.append(t.tokenize(word, wakati=True))
with open("words_list.pickle",mode='wb') as f:
pickle.dump(words_list, f)
注1)今回は「ゼロから作るDeepLearning❷」と異なり、自分でコーパスを用意しているので、対象となるコーパスの読み込みを行います。また、今回読み込んでいるのは「dokujo-tsushin-half.txt」となっていますが、もともと「dokujo-tsushin-all.txt」を読み込もうとしていたものの、容量オーバーで読み込めないとの警告が出たため、「all」は断念し、「half」(⓪事前準備のテキストファイルの結合を全ファイルの半分だけ実行したもの)を使用しました。
※以前、チャットボットをつくった際には、もっと大量のコーパスを読み込んでいたため、おそらく自分のコーパスの処理の仕方の問題かと。
注2)コーパスすべてに記事のURLが入っていたため、それを正規表現を使用して削除しました。
注3)コーパスすべてに記事の投稿日時が記載されていたため、それを正規表現を使用して削除しました。
<ポイント>
今回は日本語の自然言語処理を行うため、本に記載のある方法(スペースで単語に分割する)では文章を単語に分解することができません。
そこで、今回はサードパーティーのライブラリであるjanomeをインストールし、日本語の文章を単語に分解(分かち書き)しました。
また、文章量が多く、その結果としての単語数も多いため、毎回分かち書きをするところから実行しなくて済むよう、pickleを使って実行結果を保存するようにしました。pickleファイルは以下のようにすることで呼び出すことができ、1から分かち書きをするよりも何倍も早くロードできます。
with open('words_list.pickle', mode='rb') as f:
words_list = pickle.load(f)
print(words_list) # ロード結果を表示する必要がなければ、こちらの記述は不要
# =>出力
#[['友人', '代表', 'の', 'スピーチ', '、', '独', '女', 'は', 'どう', 'こなし', 'て', 'いる', '?', 'もうすぐ', 'ジューン', '・', 'ブライド', 'と', '呼ば', 'れる', '6月', '。', '独', '女', 'の', '中', 'に', 'は', '自分', 'の', '式', 'は', 'まだ', 'な', 'のに', '呼ば', 'れ', 'て', 'ばかり', '…', '…', 'という', 'お祝い', '貧乏', '状態', 'の', '人', 'も', '多い', 'の', 'で', 'は', 'ない', 'だろ', 'う', 'か', '?', 'さらに', '出席', '回数', 'を', '重ね', 'て', 'いく', 'と', '、', 'こんな', 'お願い', 'ごと', 'を', 'さ', 'れる', 'こと', 'も', '少なく', 'ない', '。', 'お願い', 'が', 'ある', 'ん', 'だ', 'けど', '…', '…', '友人', '代表', 'の', 'スピーチ', '、', 'やっ', 'て', 'くれ', 'ない', 'か', 'な', '?', 'さて', 'そんな', 'とき', '、', '独', '女', 'は', 'どう', '対応', 'し', 'たら', 'いい', 'か', '?', '最近', 'だ', 'と', 'インターネット', '等', 'で', '検索', 'すれ', 'ば', '友人', '代表', 'スピーチ', '用', 'の', '例文', 'サイト', 'が', 'たくさん', '出', 'て', 'くる', 'ので', '、', 'それら', 'を', '参考', 'に', 'すれ', 'ば', '、', '無難', 'な', 'もの', 'は', '誰', 'でも', '作成', 'できる', '。', 'しかし', '由利', 'さん', '33', '歳', 'は', 'ネット', 'を', '参考', 'に', 'し', 'て', '作成', 'し', 'た', 'ものの', 'これ', 'で', '本当に', 'いい', 'の', 'か', '不安', 'でし', 'た', '。', '一人暮らし', 'な', 'ので', '聞か', 'せ', 'て', '感想', 'を', 'いっ', 'て', 'くれる', '人', 'も', 'い', 'ない', 'し', '、', 'か', 'と', 'いっ', 'て', '他', 'の', '友人', 'に', 'わざわざ', '聞か', 'せる', 'の', 'も', 'どう', 'か', 'と', ・・・以下省略
⑵ 単語にIDを付与したリストの作成
def preprocess(text):
word_to_id = {}
id_to_word = {}
# <ポイント>
for words in words_list:
for word in words:
if word not in word_to_id:
new_id = len(word_to_id)
word_to_id[word] = new_id
id_to_word[new_id] = word
corpus = [word_to_id[w] for w in words for words in words_list]
return corpus, word_to_id, id_to_word
corpus, word_to_id, id_to_word = preprocess(text)
print('corpus size:', len(corpus))
print('corpus[:30]:', corpus[:30])
print()
print('id_to_word[0]:', id_to_word[0])
print('id_to_word[1]:', id_to_word[1])
print('id_to_word[2]:', id_to_word[2])
print()
print("word_to_id['女']:", word_to_id['女'])
print("word_to_id['結婚']:", word_to_id['結婚'])
print("word_to_id['夫']:", word_to_id['夫'])
# =>出力
# corpus size: 328831
# corpus[:30]: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 5, 6, 2, 22, 23, 7, 24, 2]
# id_to_word[0]: 友人
# id_to_word[1]: 代表
# id_to_word[2]: の
# word_to_id['女']: 6
# word_to_id['結婚']: 456
# word_to_id['夫']: 1453
<ポイント>
・preprocessファンクションについては、基本的には、本書と同様ですが、今回は文章の単語分割はすでに行っているためその部分を削除していること、そして、本書とは異なりfor文を2回まわしてidを付与していることがポイントです。
for文を2回まわしているのは、本書と違い分かち書きを行ったことで、単語が2重のリストに入っているためです。
2.評価
以下については、書籍の内容とかぶる部分が多いため、詳細は割愛しますが、ところどころコメントを記載しているため参考にしていただけると幸いです。
※ 出力結果を見ると、まだまだ改善の余地はありそう。。
# 共起行列の作成
def create_co_matrix(corpus, vocab_size, window_size=1):
corpus_size = len(corpus)
co_matrix = np.zeros((vocab_size, vocab_size), dtype=np.int32)
for idx, word_id in enumerate(corpus):
for i in range(1, window_size + 1):
left_idx = idx - i
right_idx = idx + i
if left_idx >= 0:
left_word_id = corpus[left_idx]
co_matrix[word_id, left_word_id] += 1
if right_idx < corpus_size:
right_word_id = corpus[right_idx]
co_matrix[word_id, right_word_id] += 1
return co_matrix
# ベクトル間の類似度(cos類似度)判定
def cos_similarity(x, y, eps=1e-8):
nx = x / (np.sqrt(np.sum(x ** 2)) + eps)
ny = y / (np.sqrt(np.sum(y ** 2)) + eps)
return np.dot(nx, ny)
# ベクトル間の類似度をランキング
def most_similar(query, word_to_id, id_to_word, word_matrix, top=5):
if query not in word_to_id:
print('%s is not found' % query)
return
print('\n[query] ' + query)
query_id = word_to_id[query]
query_vec = word_matrix[query_id]
vocab_size = len(id_to_word)
similarity = np.zeros(vocab_size)
for i in range(vocab_size):
similarity[i] = cos_similarity(word_matrix[i], query_vec)
count = 0
for i in (-1 * similarity).argsort():
if id_to_word[i] == query:
continue
print(' %s: %s' % (id_to_word[i], similarity[i]))
count += 1
if count >= top:
return
# 正の相互情報量(PPMI)を使用した単語の関連性指標の改善
def ppmi(C, verbose=False, eps = 1e-8):
M = np.zeros_like(C, dtype=np.float32)
N = np.sum(C)
S = np.sum(C, axis=0)
total = C.shape[0] * C.shape[1]
cnt = 0
for i in range(C.shape[0]):
for j in range(C.shape[1]):
pmi = np.log2(C[i, j] * N / (S[j]*S[i]) + eps)
M[i, j] = max(0, pmi)
if verbose:
cnt += 1
if cnt % (total//100) == 0:
print('%.1f%% done' % (100*cnt/total))
return M
window_size = 2
wordvec_size = 100
corpus, word_to_id, id_to_word = preprocess(text)
vocab_size = len(word_to_id)
print('counting co-occurrence ...')
C = create_co_matrix(corpus, vocab_size, window_size)
print('calculating PPMI ...')
W = ppmi(C, verbose=True)
print('calculating SVD ...')
try:
# sklearnを使用したSVDによる次元削減
from sklearn.utils.extmath import randomized_svd
U, S, V = randomized_svd(W, n_components=wordvec_size, n_iter=5,
random_state=None)
except ImportError:
U, S, V = np.linalg.svd(W)
word_vecs = U[:, :wordvec_size]
querys = ['女性', '結婚', '彼', 'モテ']
for query in querys:
most_similar(query, word_to_id, id_to_word, word_vecs, top=5)
# =>以下、出力結果
"""
[query] 女性
男性: 0.6902421712875366
たち: 0.6339510679244995
モデル: 0.5287646055221558
世代: 0.5057054758071899
層: 0.47833186388015747
[query] 結婚
恋愛: 0.5706729888916016
交際: 0.5485040545463562
相手: 0.5481910705566406
?。: 0.5300850868225098
十: 0.4711574614048004
[query] 彼
彼女: 0.7679144740104675
彼氏: 0.67448890209198
夫: 0.6713247895240784
親: 0.6373711824417114
元: 0.6159241199493408
[query] モテ
る: 0.6267833709716797
考察: 0.5327887535095215
イケメン: 0.5280393362045288
女子: 0.5190156698226929
ちゃり: 0.5139431953430176
"""