mimi

Sansanの
人・組織・カルチャーを
伝えるメディア

【Techの道も一歩から】第11回「言語処理でのちょっとした前処理」

こんにちは。DSOC R&Dグループの高橋寛治です。

形態素解析や系列ラベリングの際の素性抽出などでは、いつも似たようなコードを書きがちです。

今回はその作業を減らすための備忘録として、これらのちょっとした前処理について紹介します。

形態素解析

日本語を対象にした自然言語処理における形態素解析とは、単語分割と品詞付与を指しています。

日本語は単語に分かち書きされていないため、ほとんどのタスクの前段となる非常に重要な処理です。

Pythonで日本語形態素解析を行う際には、MeCabやPure PythonのJanomeがよく使われるかと思います。

私は、MeCabのPython 3バインディングであるmecab-python3をよく使います。

シンプルなインタフェースでMeCabの形態素解析機能がPythonで利用可能です。

まずは、mecab-python3を用いて形態素解析を行い、得られた形態素列をPythonで取り扱いやすいように加工します。

こんな感じで使いたい

以下のように、形態素解析や品詞による単語のフィルタリング、カタカナ語抽出などを手軽に使いたい、と妄想を膨らませて実装を進めます。

# 形態素解析器をインポート(tokenizer.pyのTokenizerクラス)
from tokenizer import Tokenizer

# 解析インスタンス作成
tok = Tokenizer()

# 解析
morphemes = tok.tokenize("言語処理で形態素解析は重要なプロセスだ。")

# 名詞だけ取り出す
nouns = [m for m in morphemes if m.pos == "名詞"]
print(nouns)
>>> [言語, 処理, 形態素, 解析, 重要, プロセス]

# カタカナだけ取り出す
katakanas = [m for m in morphemes if m.is_katakana]
print(katakanas)
>>> [プロセス]

解析クラスと形態素クラスが必要になりそうなことが見えてきました。

形態素クラスを作る

形態素クラスを作成し、morpheme.surface のように表層形や品詞に簡単にアクセスできるようにします。

IPADicを使った場合の例です(辞書を変更する場合は、辞書の属性情報(self.features)の内容に合わせてインスタンス変数を定義します)。

class Morpheme:
    def __init__(self, node):
        self.surface = node.surface
        self.features = node.feature.split(",")

        self.pos = self.features[0]
        self.pos_s1 = self.features[1]
        self.pos_s2 = self.features[2]
        self.pos_s3 = self.features[3]
        self.conj = self.features[4]
        self.form = self.features[5]
        self.orig = self.features[6]

        if len(self.features) < 8:
            self.reading = None
            self.reading2 =None
        else:
            self.reading = self.features[7]
            self.reading2 = self.features[8]

    def __str__(self):
        return self.surface

    def __repr__(self):
        return self.__str__()

strreprは定義しておくことで、print(morpheme)した際にそれらの戻り値が表示されるようになります。

Morphemeクラスにメソッドを追加しないのであれば、以下のようなnamedtupleを利用するのもいいと思います。

IPAMorpheme = namedtuple("IPAMorpheme", "surface pos pos_s1 pos_s2 pos_s3 conj form base reading1 reading2")

Morphemeクラスとすることで、例えば以下のような属性を追加して、後段の素性抽出の際に容易に利用可能です。

ただし、一つひとつのインスタンスがたくさんのメソッドを持つことになるため、素性抽出時に判定する処理を用意したほうが軽量でいいかもしれません。

@property
def is_katakana(self):
    return regex.fullmatch(r"^\p{Katakana}+$", self.surface) is not None

上記メソッドで利用しているregexモジュールはUnicode文字プロパティー(例えば\p{Katakana})が標準モジュールreと同等のインターフェースで使うことができて非常に便利です。

形態素解析するクラス

用意した形態素クラスに形態素解析結果を代入する処理を作成します。

Tokenizerクラスを作成し、tokenizeメソッドを作成します。

import MeCab


