読書メモ: Machine Learning 実践の極意(Part I)

「Machine Learning 実践の極意」を読みましたので、そのPart Iの読書メモです。

Machine Learning実践の極意 機械学習システム構築の勘所をつかむ! impress top gearシリーズ

Machine Learning実践の極意 機械学習システム構築の勘所をつかむ! impress top gearシリーズ

  • 作者: Henrik Brink,Joseph W. Richards,Mark Fetherolf,株式会社クイープ
  • 出版社/メーカー: インプレス
  • 発売日: 2017/11/17
  • メディア: Kindle版
  • この商品を含むブログを見る

Jupyter notebookのサンプルコードは以下のレポジトリにあります。

github.com

この本を読むモチベーション

2つあります。 読む箇所に緩急を付けますので、読書メモとしては乱杭になるかと思います。

  • 機械学習で得られたモデルを改善するプロセスやテクニックを獲得する
    • 第1部でアルゴリズムではなくプロセスごとに章立てされており、第2部ではさらに具体的な問題に適用しながらモデルを洗練させていく過程が記されているので、上の目的を果たしそう
    • どうすればさらに精度が高い答えにたどり着けるのか、についてメモしていきます
  • 機械学習に直接関連する学習からここ数ヶ月遠ざかっていたので、復習して、再定着を図る
    • この本はscikit-learn、pandasなどPythonのオーソドックスなツールを使って実装されており、上の目的を果たしそう
    • 「忘れてた」「こういう便利なものもあるのか」という再発見をメモしていきます

Part I 機械学習ワークフローの基礎

第2章 現実世界のデータ

  • 特徴量(説明変数)をどうやって選択すべきか
    • レンジを広くすると無関係な特徴量を含んでノイズとなるが、狭くすると関係する特徴量が抜け落ちるというトレードオフに悩まされる
    • 現実的なアプローチは、最初は関係する可能性が高い特徴量のみでモデルを構築し、それで十分な予測性能が得られない場合は、その他の特徴量を追加して拡張していく
      • 拡張した特徴量セットは特徴選択アルゴリズムで効果的な説明変数を選び出す
  • 目的変数(ground truth)を含むデータセットをどうやって収集すべきか
    • 既知の目的変数を含むデータセットを使って、未知の目的変数を含むデータセットから、トレーニングセットの候補を抽出する能動学習(active learning)と呼ばれる手法がある
  • トレーニングセットの量はどれくらい必要か(十分なのか)
    • データの典型性が維持されていれば、多ければ多いほうが良い
    • トレーニングセット数を増やしていき、予測性能が頭打ちになる点が無いかを調べる(頭打ちしている点が十分なトレーニングセット数)
  • トレーニングセットの典型性は十分なのか
    • トレーニングセットと、最終的に予測したい将来のデータが類似しているかどうか
    • サンプル選択時のバイアス、特徴量の収集方法の変化、データそのものの特性の変化が影響する
  • データが欠測していることに意味(情報利得)の有無で欠測値の扱いが異なる
    • 意味が有る場合は、MissingやNoneなどのカテゴリ値や、-1などの終端値に置き換える
    • 意味が無い場合は、その列の平均値や中央値、あるいは線形回帰などで補完する

第5章 特徴エンジニアリングの基礎

  • 特徴量選択の方法として、変数増加法(forward selection)と変数減少法(backward elimination)の2つがある
    • 変数増加法は以下プロセスで行う(変数減少法はこの逆で、全ての特徴量を含んだ特徴量セットからスタートして、1つずつ減らしていく)
      1. 空の特徴量セットを作る
      2. 利用可能な特徴量から1つを選択して、特徴量セットに一時的に追加して、交差検証を行う
      3. 利用可能な全ての特徴量に対して、2.を行う
      4. 最も性能が良い特徴量を選択して、特徴量セットに追加する
      5. 全ての特徴量が追加されたか、目標の性能に達するまで、2.〜4.を繰り返す
    • 上記の過程で、性能に影響を与える順番で特徴量がソートされるため、重要な特徴量を見抜くことにも役立つ

scikit-learnで特徴選択

特徴選択については、scikit-learnを使った実装まで掘り下げてみたいと思います。 (ちなみに第5章だけレポジトリにサンプルコードがレポジトリにありません。)

