NumPyの多次元配列から値が大きいn個のインデックスを取得する

NumPyの多次元配列(といってもここで扱うのは2次元の行列ですが)の要素の中から、値が大きい順に数個取り出して、そのインデックスが欲しいことがありました。
これが多次元ではなく、1次元の配列だったら、argsort使えば一発です。
参考: numpyのarrayを並び替えた結果をインデックスで取得する

多次元になるとargsordだけではうまく動きません。
まともに実装すると、やや手間だったのですが、unravel_index というメソッドを使うとうまく書けたので紹介します。

とりあえず参考に使うデータを用意しておきます。実際はもっと巨大なデータでやったのですが、この記事では結果を確認しやすいように 5*5の行列の25個の要素から上位5要素のインデックスを取得することを目指します。
まずデータを作っておきます。

import numpy as np


np.random.seed(4)
ary = np.random.randint(10, 100, size=(5, 5))
print(ary)
"""
[[56 65 79 11 97]
 [82 60 19 68 65]
 [65 67 46 60 54]
 [48 62 13 10 65]
 [31 31 83 48 66]]
"""

このデータから値が大きい順に5このインデックスを取得することを目指します。もし、欲しいのがインデックスではなく値であればこれは簡単です。ravel() か flatten()、reshape()あたりで1次元に変形してソートするだけです。こんな感じに。

np.sort(ary.ravel())[-5:]
# array([68, 79, 82, 83, 97])

今回の用件では欲しいのは、これらの値が入ってたインデックスです。つまり、
[[1, 3], [0, 2], [1, 0], [4, 2], [0, 4]] を求めたいのです。

単純に argsortすると、行ごとにその行内でソートして結果を返してくれます。
また、axis引数にNoneを指定すると行列全体でソートしてくれるのですが結果が、ravel等で1次元に直した後にargsortしたようなイメージで帰ってきます。

print(np.argsort(ary))
"""
[[3 0 1 2 4]
 [2 1 4 3 0]
 [2 4 3 0 1]
 [3 2 0 1 4]
 [0 1 3 4 2]]
"""

print(np.argsort(ary, axis=None))
"""
array([18,  3, 17,  7, 21, 20, 12, 15, 23, 14,  0, 13,  6, 16,  9, 19,  1,
       10, 24, 11,  8,  2,  5, 22,  4])
"""

axis=Noneの方の結果(小さい順なので最後の5つ、[8, 2, 5, 22, 4]が求める5つの数のインデックス)を元の(5*5)の配列に読み替えると欲しい結果が得られることになります。
1次元でインデックスが8ってことは9番目の要素で、5*5行列の9個目の要素は[1, 4]だ、同様に2は[0, 2]、5は[1, 0] と読み替えていくわけですね。割り算と余りを使って実装できそうです。やってみると次のようになります。