class Tokenizer:
    def __init__(self, mecab_args):
        self.__tagger = MeCab.Tagger(mecab_args)
        self.__tagger.parse("Initialize")

    def tokenize(self, sentence):
        return [morpheme for morpheme in self.__parse_to_tag(sentence)]

    def __parse_to_tag(self, sentence):
        node = self.__tagger.parseToNode(sentence)
        node = node.next
        while node.next:
            yield Morpheme(node)
            node = node.next

MeCabインスタンス作成時の引数を渡すことで、ユーザー辞書といった情報を利用できます。

形態素解析クラスを用いてscikit-learnでTF-IDF

上記で準備した形態素解析クラスを用いて、名詞をTF-IDFで重み付けします。

scikit-learnのインターフェースとTF-IDF

scikit-learnではTfidfVectorizerでTF-IDFが提供されています。

scikit-learnはインターフェースが統一されており、fitは与えられたデータに対してモデルの学習を行い、transformfitで得たモデルを用いて入力を変換します。

predictfitで得たモデルを用いて推定を行います。

TF-IDFの場合はfitでTF-IDF計算のための語彙辞書作成やIDFの計算を行い、transformではfitで作成した重みを使い、与えられたデータに対してTF-IDF値を計算して返します。

TfidfVectorizerでTF-IDF値を取得

TfidfVectorizerは分かち書きされたテキストの入力が前提となっているため、tokenizer引数で分かち書き処理を加えます。

tokenizerはcallableを受け付けるため、以下のようなtokenizerをラップする関数を作成します。

以下の例では、分かち書きをしつつ、名詞のみを返しています。

def tokenize_with_filter(text):
    morphemes = tok.tokenize(text)
    return [m.surface for m in morphemes if m.pos == "名詞"]

作成した関数を利用してTfidfVectorizerでTF-IDFベクトル化します。

from sklearn.feature_extraction.text import TfidfVectorizer

# 例文は青空文庫から引用
docs = [
    '吾輩は猫である。名前はまだ無い。',
    '親譲の無鉄砲で小供の時から損ばかりしている。',
    '公然と名前が云えないくらいな男だから、弱虫に極まってる。'
]

# TF-IDF値を計算
vectorizer = TfidfVectorizer(tokenizer=tokenize_with_filter)
vectors = vectorizer.fit_transform(docs)

# docsのTF-IDF計算の対象となる語彙を表示
print(vectorizer.vocabulary_)
>>> {'吾輩': 3, '猫': 8, '名前': 2, '譲': 10, '無鉄砲': 7, '供': 1, '時': 6, '損': 5, '云': 0, '男': 9, '弱虫': 4}

# docsのTF-IDF値を表示
print(vectors.toarray())
>>> 

このようにTfidfVectorizerに対してtokenizerを設定することで、かなり自由度高く入力単語列を調整することが可能となります。

系列ラベリングの素性抽出

形態素列を系列と見なして、形態素列にラベル付けするという方法がよく用いられます。

例えば、固有表現抽出や品詞付与で用います。

素性として推定対象の形態素の前後数単語の表層形や品詞を取り扱うことがよくありますが、if文で書くと素性の追加や削除はコードの編集が多くなり、やや面倒です。

テンプレートを用いて簡単に調整可能にします。

テンプレートを使った素性抽出

「素性ラベル名、素性抽出関数、対象単語からの相対的な位置」をテンプレートとして素性抽出することを考えます。

例えば、対象単語より2単語前の単語の表層形を取得するテンプレートは、(“word-2”, lambda x: x.surface, -2)と定義します。

固有表現抽出を例に挙げて説明します。

以下の表は、「午前8時に東京駅で集合する。」という文を形態素解析し、IOB2(Inside-outside-beggining)タグ形式で固有表現のラベルを付与したものです。

ここで、「東京」という単語を例に素性抽出します。

素性には、対象単語と前後2単語の表層形、対象単語と前後2単語の品詞、推定済みの前2単語のIOB2タグを利用するとします。

あるラベルを学習する際の素性は、Pythonの辞書形式で表すと以下のようになります。

{
    "word-2": "時",
    "word-1": "に",
    "word": "東京",
    "word+1": "駅",
    "word+2": "で",
    "pos-2": "名詞",
    "pos-1": "助詞",
    "pos": "名詞",
    "pos+1": "名詞",
    "pos+2": "助詞",
    "iob-2": "I-TIME",
    "iob-1": "O"
}