これまでランダムフォレストの重要度(例えばRandomForestClassifer.feature_importances_)やロジスティック回帰の重み(例えばLogisticRegressionClassifier.coef_)などで代用してきましたが、scikit-learnには特徴選択用のクラスも提供されています。 それらをいくつか試していきます。

http://scikit-learn.org/stable/modules/feature_selection.html

はじめにscikit-learn付属の乳がんデータセットをDataFrameにロードしておきます。
数値のみの30次元のデータを持ち、今回の検証では使いやすいデータセットとなっています。

import pandas as pd
from sklearn.datasets import load_breast_cancer


dataset = load_breast_cancer()

X = pd.DataFrame(data=dataset.data, columns=dataset.feature_names)
print('X shape: {}'.format(X.shape))
# X shape: (569, 30)

y = pd.DataFrame(data=dataset.target, columns=['target'])
print('y shape: {}'.format(y.shape))
# y shape: (569, 1)

RFE (recursive feature elimination)による特徴選択

変数減少法に近い実装として、sklern.feature_selection.RFE、および、RFEに交差検証を追加したRFECV(交差検証)があります。

RFEは、変数減少法と同じく、最初に全ての特徴量を使ってモデルを構築します。 そのモデルの中で最も重要度の低いを特徴量を削り、性能を再計測するという処理を、指定数(デフォルトでは1)の特徴量になるまで繰り返します。 重要度の指標にはfeature_importancesやcoefが使われます。

乳がんデートセットに対して、分類器にSVMを使い、REFCV(5分割交差検証)で特徴量を削減する実装は以下のとおりです。

  • ranking_には認識率が高くなる特徴量のランキングが昇順でセットされています
    • つまり値が1となっている特徴量のみを使うと最も高い認識率となり、今回の例では14個だけで十分ということです
    • プロットされたグラフを見てみても、特徴量数14の点で最も高い認識率(0.96)をマークしています
import matplotlib.pyplot as plt
from sklearn.svm import SVC
from sklearn.model_selection import StratifiedKFold
from sklearn.feature_selection import RFECV


# SVMによる分類
estimator = SVC(kernel='linear')

# 5分割交差検証
cv = StratifiedKFold(5)

# 特徴量削減
rfecv = RFECV(estimator, cv=cv, scoring='accuracy', step=1)

# 学習
rfecv.fit(X, y)

print('Feature ranking: \n{}'.format(rfecv.ranking_))
# Feature ranking: 
# [ 1  8  3 17  1  1  1  1  1 14 12  1  1  9 10  6  5  7 15 13  1  2 11 16  1 1  1  1  1  4]

# 最も高い認識率となる特徴量14個
print('Rank 1 features: \n{}'.format(X.columns[rfecv.ranking_ == 1]))
# Rank 1 features: 
# Index(['mean radius', 'mean smoothness', 'mean compactness', 'mean concavity',
#        'mean concave points', 'mean symmetry', 'texture error',
#        'perimeter error', 'worst radius', 'worst smoothness',
#        'worst compactness', 'worst concavity', 'worst concave points',
#        'worst symmetry'],
#       dtype='object')

# 特徴量数と認識率の変化をプロット
plt.plot(range(1, len(rfecv.grid_scores_) + 1), rfecv.grid_scores_)
plt.show()

f:id:ohke:20171118145833p:plain

SelectPercentileによる特徴選択

ある指標に従って、上位X%の特徴量を選択するのがsklearn.feature_selection.SelectPercentileです。
指標には、カイ二乗検定(chi2)、F検定(f_classif)などが使えます。

類似したクラスとして、上位k個の特徴量を選択するSelectKBestがあります。

カイ二乗値の上位20%の特徴量を選択する実装を以下に示します。

from sklearn.feature_selection import SelectPercentile, chi2

# 20%(6個)の特徴量を選択
transformed_X = SelectPercentile(chi2, percentile=20).fit_transform(X, y)

print('Transformed X shape: {}'.format(transformed_X.shape))
# Transformed X shape: (569, 6)

SelectFromModelによる特徴選択

feature_importanceやcoefを使った特徴量選択の方法として、sklearn.feature_selection.SelectFromModeも提供されています。