for i in np.argsort(ary, axis=None)[-5:]:
    print([i//5, i%5])

"""
[1, 3]
[0, 2]
[1, 0]
[4, 2]
[0, 4]
"""

上記の変換をあらかじめ用意されたメソッドで行うのが、この記事冒頭で名前を出した unravel_index です。
ドキュメント: numpy.unravel_index — NumPy v1.22 Manual

動かしてみます。

# 以下の引数を渡していることに注意して見てください。
# np.argsort(ary, axis=None)[-5:] = [ 8  2  5 22  4]
# ary.shape = (5, 5)

print(np.unravel_index(np.argsort(ary, axis=None)[-5:], ary.shape))
# (array([1, 0, 1, 4, 0]), array([3, 2, 0, 2, 4]))

かなり近いところまで来ましたね。最終的な結果が二つのarrayからなるタプルになっていますが、それぞれから1個ずつ値を取り出してペアにすれば欲しい結果になっています。

人間が見るには微妙に扱いにくい形に見えるのですが、次のように値を取り出すのに使えます。

index_list = np.unravel_index(np.argsort(ary, axis=None)[-5:], ary.shape)
print(ary[index_list])
# [68 79 82 83 97]

今回はインデックスの方が欲しかったので、これを人が見ても見やすい形にするため、vstackして、さらに転置(.T)しても良いでしょう。

print(np.vstack(index_list).T)
"""
[[1 3]
 [0 2]
 [1 0]
 [4 2]
 [0 4]]
"""
# 以下のように書けば1行で可能。
print(np.vstack(np.unravel_index(np.argsort(ary, axis=None)[-5:], ary.shape)).T)

以上で欲しかった結果が得られました。unravel_indexがちょっと馴染みが薄かったのでまだ慣れないのですが、個人的にはこれが一番いいのではないかと思います。

大き方から順ではなく、小さい方からとたい、って時は、argsortに対するスライスの部分を少し変えるだけで対応できます。([-5:]ではなく[:5]に。)

ちなみに、argsort/ unravel_index/ vstack など少々マイナーなメソッドをいくつも使うのが嫌な場合は次のような方法もあります。多次元の配列を、インデックスと値のペアのようなデータ構造に変換してソートするやり方です。

# インデックスと値を組にしたデータにする。
lil_ary = [(i, j, ary[i, j]) for i in range(ary.shape[0]) for j in range(ary.shape[1])]
print(lil_ary[:5])  # 中身を一部確認
# [(0, 0, 56), (0, 1, 65), (0, 2, 79), (0, 3, 11), (0, 4, 97)]

# 3番目の要素(indexは2)をキーにしてソート
lil_ary.sort(key=lambda x: x[2])
# 最後の5項目を取る
print(lil_ary[-5:])
# [(1, 3, 68), (0, 2, 79), (1, 0, 82), (4, 2, 83), (0, 4, 97)]

あとはこれの、タプルのそれぞれの先頭2要素を取り出せば欲しかったインデックスです。
何をやっているのかはこちらの方がわかりやすいかも、と思っていますがこのやり方はメモリ効率などの観点でデメリットもあるので巨大なデータへの適用はお勧めしません。

gensim の phrases で用意されているスコア関数について

前回の記事から引き続き、gensim の phrases モデルの話です。
参考(前回の記事): gensimでフレーズ抽出
参考(公式ドキュメント): models.phrases – Phrase (collocation) detection — gensim
参考(Githubのソース): gensim/phrases.py at master · RaRe-Technologies/gensim · GitHub

前回の記事でこのモデルの使い方を紹介しました。このモデルは連続して出現した単語のペアに対してスコアを計算して、そのスコアが指定した閾値を超えたらその単語のペアをフレーズとして抽出しているのでした。この時に使われているスコアの計算式を具体的に見ていこうという記事です。このスコアの計算式は用意されているものが2種類と、後自分で作ったオリジナルの関数もつかえますが一旦用意されている2種類を見ていきます。

データは前回の記事と同じものを使います。形態素解析(分かち書き)まで終わったデータが、データフレームに格納されているものとします。

計算に使われる引数について

当然計算式はそれぞれ違うのですが、関数に渡される引数は共通です。(とはいえ、Pythonの実装上引数として渡されるだけで、両方が全部使っているわけではありません。)
それらの値について最初に見ておきましょう。
ドキュメント、もしくはソースコードを見ると以下のように6個の値を受け取っていますね。

def npmi_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
# 実装は略

def original_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
# 実装は略

説明と動作確認のため、適当にモデルを学習させておきます。

from gensim.models.phrases import Phrases


phrase_model = Phrases(
    sentences=df["tokens"],  # 学習するデータ
    min_count=20,  # 最低何回出現した単語および単語ペアを対象とするか。デフォルト5
    scoring='default',  # スコアリングに用いる関数。 "default", "npmi", もしくは自作の関数を指定。
    threshold=1000,  # スコアが何点を超えたらフレーズとみなすか。でフォルト10.0
)

それでは、順番に引数を見ていきます。

まず、 worda_count / wordb_count はそれぞれ1つ目、2つ目の単語が出てきた回数です。
モデルでいうと、 phrase_model.vocab から取得されます。それぞれの単語が出てきたテキスト数ではなく、出てきた回数であるところが注意が必要です。(1つのテキストに5回出てきたらそれで5と数えられます。)

bigram_count は 1つ目の単語と2つ目の単語が連続して登場した回数です。これも同様に連続して登場したテキスト数ではなく回数です。

len_vocab は vocab 辞書の要素数です。コードでいうとlen(phrase_model.vocab)。ユニグラムとバイグラムを両方数えた語彙数になります。前回の記事でいうと、{“キン”: 81, “ドル”: 111, “キン_ドル”: 81} となっていたらこれで3と数えます。 モデルを学習する時、 min_count で一定回数以下しか出現しなかった単語を足切りしますが、このvocab 作成時は足切りが行われません。1回でも出現したユニグラム、バイグラムが全部数えられるので注意が必要です。

min_count はシンプルに、モデル学習時に指定したバイグラムの最低出現回数です。ちなみに、min_count と全く同じ回数だけ出現した単語は対象に含まれるようですが、スコア関数がデフォルトのoriginal_scoreの場合は定義からスコアが絶対に0になるので結果的に抽出されません。

corpus_word_count は学習したテキストの単語の数を単純に足したものです。次のコードの二つの値が一致することからわかります。

print(phrase_model.corpus_word_count)
# 592490

print(df.tokens.apply(len).sum())
# 592490

さて、スコア計算に使われる6個の値が確認できたところで、順番にスコアの定義を見ていきましょう。

オリジナルスコア

scoring=’default’ と指定された時に採用されるのが、original_scorer です。
以下の論文で提唱されたものをベースとしています。
参考: Distributed Representations of Words and Phrases and their Compositionality

ドキュメントによると次の式で定義されています。
$$
\frac{(\text{bigram_count}-\text{min_count})\times \text{len_vocab}}{\text{worda_count}\times\text{wordb_count}}
$$

シンプルでわかりやすいですね。バイグラムが最小出現回数に比べてたくさん出現するほどスコアが伸びるようになっています。一方でユニグラムでの出現回数が増えるとスコアは下がります。出現した時は高確率で連続して出現するという場合に高くなるスコアです。
len_vobabを掛けているのはイマイチ意図が読めないですね。両単語が全く関係ないテキストをコーパスに追加していくとスコアが伸びていってしまいます。
また、min_countがスコアの計算に使われているので、min_countを変えると足切りラインだけでなくスコアも変わる点も個人的にはちょっとイマイチかなと思いました。

ソースコードも一応見ましたが、定義そのままですね。

def original_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
    denom = worda_count * wordb_count
    if denom == 0:
        return NEGATIVE_INFINITY
    return (bigram_count - min_count) / float(denom) * len_vocab

もう何回も使っている”キン_ドル”を例に、モデルが計算した結果と、定義通りの計算結果が一致することを見ておきましょう。

# モデルが実際に計算したスコア
print(phrase_model.export_phrases()["キン_ドル"])
# 1062.89667445223

# 計算に使われた値たち
print(len(phrase_model.vocab))
# 156664
print(phrase_model.vocab["キン"])
# 81
print(phrase_model.vocab["ドル"])
# 111
print(phrase_model.vocab["キン_ドル"])
# 81
print(phrase_model.min_count)
# 20

# 定義に沿った計算結果
print((81-20)*156664/(81*111))
# 1062.89667445223

 以上で、デフォルトのオリジナルスコアが確認できました。次はNPMIスコアです。

NPMIスコア

scoring=’npmi’ と指定すると使われるのがnpmi_scorerです。
以下の論文で提唱されたものをもとにしています。
参考: Normalized (Pointwise) Mutual Information in Colocation Extraction

ドキュメントによると定義は次の式です。
$$
\frac{\ln{(prob(\text{worda}, \text{wordb}) / (prob(\text{worda})\times prob(\text{wordb}))))}}{-\ln{(prob(\text{worda}, \text{wordb}))}}
$$

ここで、
$$
prob(\text{word}) = \frac{\text{word_count}}{\text{corpus_word_count}}
$$
だそうです。
僕は、だいたい想像つくけど、$prob(\text{worda}, \text{wordb})$の定義も書けや、と思いました。
ソースを読んだ限りでは、以下の定義のようです。
$$
prob(\text{worda}, \text{wordb}) = \frac{\text{bigram_count}}{\text{corpus_word_count}}
$$

分子と分母の両方にバイグラムの出現割合が登場するので直感的には少しわかりにくいですね。分母にマイナスがついているのも理解をややこしくしています。

注意点として、このスコアは-1〜1の範囲(もしくは-inf)で値を返します。モデルの閾値はデフォルト10ですが、これは明らかにoriginal_scorerを使うことを想定しているので、これを使う時は閾値も合わせて調整しなければなりません。

一応ソースコードも見ておきましょう。

def npmi_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
    if bigram_count >= min_count:
        corpus_word_count = float(corpus_word_count)
        pa = worda_count / corpus_word_count
        pb = wordb_count / corpus_word_count
        pab = bigram_count / corpus_word_count
        try:
            return log(pab / (pa * pb)) / -log(pab)
        except ValueError:  # some of the counts were zero => never a phrase
            return NEGATIVE_INFINITY
    else:
        # Return -infinity to make sure that no phrases will be created
        # from bigrams less frequent than min_count.
        return NEGATIVE_INFINITY

これも一応、モデルの計算結果と自分で計算した値を突き合わせておきます。

import numpy as np  # logを使うためにimport

# npmi をtukausetteidemoderuwogakusyuu
phrase_model = Phrases(
    sentences=df["tokens"],  # 学習するデータ
    min_count=20,  # 最低何回出現した単語および単語ペアを対象とするか。デフォルト5
    scoring='npmi',  # スコアリングに用いる関数。 "default", "npmi", もしくは自作の関数を指定。
    threshold=0.8,  # スコアが何点を超えたらフレーズとみなすか。でフォルト10.0
)

# モデルが計算したスコア
print(phrase_model.export_phrases()["キン_ドル"])
# 0.9645882456014411

# 計算に使われた値たち
print(phrase_model.vocab["キン"])
# 81
print(phrase_model.vocab["ドル"])
# 111
print(phrase_model.vocab["キン_ドル"])
# 81
print(phrase_model.corpus_word_count)
# 592490

# 定義に沿って計算
pa = 81/592490
pb = 111/592490
pab = 81/592490
print(np.log(pab / (pa * pb)) / -np.log(pab))
# 0.9645882456014411

想定通りの結果になりましたね。

これで、gensimのphrasesモデルでフレーズ抽出に使われているスコアの計算式が理解できました。

どういうスコアなのかが分かればそれをもとに閾値を適切に決めれるのでは、という期待があったのですが、正直、この計算式だからこうだみたいな目安はまだあまり見えてきませんでした。

一回 min_countと閾値を両方ともものすごく低い値にして学習し、スコアの分布を見たり、だいたい何単語くらい抽出したいのかといったことをかがえて何パターンか試して使っていくのが良いのかなと思います。

gensimでフレーズ抽出

以前このブログで、テキストデータ中のよく連続する単語を検出するコードを紹介しました。
参考: Pythonを使ってよく連続する文字列を検索する

これは単純にある単語の前か後に出現しやすい単語を探すだけのコードだったのですが、実は同じような目的のモデルでもう少しスマートなロジックで実装されたものがgensimにあることがわかったのでそれを紹介します。

なお、今回の記事は以下のバージョンのgensimで動かすことを前提とします。

$ pip freeze | grep gensim
gensim==4.1.2

僕は複数開発環境を持っているのですが、gensim==3.8.0 など、3系の環境と、今使っている4系の環境で細かい挙動が色々異なり少し手こずりました。(会社のMacで動いたコードが私物のMacで動きませんでした。)
今回紹介するモデルに限らず、githubのgensimのリポジトリのWikiにマイグレーションガイドが出てるので、gensimを頻繁に使われる方は一読をお勧めします。
参考: Migrating from Gensim 3.x to 4 · RaRe-Technologies/gensim Wiki · GitHub

前置きが長くなりました。今回紹介するのは、gensimのphrasesです。
ドキュメント: models.phrases – Phrase (collocation) detection — gensim

要は、分かち書き済みの文章から学習して、頻繁に連続する2単語をフレーズとして抽出してくれるモデルです。
「頻繁に連続する」の基準として、僕が以前の記事で紹介したような単純な割合ではなく、論文で提唱されている手法(を元にした関数)を使ってスコアリングし、そのスコアが閾値を超えたらフレーズとして判定するという手法が採られています。(デフォルトで使われるのは1個目の方です。2個目はオプションで使うことができます。)
参考:
– Distributed Representations of Words and Phrases and their Compositionality
– Normalized (Pointwise) Mutual Information in Collocation Extraction” by Gerlof Bouma

今回の記事は使い方をメインで扱いたいので、このスコアリング関数については次の記事で紹介しましょうかね。

早速使っていきましょう。まず学習させるデータの準備です。以前用意したライブドアニュースコーパスを使います。今回はお試しで、そんなたくさんのデータ量いらないので、「ITライフハック」のデータだけ使います。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

上記の記事で作ったCSVデータの読み込みと、分かち書きまでやっておきます。

import subprocess
import pandas as pd
import MeCab


# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# 今回は"it-life-hack" だけ使う
df = df[df.category=="it-life-hack"].reset_index(drop=True)
# ユニコード正規化とアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()

# 辞書のパス取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
# 今回は品詞情報も原型変換も行わないので -Owakati で実行する。
tagger = MeCab.Tagger(f"-Owakati -d {dicdir}/ipadic")


# 分かち書きした結果を配列で返す関数
def mecab_tokenizer(text):
    return tagger.parse(text).split()


# 動作確認
print(mecab_tokenizer("すもももももももものうち"))
# ['すもも', 'も', 'もも', 'も', 'もも', 'の', 'うち']

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

これで、各テキストを分かち書きして配列にしたものがdf[“tokens”]に入りました。 (scikit-learnの場合は空白区切りの文字列にしますが、gensimの場合は単語を要素とする配列でデータを用意します。)
早速Phrasesモデルを作ります。

デフォルトのスコア関数、閾値はかなり大きめの1000で学習してみます。(あまりたくさんフレーズを見つけられても、この記事ではどうせ紹介できないのでかなり絞っています。デフォルトは10なので、通常の利用では1000は大きすぎです。)

from gensim.models.phrases import Phrases


phrase_model = Phrases(
    sentences=df["tokens"],  # 学習するデータ
    min_count=20,  # 最低何回出現した単語および単語ペアを対象とするか。デフォルト5
    scoring='default',  # スコアリングに用いる関数。 "default", "npmi", もしくは自作の関数を指定。
    threshold=1000,  # スコアが何点を超えたらフレーズとみなすか。でフォルト10.0
)

さて、これで学習ができました。学習した語彙は vocab プロパティが持っています。

phrase_model.vocab
"""
{'マイクロンジャパン': 2,
 'は': 12486,
 'マイクロンジャパン_は': 1,
 '、': 21839,
 'は_、': 3765,
 '従来': 138,
 '、_従来': 54,
 'の': 25248,
 '従来_の': 68,
# 以下略
"""

単語とその単語の出現回数に加えて、アンダーバーで二つの単語を繋いだbi-gram について、その出現回数の辞書となっています。(4系のgensimではvocabが単純な辞書ですが、実は3系では違ったのですよ。gensimオリジナルの型でしたし、単語はエンコーディングされていました。)

このモデルが結果的に見つけてくれたフレーズは、export_phrases()メソッドで取得することができます。(これも3系4系で挙動が違うメソッドです。)

phrase_model.export_phrases()
"""
{'ガ_ジェット': 1793.2403746097816,
 'インター_フェイス': 1398.7857142857142,
 '池田_利夫': 1409.4339100346021,
 '岡本_奈知': 1444.5520523497917,
 'ジャム_ハウス': 1377.0331304935767,
 'エヌプラス_copyright': 1409.4339100346021,
 'all_rights': 1259.6684809500248,
 'rights_reserved': 1393.045143638851,
 '上倉_賢': 1367.7015873015873,
 'キン_ドル': 1062.89667445223,
# 以下略
"""

見つけたフレーズと、そのフレーズのスコアの辞書として結果が得られます。

あの単語と、この単語の組み合わせのスコアって何点だったのかな?と思ったら、scoringメソッドで調べられます。気になるフレーズが検出されなかったら見てみましょう。

引数は結構たくさん渡す必要あります。まずヘルプ見てみましょう。

phrase_model.scoring?
"""
Signature:
phrase_model.scoring(
    worda_count,
    wordb_count,
    bigram_count,
    len_vocab,
    min_count,
    corpus_word_count,
)
"""

試しに、「キン_ドル」で1062.89… であることを見ておきましょうかね。worda_count とかは先に述べた通り、vocabから拾ってこれます。コーパスの単語数頭の情報はモデルが持ってるのでそこからとりましょう。

phrase_model.scoring(
    phrase_model.vocab["キン"],
    phrase_model.vocab["ドル"],
    phrase_model.vocab["キン_ドル"],
    len(phrase_model.vocab),
    phrase_model.min_count,
    phrase_model.corpus_word_count
)
# 1062.89667445223

学習に使ったデータとは別のテキストから、学習済みのフレーズを検索することもできます。

sample_data = [
    ['アマゾン', 'の', '新しい', 'ガ', 'ジェット'],
    ['新しい', 'キン', 'ドル', 'を', '買い', 'まし', 'た'],
]

print(phrase_model.find_phrases(sample_data))
# {'ガ_ジェット': 1793.2403746097816, 'キン_ドル': 1062.89667445223}

また、次のようにdictのようにモデルを使うと、渡されたデータ内で見つけたフレーズを _ で連結してくれます。結果がジェネレーターで帰ってくるので、listを使って配列にしてからprintしました。これはとても便利な機能なのですが、このような辞書的な呼び出し方ではなく、transformか何か名前のあるメソッドにしてほしかったですね。

print(list(phrase_model[sample_data]))
# [['アマゾン', 'の', '新しい', 'ガ_ジェット'], ['新しい', 'キン_ドル', 'を', '買い', 'まし', 'た']]

ちなみに、 _ だと不都合がある場合は、モデル学習時に delimiter 引数で違う文字を使うこともできます。

スコア関数を変えたり、閾値を変えたらり、また、スコア関数の中でmin_countなども使われていますので、この辺の値を変えることで結果は大きく変わります。なかなか面白いので色々試してみましょう。

また、このモデルを重ねがけするように使うことで、3単語以上からなるフレーズを抽出することもできます。(閾値などの調整に少々コツが必要そうですが。)
そのような応用もあるので、なかなか面白いモデルだと思います。

scikit-learnでテキストをBoWやtfidfに変換する時に空白以外の場所で単語を区切らないようにする

以前、scikit-learnのテキスト系の前処理モデルである、CountVectorizer
TfidfVectorizer において、1文字の単語を学習結果に含める方法の記事を書きました。
参照: scikit-learnでテキストをBoWやtfidfに変換する時に一文字の単語も学習対象に含める

この記事の最後の方で、以下のようなことを書いてました。

これ以外にも “-” (ハイフン) などが単語の境界として設定されていて想定外のところで切られたり、デフォルトでアルファベットを小文字に統一する設定になっていたり(lowercase=True)と、注意する時に気をつけないといけないことが、結構あります。

このうち、ハイフンなどの空白以外のところで単語を切ってしまう問題はワードクラウドで可視化したり単語の出現頻度変化を調べたりする用途の時に非常に厄介に思っていました。機械学習の特徴量を作る時などは最終的な精度にあまり影響しないことが多いので良いのですが。

これを回避するスマートな方法を探していたのですが、それがようやく分かったので紹介します。

前の記事でも行ったように、token_patternの指定で対応を目指したわけですが、以下のコード中の\\\\b の部分をどう調整したら良いのかがわからず苦戦していました。
このbは、\\\\wと\\\\Wの境にマッチするという非常に特殊な正規表現ですが、これをどうすれば空白と\\\\wの間にマッチさせらるのかが分からなかったのです。

# 1文字の単語も学習する設定
bow_model = CountVectorizer(token_pattern='(?u)\\b\\w+\\b')

それがあるとき気づいたのですが、この\\\\b とついでに(?u)は無くても動作変わらないんですよ。

先にそれを紹介しておきます。以前作ったニュースコーパスのデータでやってみます。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

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

# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規化とアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()
# 改行コードを取り除く
df.text = df.text.str.replace("\n", " ")


# 辞書のパス取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
# 今回は品詞による絞り込みも原型への変換も行わないので -Owakati で実行する。
tagger = MeCab.Tagger(f"-Owakati -d {dicdir}/ipadic")


# 分かち書きした結果を返す。
def mecab_tokenizer(text):
    # 末尾に改行コードがつくのでstrip()で取り除く
    return tagger.parse(text).strip()


# 動作確認
print(mecab_tokenizer("すもももももももものうち"))
# すもも も もも も もも の うち

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

# 普段指定している token_pattern で学習
bow_model_1 = CountVectorizer(token_pattern='(?u)\\b\\w+\\b', min_df=10)
bow_model_1.fit(df["tokens"])
# 学習した語彙数
print(len(bow_model_1.vocabulary_))
# 15332

# # token_pattern を \\w+ だけにしたもの
bow_model_2 = CountVectorizer(token_pattern='\\w+', min_df=10)
bow_model_2.fit(df["tokens"])
# 学習した語彙数
print(len(bow_model_2.vocabulary_))
# 15332

語彙数が同じなだけでなく、学習した単語の中身も全く同じです。

# 学習した単語は一致する
set(bow_model_1.get_feature_names()) == set(bow_model_2.get_feature_names())
# True

さて、本題に戻ります。\\\\w+ でこれにマッチする単語が抜き出せるとなれば、単純にスペース以外にマッチする正規表現を書いてあげれればそれで解決です。
“[^ ]+” (ハット”^”と閉じ大括弧”]”の間にスペースを忘れないでください)や、\\\\S+ などを使えばOKです。
それぞれ試しておきます。

