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

Python janomeのanalyzerが便利

前回の投稿でも形態素解析に利用したjanomeですが、形態素解析を単純にラッピングするだけでなく、いくつかシンプルで便利な機能も実装されています。

今回は、形態素解析以外の前処理も簡単に統合できるanalyzerについて紹介します。

前処理が必要なデータ

前処理が必要となるデータの例として、太宰治著「走れメロス」を青空文庫からダウンロードしてきます(原文はこちら)。

import urllib.request


# 「走れメロス」を青空文庫からダウンロード
url = 'http://www.aozora.gr.jp/cards/000035/files/1567_14913.html'
html = ''

with urllib.request.urlopen(url) as response:
    html = response.read().decode('shift_jis')

print(html)

ダウンロードした文書は、以下の特徴を持ち、そのままでは形態素解析にかけることはできません。

  • 本文以外と関係のないHTMLタグが含まれる
  • 本文がdiv要素( <div class="main_text">〜</div> )に挟まれている
  • 本文中にもルビのタグ( <ruby><rb>邪智暴虐</rb><rp>(</rp><rt>じゃちぼうぎゃく</rt><rp>)</rp></ruby> )が含まれている
<?xml version="1.0" encoding="Shift_JIS"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
    "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja" >
<head>
    <meta http-equiv="Content-Type" content="text/html;charset=Shift_JIS" />
    <meta http-equiv="content-style-type" content="text/css" />
    <link rel="stylesheet" type="text/css" href="../../aozora.css" />
    <title>太宰治 走れメロス</title>
    <script type="text/javascript" src="../../jquery-1.4.2.min.js"></script>
  <link rel="Schema.DC" href="http://purl.org/dc/elements/1.1/" />
    <meta name="DC.Title" content="走れメロス" />
    <meta name="DC.Creator" content="太宰治" />
    <meta name="DC.Publisher" content="青空文庫" />
</head>
<body>
<div class="metadata">
<h1 class="title">走れメロス</h1>
<h2 class="author">太宰治</h2>
<br />
<br />
</div>
<div id="contents" style="display:none"></div><div class="main_text"><br />
 メロスは激怒した。必ず、かの<ruby><rb>邪智暴虐</rb><rp>(</rp><rt>じゃちぼうぎゃく</rt><rp>)</rp></ruby>の王を除かなければならぬと決意した。
(以下略)

試しにそのまま形態素解析してみても、意味のある結果とはなりません。

# pip install janome
from janome.tokenizer import Tokenizer

tokenizer = Tokenizer()

for token in tokenizer.tokenize(html):
    print(token)
<?    名詞,サ変接続,*,*,*,*,<?,*,*
xml 名詞,固有名詞,組織,*,*,*,xml,*,*
    記号,空白,*,*,*,*, ,*,*
version 名詞,固有名詞,組織,*,*,*,version,*,*
="  名詞,サ変接続,*,*,*,*,=",*,*
1   名詞,数,*,*,*,*,1,*,*
.   名詞,サ変接続,*,*,*,*,.,*,*
0   名詞,数,*,*,*,*,0,*,*
"   名詞,サ変接続,*,*,*,*,",*,*
・・・

janome.analyzer

日本語の自然言語処理では、形態素解析以外にも様々な前処理が必要となります。

形態素解析の前後で行う処理は異なります。 形態素解析前に行う処理は、例えば、処理の対象とする文章の抽出、不要な文字列の削除(HTMLタグなど)、文字種の統一(英字は全て英小文字にするなど)、スペルミス・変換ミスなどによる表記ゆらぎの補正などです。
これに対して、形態素解析後に行う処理は分かち書き後の字句(トークン)を対象としており、数字の置換(数字の名詞は全て0に置き換えるなど)、特定の品詞のみの抽出などです。

前処理とその効果については、こちらの方のQiita投稿がとても参考になります。

qiita.com

janome(v.0.3.4以上)で提供されているanalyzeモジュールでは、こうした前処理を楽に行う仕組みが用意されています。

analyzerは、文字単位で処理するCharFilter、形態素解析分かち書きをするTokenizer、分かち書きされたトークン単位で処理するTokenFilterを組み合わせて使います。

CharFilter

janomeでは2つのCharFilterが提供されています。

また、CharFilterクラスを継承することで、独自のCharFilterも実装できます。
ここでは、取得したHTMLから本文を抽出するMainTextCharFilterを実装します。 このクラスは、開始文字列(start)と終了文字列(end)で挟まれた文字列を抽出して返します。 CharFilterクラスを継承していること、initで処理に必要なパラメータを渡していること、applyで抽出処理を実装していることがポイントです。

from janome.charfilter import *


class MainTextCharFilter(CharFilter):
    """
    開始文字列(start)と終了文字列(end)に挟まれたコンテンツ文字列を返すCharFilterの実装
    青空文庫の場合、<div class="main_text">・・・本文・・・</div><div class="bibliographical_information">なので、
    startには'<div class="main_text">'、endには'<div class="bibliographical_information">'を設定する
    """
    def __init__(self, start, end):
        self.start = start
        self.end = end

    def apply(self, text):
        return text.split(self.start)[1].split(self.end)[0]

janome.Analyzerで、これらCharFilterとTokenizerを組み合わせます。
使い方はとても簡単で Analyzer(CharFilterのリスト, tokenizer, TokenFilterのリスト) とするだけで、CharFilterによる文字単位の処理→トークン化(形態素解析)→TokenFilterによるトークン単位の処理を順番に行ってくれます(scikit-learnのPipeline、のようなイメージです)。 CharFilterはリスト内の順番(つまり追加された順番)で処理されます(TokenFilterについても同様です)。