SelectFromModelは学習済みのモデル(feature_importanceまたはcoefを持つことが前提)と閾値を生成時に渡し、transformメソッドで閾値以上の重要度の特徴のみを抽出した特徴ベクトルへ変換する、前処理として機能します。
あくまで変換のみに使うため、分類は別のモデルで行うこともできます。

SelectFromModelを使って、ランダムフォレストのfeature_importances_で特徴選択するコードを以下に示します。

  • prefitがTrueの場合は、学習済みの分類器を用意して、SelectFromModelにセットします
    • thresholdで選択する特徴量の閾値を設定してます(今回は平均値以上の特徴量を選択します)
  • transformメソッドで30次元から9次元まで削減されます
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_selection import SelectFromModel
from sklearn.pipeline import Pipeline


# ランダムフォレスト
estimator = RandomForestClassifier(random_state=0)
estimator.fit(X, y)

# 重要度が平均値以上の特徴量を抽出
sfm = SelectFromModel(estimator, threshold='mean', prefit=True)
transformed_X = sfm.transform(X)

# 30次元から9次元まで削減
print('Transformed X shape: {}'.format(transformed_X.shape))
# Transformed X shape: (569, 9)

# estimatorから計算した場合と一致することを確認
importances = estimator.feature_importances_
importances[importances > sum(importances)/len(importances)]
# array([ 0.07046207,  0.08107073,  0.14697489,  0.04568418,  0.12102667,
#         0.1117098 ,  0.10620932,  0.04035243,  0.10317082])

LDAでブログ記事のトピックを抽出・分類する

今回はLDAを使って、京大ブログコーパスをトピック毎に分類できないか試みてみます。

LDA

LDA(Latent Dirichlet Allocation, 潜在ディリクレ配分法)は、文書のトピック(文書の話題、カテゴリ、ジャンルとも言える)についてのモデルです。 初出は以下の論文です。

http://jmlr.csail.mit.edu/papers/v3/blei03a.html

LDAでは、文書のトピックは単一に定まるのではなく、複数のトピックの割合で表現できると考えます。 例えば、「AlphaGo」に関するある記事は、「囲碁」トピック60%、「人工知能」トピック40%といった感じです。 トピックは未知ですが、この確率分布はディリクレ分布に従って生成されると仮定します。
文書を構成する各単語は、この潜在的なトピックから生成されたと考えます。 AlphaGoの記事を再度引き合いに出すと、例えば「19路盤」という単語は囲碁トピック95%、「ニューラルネットワーク」なら人工知能トピック80%といった具合です。 つまり、共通する単語を多く持つ2つの文書は、トピックも重複していると考えることができます。

LDAでは、各単語のトピック(確率分布)を推定し、さらに単語の集合である文書のトピックを推定します。 ただし、教師なし学習のため、ラベリングされるわけではないことに注意してください。 またトピック数もハイパーパラメータとして事前に設定する必要があります。

LDAについては以下が参考になります。

abicky.net

LDA入門

LDAの実装として、今回もgensimを使います。

radimrehurek.com

ニュース記事から単語を抽出

前回の投稿でも使った京大ブログコーパスからトピック抽出します。

http://nlp.ist.i.kyoto-u.ac.jp/kuntt/

リンクから、"解析済みブログコーパス"(KNBC_v1.0_090925.tar.bz2)をダウンロード・解凍し、得られたディレクトリ(KNBC_v1.0_090925)をこれから作成するPythonファイルと同じディレクトリにコピーします。

KNBC_v1.0_090925/corpus2ディレクトリ以下には4つのカテゴリ(グルメ、携帯電話、京都観光、スポーツ)毎にTSVファイルが存在します。
グルメ(Gourmet.tsv)が57記事、携帯電話(Keitai.tsv)が79記事、京都観光(Kyoto.tsv)が91記事、スポーツ(Sports.tsv)が22記事で、合計249記事が収録されています。

import glob
import re


# ブログコーパスのリスト
texts = {}

# livedoorニュースのファイル名一覧を取得する
paths = glob.glob('./KNBC_v1.0_090925/corpus2/*.tsv')