テンプレートと素性抽出を行う関数を定義します。

# 素性抽出のための関数
word_feature = lambda x: x.surface
pos_feature = lambda x: x.pos
iob2_feature = lambda x: x.iob2

# テンプレート
templates = [
    ("word-2", word_feature, -2), ("word-1", word_feature, -1), ("word", word_feature, 0), ("word+1", word_feature, 1), ("word+2", word_feature, 2),
    ("pos-2", pos_feature, -2), ("pos-1", pos_feature, -1),("pos", pos_feature, 0), ("pos+1", pos_feature, 1), ("pos+2", pos_feature, 2),
    ("iob2-2", iob2_feature, -2),  ("iob2-1", iob2_feature, -1),
]

定義したテンプレートと系列を入力し、テンプレートに従って素性を抽出する関数を書きます。

def iter_feature(tokens, templates):
    tokens_len = len(tokens)
    for i in range(tokens_len):
        # Bias項を入れて頻出するものが優先されないようにする
        feature = {"bias": 1.0}

        # テンプレートを適用
        for label, f, target in templates:
            current = i + target
            if current < 0 or current >= tokens_len:
                continue
            # テンプレート作成対象の場合に、素性抽出関数を適用
            feature[label] = f(tokens[current])

        # BOSとEOSを素性に加える
        if i == 0:
            feature["BOS"] = True
        elif i == tokens_len - 1:
            feature["EOS"] = True

        # 素性をイテレート
        yield feature

実際に素性を抽出し、scikit-learnの学習で利用できるように変換します。

# 素性の抽出
features = []
for tokens in corpus:
    features.extend(iter_feature(tokens, templates))

from sklearn.feature_extraction import DictVectorizer

# scikit-learnで素性が入力できるように辞書形式の素性を数値列に変換
feature_vectorizer = DictVectorizer()
vector = feature_vectorizer.fit_transform(features)

テンプレートを使って推定

学習したモデルで推定する際に、テンプレートを適用して同じように素性を抽出する必要があります。

推定されたラベルを用いて次のタグを推定するようなモデルの場合を考えます。

for token, feature in zip(tokens, iter_feature(tokens, templates)):
    # 素性抽出
    vec = feature_vectorizer.transform(feature)
    # tokenのiob2変数に推定値をセットする
    token.iob2 = label_encoder.inverse_transform(model.predict(vec))[0]

label_encoderは推定するラベルをscikit-learnで利用できるように数値化したものです。

学習時に保存しておく必要があります。これにより学習済みモデルで推定した数値化されたラベルをもとのラベルに戻します。

上記例では、それぞれのiob2インスタンス変数に推定されたIOB2タグが格納されます。

よく使うことを整理してすばやく実装する

似たような処理を整理することで、作業を開始しやすくなります。

もしかするとモジュール化してGithubで管理しておくことで、pip install git+https://github.com/hogehoge_user/mymoduleにより爆速で前処理環境を整えることができるかもしれません。

次回からは、言語処理でのアルゴリズムついて紹介していきたいと思います。

執筆者プロフィール

※ 本連載の続きは、「Sansan Builders Box」で読むことができます。

過去記事

▼第10回 言語処理でのちょっとしたデータ確認やクレンジング

▼第9回 「API GatewayとAWS Lambda PythonでAPI開発」 Vol. 4:デプロイ

▼第8回 「API GatewayとAWS Lambda PythonでAPI開発」Vol. 3:エラー処理

▼第7回 「API GatewayとAWS Lambda PythonでAPI開発」Vol. 2:ローカルでの開発環境構築

▼第6回 「API GatewayとAWS Lambda PythonでAPI開発」Vol. 1:API GatewayとAWS Lambdaを知る

▼第5回 快適なシェル環境の再構築を自動化する

▼第4回 第16回情報科学技術フォーラム(FIT2017)で登壇

▼第3回 第11回テキストアナリティクス・シンポジウム

▼第2回 R&D論文読み会勉強会

▼第1回 言語処理100本ノック勉強会

text: DSOC R&Dグループ 高橋寛治