bow_model_3 = CountVectorizer(token_pattern='\\S+', min_df=10)
bow_model_3.fit(df["tokens"])
print(len(bow_model_3.vocabulary_))
# 15479

bow_model_4 = CountVectorizer(token_pattern="[^ ]+", min_df=10)
bow_model_4.fit(df["tokens"])
print(len(bow_model_4.vocabulary_))
# 15479

学習した語彙数が増えましたね。

「セ・リーグ」とか「ウォルト・ディズニー」が「・」で区切られずに学習されているのがわかりますよ。

print("セ・リーグ" in bow_model_1.get_feature_names())  # \\w+のモデルでは学習されていない。
# False

print("セ・リーグ" in bow_model_3.get_feature_names())  # \\S+のモデルでは学習されている
# True

print("ウォルト・ディズニー" in bow_model_1.get_feature_names())  # \\w+のモデルでは学習されていない。
# False

print("ウォルト・ディズニー" in bow_model_3.get_feature_names())  # \\S+のモデルでは学習されている
# True

これで、変なところで区切られずにMeCabで切った通りの単語で学習ができました。

ただし、この方法でもデメリットがないわけではありません。
\\\\w+ にはマッチしないが、\\\\S+にはマッチする文字がたくさん存在するのです。
要するに\\\\Wにマッチする文字たちのことです。

「?」や「!」などの感嘆符や「◆」のようなこれまで語彙に含まれなかった文字やそれを含む単語も含まれるようになります。これらが不要だという場合は、分かち書きする前か後に消しておいた方が良いでしょう。

もしくは逆に、不要に切ってほしくない文字は「- (ハイフン)」と「・(中点)」だけなんだ、みたいに特定できているのであれば、token_pattern=”[\\w\-・]+” みたいに指定するのも良いと思います。(ハイフンは正規表現の[]内で使うときは文字の範囲指定を意味する特殊文字なのでエスケープ必須なことに気をつけてください。)

いずれにせよ、結果を慎重に検証しながら使った方が良さそうです。