for path in paths:
    with open(path, 'r', encoding='euc-jp') as f:
        tsv = f.read()
        
        for i, line in enumerate(tsv.split('\n')):
            if line == '':
                break

            # 1行1文となっており、各行は以下のようにタブ区切りされている
            # KN203_Kyoto_1-1-2-01 最近、鴨川沿いを歩くことにはまってます☆    [著者]    鴨川沿いを歩くことにはまってます    採否+   鴨川沿いを歩くこと
            columns = line.split('\t')
            # 各行の1番目のカラムには記事IDが付与されているので、記事ID毎に本文(2番目のカラム)を連結する
            # なお3番目以降のカラムはアノテーションで、今回は使用しないため、読み捨てる
            index = columns[0].split('-')[0]
            if not index in texts:
                texts[index] = ''
                # 1行目はタイトルで、"[京都観光]"などの分類を表す言葉が入っているので、読み捨てる
                continue
            texts[index] = texts[index] + columns[1]

print('texts length: {}'.format(len(texts)) )
# texts length: 249
print('text(KN203_Kyoto_1): \n{}'.format(texts['KN203_Kyoto_1']))
# text(KN203_Kyoto_1): 
# 最近、鴨川沿いを歩くことにはまってます☆鴨川沿いを歩くのはほんとに気持ちよくて、京都はいいところだなぁとしみじみ感じています。虫がたまに口の中に入るのが難点なのですが・・でも、最近歩きすぎて、なんだか足に筋肉がついてきてる気が!?歩きすぎには要注意です。とかいいつつ、今日もいい天気なので、時間をみつけていってこようと思います☆

形態素解析

今回もNEologd付きのjanomeで形態素解析し、単語ごとに分かち書きします。 NEologd付きのjanomeのインストールは本家のwiki前回の投稿を参考にしてみてください。

from janome.tokenizer import Tokenizer
from janome.charfilter import UnicodeNormalizeCharFilter, RegexReplaceCharFilter
from janome.tokenfilter import POSKeepFilter, LowerCaseFilter, ExtractAttributeFilter
from janome.analyzer import Analyzer

            
char_filters = [UnicodeNormalizeCharFilter(), # UnicodeをNFKCで正規化
                RegexReplaceCharFilter('\d+', '0')] # 数字を全て0に置換

tokenizer = Tokenizer(mmap=True) # NEologdを使う場合、mmap=Trueとする

token_filters = [POSKeepFilter(['名詞', '形容詞', '副詞', '動詞']), # 名詞、形容詞、副詞、動詞のみを抽出する
                 LowerCaseFilter(), # 英字は小文字にする
                 ExtractAttributeFilter('base_form')] # 原型のみを取得する

analyzer = Analyzer(char_filters, tokenizer, token_filters)

単語の抽出とストップワードの除去

先程作成したAnalyzerで分かち書きを行い、最後にストップワードを除去します。

今回は、Slothlibのストップワードを使います。
これは「あそこ」「こちら」「私」「多く」「確か」などの日本語のほとんどの文書に現れる単語のリストで、こういった単語はカテゴリ抽出の役に立たないので除外します。

上記の結果、249記事から28104単語を抽出しました。

import urllib.request


stopwords = []
url = 'http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt'

# ストップワードの取得
with urllib.request.urlopen(url) as response:
    stopwords = [w for w in response.read().decode().split('\r\n') if w != '']

print('Stopwords: {}'.format(stopwords))
# Stopwords: ['あそこ', 'あたり', 'あちら', 'あっち', 'あと', ...

texts_words = {}

for k, v in texts.items():
    texts_words[k] = [w for w in analyzer.analyze(v)]

print('words count: {}'.format(sum([len(t) for t in texts_words.values()])))
# words count: 28104

辞書とコーパスを作る

gensimのLDAでは、事前に辞書とコーパスを作る必要があります。

  • 辞書は、単語とその単語を一意に識別するIDを持ち、gensim.corpora.Dictionaryがその実装となります
    • Dictionary.filter.extremesで辞書に登録する単語に制限を設けられます
      • no_below: 出現する文書数が指定数未満の単語は除外(今回は3未満)
      • no_above: 出現する文書の割合が指定数以上の単語は除外(今回は40%以上)
  • コーパスは、文書毎の単語IDとその出現回数を持つタプルリストで、いわゆるBag of Words(BoW)です
# pip install gensim
import gensim


# 辞書の作成
dictionary = gensim.corpora.Dictionary(texts_words.values())
dictionary.filter_extremes(no_below=3, no_above=0.4)
# 辞書をテキストファイルで保存する場合
# dictionary.save_as_text('blog_dictionary.txt')
# dictionary = gensim.corpora.Dictionary.load_from_text('blog_dictionary.txt')

