Pythonを使ってよく連続する文字列を検索する

前回の記事で紹介したn-gram(といっても今回使うのはユニグラムとバイグラム)の応用です。
テキストデータの中から高確率で連続して登場する単語を探索する方法を紹介します。
参考: scikit-learnで単語nグラム

コーパスとして、昔作成したライブドアニュースコーパスをデータフレームにまとめたやつを使います。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

今回の記事で使うライブラリの読み込み、データの読み込み、さらに分かち書きに使う関数の準備とそれを使った単語の形態素解析まで済ませておきます。

import re
import pandas as pd
import MeCab
from sklearn.feature_extraction.text import CountVectorizer

# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規かとアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()

# 分かち書きの中で使うオブジェクト生成
tagger = MeCab.Tagger("-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd")


def mecab_tokenizer(text):
    # テキストを分かち書きする関数を準備する
    parsed_lines = tagger.parse(text).split("\n")[:-2]
    surfaces = [l.split('\t')[0] for l in parsed_lines]
    features = [l.split('\t')[1] for l in parsed_lines]
    # 原型を取得
    bases = [f.split(',')[6] for f in features]
    # 各単語を原型に変換する
    token_list = [b if b != '*' else s for s, b in zip(surfaces, bases)]
    return " ".join(token_list)

# 分かち書き
df["tokens"] = df.text.apply(mecab_tokenizer)

さて、データの準備が整ったので本題の処理の方に移っていきましょう。
やることは簡単で、ユニグラムモデル(単語単位のBoW)とバイグラムモデル(2単語連続で学習したBoW)を作成します。そして、「単語1」の出現回数で、「単語1 単語2」の出現回数を割ることによって、「単語1」の後に「単語2」が出現する確率を求めてそれがある程度より高かったらこの2単語は連続しやすいと判断します。また逆に、「単語2」の出現回数で、「単語1 単語2」の出現回数を割って同様の判定もかけます。

そのためにまず、バイグラムとユニグラムの単語の出現回数の辞書を作成します。
それぞれモデルを作ってBoWにし、語彙と出現回数のペアの辞書へと変換します。

# モデル作成
uni_model = CountVectorizer(
        token_pattern='(?u)\\b\\w+\\b',
        ngram_range=(1, 1),
        min_df=30,
    )
bi_model = CountVectorizer(
        token_pattern='(?u)\\b\\w+\\b',
        ngram_range=(2, 2),
        min_df=30,
    )

# BoWへ変換
uni_bow = uni_model.fit_transform(df["tokens"])
bi_bow = bi_model.fit_transform(df["tokens"])

# 学習した語彙数
print(len(uni_model.get_feature_names()), len(bi_model.get_feature_names()))
# 6921 12227

# 出現回数の辞書へ変換
uni_gram_count_dict = dict(zip(
        uni_model.get_feature_names(),
        uni_bow.toarray().sum(axis=0)
    ))
bi_gram_count_dict = dict(zip(
        bi_model.get_feature_names(),
        bi_bow.toarray().sum(axis=0)
    ))

これで計算に必要な情報が揃いました。min_df は少し大きめの30にしていますが、これは利用するコーパスの大きやさかける時間、求める精度などによって調整してください。(少し大きめの値にしておかないと、特にバイグラムの語彙数が膨れ上がり、次の処理が非常に時間がかかるようになります。)

さて、これで出現回数の情報が得られたのでこれを使って「単語1」の次に来やすい「単語2」を探してみましょう。余りたくさん出てきても困るので95%以上の確率で続くなら出力するようにしたのが次のコードです。

for uni_word, uni_count in uni_gram_count_dict.items():
    # uni_word: ユニグラムモデルでカウントした単語
    # uni_count: 上記単語が出現した回数

    # 対象の単語で始まる単語ペアにマッチする正規表現
    pattern = f"^{uni_word}\\b"
    target_bi_gram = {k: v for k, v in bi_gram_count_dict.items() if re.match(pattern, k)}
    for bi_words, bi_count in target_bi_gram.items():
        # bi_words: バイグラムモデルでカウントした単語ペア
        # bi_count: 上記単語ペアが出現した回数

        if bi_count / uni_count >= 0.95:
            print(bi_words, f"{bi_count}回/{uni_count}回")

# 以下出力の先頭の方の行
"""
1677 万 73回/73回
2106 bpm 107回/107回
75m bps 68回/70回
84回 アカデミー賞 94回/96回
888 毎日 41回/42回
angrybirds 風 41回/41回
bci 3 81回/81回
blu ray 339回/339回
deji 通 614回/619回
details id 160回/162回
digi 2 322回/326回
icecream sandwich 939回/941回
kamikura digi 89回/89回
katsuosh digi 56回/56回
let s 76回/79回
-- 以下省略 -- 
"""