それでは、UnicodeNormalizeCharFilterでUnicodeへの変換、MainTextCharFilterで本文の抽出、RegexReplaceCharFilterでルビとHTMLタグの削除を組み合わせます。
Analyzer.analyzeメソッドで、形態素解析まで完了したトークンを取得します。

from janome.analyzer import Analyzer


char_filters = [UnicodeNormalizeCharFilter(), # UnicodeをNFKC(デフォルト)で正規化
                MainTextCharFilter('<div class="main_text">', '<div class="bibliographical_information">'), # 本文を抽出
                RegexReplaceCharFilter('<rp>\(.*?\)</rp>', ''), # ルビを削除
                RegexReplaceCharFilter('<.*?>', '')] # HTMLタグを削除

tokenizer = Tokenizer()

analyzer = Analyzer(char_filters, tokenizer)

for token in analyzer.analyze(html):
    print(token)

出力されたトークンを表示すると、HTMLタグやルビが削除され、本文のみを対象にした形態素解析ができていることが確認できます。

メロス    名詞,一般,*,*,*,*,メロス,*,*
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
激怒  名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し 動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
た 助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
。 記号,句点,*,*,*,*,。,。,。
必ず  副詞,助詞類接続,*,*,*,*,必ず,カナラズ,カナラズ
、 記号,読点,*,*,*,*,、,、,、
かの  連体詞,*,*,*,*,*,かの,カノ,カノ
邪智  名詞,一般,*,*,*,*,邪智,ジャチ,ジャチ
暴虐  名詞,一般,*,*,*,*,暴虐,ボウギャク,ボーギャク
の 助詞,連体化,*,*,*,*,の,ノ,ノ
王 名詞,一般,*,*,*,*,王,オウ,オー
・・・(中略)・・・
山越え   名詞,一般,*,*,*,*,山越え,ヤマゴエ,ヤマゴエ
、 記号,読点,*,*,*,*,、,、,、
十 名詞,数,*,*,*,*,十,ジュウ,ジュー
里 名詞,一般,*,*,*,*,里,サト,サト
・・・

TokenFilter

janomeでは7つのTokenFilterが提供されています。

またTokenFilterを継承することで、独自の処理も定義できます。
ここでは数字(漢数字を含む)を表す名詞を全て0で置き換えるNumericReplaceFilterを実装します。 例えば"十里"の場合、"十"と"里"に分けられますので、"十"を"0"に置き換えます。 読みもあわせて"ゼロ"に統一します。

CharFilterで漢数字を全て0に置き換えても良さそうですが、例えば"四肢"や"人一倍"など、一般名詞や形容詞として漢数字を含む場合もあり、それらは置換されないようにするために、品詞が特定できる形態素解析後に処理します。

実装は以下のとおりです。 TokenFilterを継承していること、トークンのリストのみを引数とするapplyで処理を実装していることがポイントです。

from janome.tokenfilter import *


class NumericReplaceFilter(TokenFilter):
    """
    名詞中の数(漢数字を含む)を全て0に置き換えるTokenFilterの実装
    """
    def apply(self, tokens):
        for token in tokens:
            parts = token.part_of_speech.split(',')
            if (parts[0] == '名詞' and parts[1] == '数'):
                token.surface = '0'
                token.base_form = '0'
                token.reading = 'ゼロ'
                token.phonetic = 'ゼロ'
            yield token

最後に、先ほど作成したchar_filters、tokenizer、そして、NumericReplaceFilter、CompoundNounFilter、POSKeepFilter、LowerCaseFilterのリストtoken_filtersをAnalyzerで組み合わせます。

token_filters = [NumericReplaceFilter(), # 名詞中の漢数字を含む数字を0に置換
                 CompoundNounFilter(), # 名詞が連続する場合は複合名詞にする
                 POSKeepFilter(['名詞', '動詞', '形容詞', '副詞']), # 名詞・動詞・形容詞・副詞のみを取得する
                 LowerCaseFilter()] # 英字は小文字にする

analyzer = Analyzer(char_filters, tokenizer, token_filters)

for token in analyzer.analyze(html):
    print(token)

出力を見ると、助詞や記号などが除かれていること、連続する名詞が複合名詞となっていること("邪智"と"暴虐"で分離していましたが、"邪智暴虐"で1語になっています)、漢数字が0に置き換えられていること("十里"→"0里")が確認できます。

メロス    名詞,一般,*,*,*,*,メロス,*,*
激怒  名詞,サ変接続,*,*,*,*,激怒,ゲキド,ゲキド
し 動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
必ず  副詞,助詞類接続,*,*,*,*,必ず,カナラズ,カナラズ
邪智暴虐    名詞,複合,*,*,*,*,邪智暴虐,ジャチボウギャク,ジャチボーギャク
王 名詞,一般,*,*,*,*,王,オウ,オー
除か  動詞,自立,*,*,五段・カ行イ音便,未然形,除く,ノゾカ,ノゾカ
なら  動詞,非自立,*,*,五段・ラ行,未然形,なる,ナラ,ナラ
決意  名詞,サ変接続,*,*,*,*,決意,ケツイ,ケツイ
し 動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
・・・(中略)・・・
山越え   名詞,一般,*,*,*,*,山越え,ヤマゴエ,ヤマゴエ
0里    名詞,複合,*,*,*,*,0里,ゼロサト,ゼロサト
・・・

今回は、janome.Analyzerを使うことで、日本語の自然言語処理で面倒な前処理を、簡単に構造化できることを見ていきました。

*1:11/5 コメントを受けて修正