print('dictionary: {}'.format(dictionary.token2id))
# dictionary: {'行く': 0, 'くる': 1, 'おいしい': 2, '庭': 3, '町屋': 4, '風': 5, '店内': 6, ...

# コーパスの作成(ベクトル化)
corpus = [dictionary.doc2bow(words) for words in texts_words.values()]
# コーパスをテキストファイルで保存する場合
# gensim.corpora.MmCorpus.serialize('blog_corpus.mm', corpus)
# corpus = gensim.corpora.MmCorpus('blog_corpus.mm')

print('corpus: {}'.format(corpus))
# corpus: [[(0, 2), (1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 1), ...

モデルを構築

最後にLDAでモデルを構築して、文書毎のトピックを分析してみます。

gensim.models.ldamodel.LdaModelを使っています。

  • corpusid2wordに、先程作成したコーパスと辞書をセットします
  • num_topics: トピック数を設定します
    • 元々4つのカテゴリに分けられた文書のため、4を設定します

show_topicsメソッドでトピック毎に出現確率が高い単語順に出力できます。

  • トピック0、2、3は類似しており、京都観光とグルメが当てはまりそうな単語が並んでいます
  • トピック1は、携帯電話に関するもののようです
# LDAモデルの構築
lda = gensim.models.ldamodel.LdaModel(corpus=corpus, 
                                      num_topics=4, 
                                      id2word=dictionary, 
                                      random_state=1)

print('topics: {}'.format(lda.show_topics()))
[(0,
  '0.022*"京都" + 0.014*"行く" + 0.011*"食べる" + 0.009*"くる" + 0.008*"みる" + 0.007*"見る" + 0.007*"ん" + 0.007*"できる" + 0.007*"いう" + 0.006*"いい"'),
 (1,
  '0.024*"携帯" + 0.020*"携帯電話" + 0.014*"使う" + 0.012*"メール" + 0.011*"しまう" + 0.010*"くる" + 0.009*"いう" + 0.008*"できる" + 0.008*"電話" + 0.008*"みる"'),
 (2,
  '0.011*"行く" + 0.011*"できる" + 0.010*"京都" + 0.010*"てる" + 0.010*"ん" + 0.009*"使う" + 0.009*"メール" + 0.009*"食べる" + 0.009*"いい" + 0.009*"見る"'),
 (3,
  '0.014*"しまう" + 0.013*"京都" + 0.011*"できる" + 0.009*"見る" + 0.009*"いい" + 0.009*"くる" + 0.008*"行く" + 0.008*"携帯電話" + 0.008*"みる" + 0.008*"ん"')]

トピックを分析

最後に文書毎のトピックを分析してみます。

LdaModel.get_document_topicsメソッドで、文書のBoWからトピックに属する確率をタプルリストで取得できます。 確率が最大となるトピックIDをその文書のトピックとして分類しました。

topic_counts = {
    'Kyoto': [0, 0, 0, 0],
    'Gourmet': [0, 0, 0, 0],
    'Keitai': [0, 0, 0, 0],
    'Sports': [0, 0, 0, 0]
}

for k, v in texts_words.items():
    category = k.split('_')[1]
    bow = dictionary.doc2bow(v)
    topics = lda.get_document_topics(bow)
    
    top_topic = sorted(topics, key=lambda topic:topic[1], reverse=True)[0][0]
    topic_counts[category][top_topic] += 1

print('Topic counts:\n{}'.format(topic_counts))

結果は以下の通りとなりました。 例えば、グルメカテゴリの記事については、25本がトピック0、1本がトピック1、15本がトピック2、16本がトピック3に分類されていることを表しています。

京都観光カテゴリの記事がトピック0とトピック2(91本中73本で80%)、携帯電話カテゴリの記事がトピック1(79本中46本58%)に分類されており、概ねカテゴリ化できていることがわかります。

一方で、グルメカテゴリの記事もトピック0と2に寄ってしまっています。 グルメ記事の内容も、京都のお店が中心となっており、京都観光カテゴリの記事と重複する単語を多く含んでいたため、十分に分類できなかったと思われます。
スポーツカテゴリの記事については各トピックに分散してしまっています。 各単語は他のカテゴリと比較しても文書数が少なく、また、各記事で競技が多岐に渡っていたため(ボート、水泳、野球、剣道、他にも部活の思い出など)、カテゴリ内で重複する単語が少なく、その結果スポーツ以外の単語をベースにトピック分析された結果、分散してしまったと考えられます。

