け日記

SIerから転職したWebアプリエンジニアが最近のITのキャッチアップに四苦八苦するブログ

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 コメントを受けて修正