途中正規表現を使っていますが、これは、選択中の「単語1」に対して「単語1 単語2」という文字列に一致させるものです。^は先頭、\\b(エスケープされて実際は\b)は単語区切りにマッチします。

出力はたくさん出ますので、先頭の方を上のコード中に例示しました。
84回 アカデミー賞 94回/96回 は 「84回」って単語が96回登場し、そのうち、94回は「84回 アカデミー賞」と続いたという意味です。
1677 万 などは 1677万画素って単語の一部ですね。

続いて、ある単語の後ろではなく前に登場しやすい単語も探してみましょう。
これは、正規表現のpatternが少し違うだけです、と行きたかったのですがもう1箇所違います。re.matchが先頭マッチの探索しかしてくれないので、正規表現でマッチさせるところのメソッドがre.searchになります。

for uni_word, uni_count in uni_gram_count_dict.items():

    # 対象の単語で終わる単語ペアにマッチする正規表現
    pattern = f"\\b{uni_word}$"
    target_bi_gram = {k: v for k, v in bi_gram_count_dict.items() if re.search(pattern, k)}
    for bi_words, bi_count in target_bi_gram.items():
        if bi_count / uni_count >= 0.95:
            print(bi_words, f"{bi_count}回/{uni_count}回")

# 以下出力の先頭の方の行
"""
iphone 3gs 38回/38回
第 84回 96回/96回
成長率 888 41回/42回
msm 8960 118回/118回
with amazlet 38回/38回
の angrybirds 41回/41回
パック bci 81回/81回
2106 bpm 107回/109回
apps details 160回/162回
after effects 63回/64回
パッケージ ffp 77回/77回
wi fi 869回/882回
モバイルwi fiルーター 136回/136回
-- 以下省略 -- 
"""

こちらもうまく出力されましたね。読み解き方はは先ほどと同じです。

そもそも、なぜこのような処理を作ろうと思ったかと言うと、MeCab等で分かち書きした時に、本当は1単語なのに複数単語に分かれてしまっているようなものを効率よく検索したかったためです。

この記事のコードでは新語辞書(mecab-ipadic-neologd)を使ってるので、あからさまなものは少ないですが、デフォルトのIPA辞書を使うと、 クラウド が 「クラ」と「ウド」に分かれている例などがポロポロ見つかります。

この記事の目的は上のコードで果たしたので以下は補足です。

さて、今回の結果を辞書の改善等に使うことを考えると、ここで出力された単語ペアを結合させて放り込んでいけば良さそうに見えます。しかし、実際は話はそう単純ではありません。
例えば、「wi fi」とか、「モバイルwi fiルーター」といった単語が出てきていますが、なるほど、辞書にWiFIやモバイルWiFiルーターが含まれてないんだな、と勘違いしそうになります。

しかし実際は、Neologdを使うと、どちらも正しく形態素解析できていて、「Wi-Fi」や「モバイルWi-Fiルーター」と言う単語で出力されるんですね。いつ分かれているかと言うと、sickit-learnが学習する時に-(ハイフン)を単語境界文字として扱っているのでここで切ってしまっています。

同様の例として、「ウォルト ディズニー」などもあります。これも実はMeCabは「ウォルト・ディズニー」と一単語にしているのに、scikit-learnが「・」で勝手に区切ってます。

このほかにも、出力を見ていくと「くだける 充電」と言うのが出てきますが、世の中に「くだける充電」という単語があるのか、と考えると間違えます。
これは元のテキストを見ると「おくだけ充電」という言葉があり、これが、「お」「くだけの原型のくだける」「充電」と形態素解析されて後半の2単語がくっついて出てきたものです。

このほか、「特有 の」とか「非常 に」のように、確かによく連続するんだろうけど、これは単語として分かれるのが正常だよね、って言う例も多く確認はかなり手間なようでした。

そもそも出力が意外に多かったです。(サンプルコードでは0.95以上としましたが、本気で探すならこの閾値はもっと下げた方が良さそうです。しかしそれをやるとどんどん出力が増えます。)

本当は1単語なのに間違って分かれてしまっている単語を探す、と言う目的に対しては、思ったよりノイズが多かったのですが、それでもかなり有効な方法だと思うので同様の課題をお持ちの方は試してみてください。細かくは書いていませんが、形態素解析する段階で品詞を絞っておく(例えば名詞のみする)とか、てにをは的なワードを除いておくなど改善の余地多いので色々試すのも楽しいです。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です