Topic counts:
{'Gourmet': [25, 1, 15, 16],
 'Keitai': [6, 46, 17, 10],
 'Kyoto': [40, 1, 33, 17],
 'Sports': [6, 6, 7, 3]}

Word2Vecで京都観光に関するブログ記事の単語をベクトル化する

京都観光に関するブログ記事を使い、Word2Vecで単語のベクトル化します。
ベクトル化することで、例えば「紅葉」という言葉から紅葉の名所を列挙したり、「カップル」という言葉からデートコースを探したりできないか、というのを試みてみたいと思います。

Word2Vec

数万〜数十万ある単語をそれぞれ数百次元のベクトルに落とし込むことで、単語ごとの特徴量を得る手法です。

単語ごとにベクトルとして表現されるため、単語間の類似度計算(例えば"air"は"water"よりも"atomosphere"に似ているなど)や、単語どうしの加減算(例えばking-man+womanなど)が可能となります。

  • ベクトル化できれば、コサイン類似度などで類似度を算出できます

内部的には2層のニューラルネットワークを学習することでベクトルを得ています。 こちらの方の解説記事がとても参考になります(今回採用するSkip-Gramに関する記述もあります)。

qiita.com

入力(学習データ)は辞書である必要はなく、あらゆる文書を対象に学習できます。

京大ブログコーパス

類義語辞書のデータソースとして、京大ブログコーパスを使います。

これは、4つのテーマ(京都観光、携帯電話、スポーツ、グルメ)に関する249記事(合計4186文)のアノテーション(形態素、構文、格・省略・照応、評判情報)付きコーパスです。 京大と日本電信電話株式会社の共同プロジェクトにより作られました(ちなみにMeCabも同じプロジェクトの成果の1つです)。

http://nlp.ist.i.kyoto-u.ac.jp/kuntt/

リンクから、"解析済みブログコーパス"(KNBC_v1.0_090925.tar.bz2)をダウンロード・解凍し、得られたフォルダ(KNBC_v1.0_090925)をこれから作成するPythonファイルと同じフォルダにコピーします。

そして、KNBC_v1.0_090925/corpus2/Kyoto.tsvから記事毎の本文のみを抽出します(今回はアノテーションを使いません)。
Kyoto.tsvは1行1文となっており、例えば記事KN203であれば以下のように7文で構成されます。

KN203_Kyoto_1-1-1-01 [京都観光]鴨川                
KN203_Kyoto_1-1-2-01    最近、鴨川沿いを歩くことにはまってます☆    [著者]    鴨川沿いを歩くことにはまってます    採否+   鴨川沿いを歩くこと
KN203_Kyoto_1-1-3-01    鴨川沿いを歩くのはほんとに気持ちよくて、京都はいいところだなぁとしみじみ感じています。   [著者]\n[著者]  ほんとに気持ちよくて\nいいところだなぁとしみじみ感じています   感情+\n批評+    鴨川沿いを歩くの\n京都
KN203_Kyoto_1-1-3-02    虫がたまに口の中に入るのが難点なのですが・・  [著者]    虫がたまに口の中に入るのが難点なのですが・・  メリット- 虫がたまに口の中に入るの
KN203_Kyoto_1-1-4-01    でも、最近歩きすぎて、なんだか足に筋肉がついてきてる気が!?              
KN203_Kyoto_1-1-5-01    歩きすぎには要注意です。    [著者]    歩きすぎには要注意です   当為  歩きすぎ
KN203_Kyoto_1-1-6-01    とかいいつつ、今日もいい天気なので、時間をみつけていってこようと思います☆ [著者]    今日もいい天気なので、時間をみつけていってこようと思います 採否+   [鴨川沿いを歩くの]

Pythonでディクショナリ(texts)に記事毎の本文を抽出します。 合計72記事となりました。

# {'記事ID(KNxxx)': '本文'}
texts = {}

with open('./KNBC_v1.0_090925/corpus2/Kyoto.tsv', 'r', encoding='euc-jp') as f:
    # Kyoto.tsvファイルを読み込み
    kyoto_tsv = f.read()
    
    # 記事毎に本文を抽出する
    for line in kyoto_tsv.split('\n'):
        if line == '':
            break
        
        # Kyoto.tsvは1行1文となっており、各行は以下のようにタブ区切りされている
        # KN203_Kyoto_1-1-2-01 最近、鴨川沿いを歩くことにはまってます☆    [著者]    鴨川沿いを歩くことにはまってます    採否+   鴨川沿いを歩くこと
        columns = line.split('\t')
        # 各行の1番目のカラムにはKNxxx(記事ID)が付与されているので、記事ID毎に本文(2番目のカラム)を連結する
        # なお3番目以降のカラムはアノテーションで、今回は使用しないため、読み捨てる
        index = columns[0].split('_')[0]
        if not index in texts:
            texts[index] = ''
        texts[index] = texts[index] + columns[1]
    
print('記事数: {}'.format(len(texts)))
# 記事数: 72
print('本文(KN203): {}'.format(texts['KN203']))
# 本文(KN203): [京都観光]鴨川最近、鴨川沿いを歩くことにはまってます☆鴨川沿いを歩くのはほんとに気持ちよくて、京都はいいところだなぁとしみじみ感じています。虫がたまに口の中に入るのが難点なのですが・・でも、最近歩きすぎて、なんだか足に筋肉がついてきてる気が!?歩きすぎには要注意です。とかいいつつ、今日もいい天気なので、時間をみつけていってこようと思います☆

janome形態素解析

今回もjanome形態素解析します(janomeについては11/2の投稿で詳しく紹介しています)。

ただし、今回はNEologdを内包したjanomeを使うため、janomewikiを参考にインストールします。

(very experimental) NEologd 辞書を内包した janome をビルドする方法 · mocobeta/janome Wiki · GitHub

Janome-0.3.5.neologd20170828.tar.gzをダウンロードして、以下コマンドでインストールします。
使用時には Tokenizer(mmap=True) でTokenizerを生成します。

$ pip install Janome-0.3.5.neologd20170828.tar.gz --no-compile
$ python -c "from janome.tokenizer import Tokenizer; Tokenizer(mmap=True)"

それでは形態素解析分かち書きします。 名詞、形容詞、副詞の原型を抽出しています。

from janome.tokenizer import Tokenizer
from janome.charfilter import UnicodeNormalizeCharFilter, RegexReplaceCharFilter
from janome.tokenfilter import POSKeepFilter, LowerCaseFilter, ExtractAttributeFilter
from janome.analyzer import Analyzer

            
char_filters = [UnicodeNormalizeCharFilter(), # UnicodeをNFKCで正規化
                RegexReplaceCharFilter('\d+', '0')] # 数字を全て0に置換

tokenizer = Tokenizer(mmap=True) # NEologdを使う場合、mmap=Trueとする

token_filters = [POSKeepFilter(['名詞']), # 名詞のみを取得する
                 LowerCaseFilter(), # 英字は小文字にする
                 ExtractAttributeFilter('base_form')] # 原型のみを取得する

analyzer = Analyzer(char_filters, tokenizer, token_filters)

print('KN203から抽出した単語: ')
for token in analyzer.analyze(texts['KN203']):
    print(token)
# KN203から抽出した単語: 
# 鴨川
# 最近
# 鴨川
# 沿い
# こと
# 鴨川
# 沿い
# の
# 京都
# ところ
#  ・・・

ストップワードの除去

上で見たKN203もそうですが、「の」や「こと」、「京都」、「観光」などはほとんどの文書に現れます。

そのため、一定の頻度(ここでは25%以上)の文書に現れる単語はストップワードとして除去します。

# ストップワードの取得
def get_stopwords(texts_words):
    word_count = {}
    
    for words in texts_words.values():
        for word in set(words):
            if not word in word_count:
                word_count[word] = 0
            word_count[word] += 1
            
    # 18(25%)以上の文書に含まれる単語をストップワードとする
    return {k for k, v in word_count.items() if v >= len(texts_words) * 0.25}


# 文書毎に形態素解析で分かち書きされた単語リストを持つ
texts_words = {}
for k, v in texts.items():
    texts_words[k] = list(analyzer.analyze(v))

stopwords = get_stopwords(texts_words)
print('stop words: {}'.format(stopwords))
# stop words: {'時間', '人', '観光', '京都', '的', 'それ', '何', 'こと', 'とき', '気', '中', 'もの', '前', '京都観光', '0', 'さ', 'ところ', 'ん', 'よう', '私', 'これ', 'の', '一'}

Word2Vecの入力ファイルの生成

前処理の最後に、Word2Vecで読み込む入力ファイルを生成します。

Word2Vecは、1行1文書で、かつ、各行は単語ごとに半角スペースで区切られているテキストファイルとなっている必要があります。
"prepared_text.txt"に書き出しています。

preprocessed_texts = {}

for k, v in texts_words.items():
    preprocessed_texts[k] = ' '.join([w for w in v if w not in stopwords])

print('前処理後(KN203): {}'.format(preprocessed_texts['KN203']))
# 前処理後(KN203): 鴨川 最近 鴨川 沿い 鴨川 沿い 虫 口 難点 最近 足 筋肉 すぎ 要注意 今日もいい天気

with open('prepared_text.txt', 'w') as f:
    f.write('\n'.join(preprocessed_texts.values()))

これで準備完了です。

Word2Vecを使う

それではWord2Vecでモデルを構築していきます。

トピック分析のライブラリとして有名なgensimを使います。
gensimのトピック分析モデルの一つとしてWord2Vecも組み込まれています(他にもLDAなどが内包されています)。 いつもどおり、 $ pip install gensim でインストールしておきます。

radimrehurek.com

Word2Vecのモデル構築は2ステップです。
ベクトル化のアルゴリズムとしてCBoWとSkip-Gramが選択できます。 データセットが小さくても良い性能が出ると評価されているSkip-Gramを使います。

  1. word2vec.LineSentenceでprepared_text.txtをコーパスにする
  2. word2vec.Word2Vecで作成したコーパスからモデルを構築する
    • sgは、ベクトル化のアルゴリズムを選択するオプションで、1の場合はSkip-Gramが選択される(デフォルトは0で、CBoWが選択される)
    • sizeは、ベクトルの次元数(デフォルトは100)
    • windowは、Skip-Gram使用時に前後いくつの単語を見るか定めるもの(デフォルトは5)
    • min_countは、出現頻度がこの値より少ない単語についてはベクトル化に含められないようになります(デフォルトは0)
from gensim.models import word2vec


# prepared_text.txtからコーパスを生成
sentences = word2vec.LineSentence('prepared_text.txt')

# モデル構築
model = word2vec.Word2Vec(sentences, sg=1, size=200, window=5, min_count=3)

それでは、いくつか京都観光に関する単語と、類似している単語上位10個を列挙してみます。

"紅葉"については、"貴船神社"、"嵐山"といった紅葉の名所との関連が強いことがわかります。 上手くいった例かと思います。

一方で、"カップル"は特に関連するような言葉は出てこない結果となりました。 "カップル"を含む元々の文書数が少ない("紅葉"が10に対して"カップル"は6)ことが影響しているようです。

similars1 = model.wv.most_similar(positive='紅葉', topn=10)
print(similars1)
# [('通り', 0.9995862245559692),
#  ('そう', 0.9995594024658203),
#  ('僕', 0.9995542764663696),
#  ('貴船神社', 0.9995498061180115),
#  ('道', 0.9995443820953369),
#  ('寺', 0.9995434880256653),
#  ('日', 0.99953693151474),
#  ('ため', 0.9995332956314087),
#  ('方', 0.9995331168174744),
#  ('嵐山', 0.9995298385620117)]

similars2 = model.wv.most_similar(positive='カップル', topn=10)
print(similars2)
# [('寺', 0.9993862509727478),
#  ('自転車', 0.999378502368927),
#  ('さん', 0.9993761777877808),
#  ('お寺', 0.9993686676025391),
#  ('水', 0.9993683695793152),
#  ('山', 0.9993564486503601),
#  ('そう', 0.9993509650230408),
#  ('通り', 0.9993289709091187),
#  ('雰囲気', 0.9993254542350769),
#  ('時', 0.9993242025375366)]

文書数が少ないため、類義語辞書を簡単に作るといったWord2Vecの真骨頂を出し切れていない感じはありますね。。。

岩波データサイエンス Vol.2

岩波データサイエンス Vol.2