単語ごとにその単語が含まれるテキストの数を数える

前回の記事では単語の出現回数を求めましたが、
今回は単語が出現するテキストの数を算出してみます。

参考:テキストデータ中の単語の出現回数を数える

とても似ていますが、要は1つのテキストに同じ単語が何回出ても1回としてしか数えないというものです。
(tf-idfのidfの計算につかわ割れるやつですね。)

コードはとても似たものになります。


from sklearn.feature_extraction.text import CountVectorizer
from sklearn.datasets import fetch_20newsgroups
import numpy as np

# データの読み込み
remove = ('headers', 'footers', 'quotes')
twenty_news = fetch_20newsgroups(
                                subset='all',
                                remove=remove,
                            )
X = twenty_news.data
# 文章の数
print(len(X))  # 18846

# BoW化
tf_vectorizer = CountVectorizer(
                                min_df=50,
                                token_pattern='(?u)\\b\\w+\\b',  # 1文字の単語も対象とする
                               )
tf = tf_vectorizer.fit_transform(X)
# モデルが集計対象とした(min_df回以上出現した)単語の数
print(len(tf_vectorizer.get_feature_names()))  # 4476
print(tf.shape)  # (18846, 4476)

# 単語ごとにその単語が1回以上出現したドキュメント数を求める。
document_frequency = np.array((tf>0).sum(axis=0))[0]
print(document_frequency.shape)  # (4476,)

# 出現回数上位100位までの単語と出現回数を表示
for i in document_frequency.argsort()[:-100:-1]:
    print(document_frequency[i], "\t", tf_vectorizer.get_feature_names()[i])

# 以下出力
'''
15749 	 the
14108 	 to
13948 	 a
13015 	 i
12991 	 and
12809 	 of
11842 	 in
11685 	 is
11029 	 it
10974 	 that
10406 	 for
8722 	 have
8665 	 this
8596 	 on
8447 	 you
8140 	 be
-- (以下略) --
'''

ポイントはここ

np.array((tf>0).sum(axis=0))[0]

(tf>0) すると、行列の各要素が正の数ならTrue,0ならFalse になります。
それをsumすることで、Trueが1として計算されてTrueの数がもとまるというカラクリです。

テキストデータ中の単語の出現回数を数える

テキストが1個だけなら、scikit-lerarnでBoW作って終わりなので、テキストデータは複数あるものとします。
(とは言っても、やることは各テキストをBoWにして足すだけです。)
結果を全部列挙したらすごい量になるのと、数えるだけだと面白くないので、
アウトプットは出現回数が多い単語のランキングにしましょう。

今回もサンプルデータは20newsgroupsを使わせていただきます。
また、この記事では出現回数数えて、上位の単語を列挙するところををゴールとし、
機械学習にかけたりしないのでstop_wordsや、細かな前処理は省きます。

コードは以下のようになりました。


from sklearn.feature_extraction.text import CountVectorizer
from sklearn.datasets import fetch_20newsgroups
import numpy as np

# データの読み込み
remove = ('headers', 'footers', 'quotes')
twenty_news = fetch_20newsgroups(
                                subset='all',
                                remove=remove,
                            )
X = twenty_news.data
# 文章の数
print(len(X))  # 18846

# BoW化(今回は出現頻度が高い単語に関心があるので、min_dfは大きめに設定。
tf_vectorizer = CountVectorizer(
                                min_df=50,
                                token_pattern='(?u)\\b\\w+\\b',  # 1文字の単語も対象とする
                               )
tf = tf_vectorizer.fit_transform(X)
# モデルが集計対象とした(min_df回以上出現した)単語の数
print(len(tf_vectorizer.get_feature_names()))  # 4476
print(tf.shape)  # (18846, 4476)

# 単語ごとに各ドキュメントの出現回数を足し合わせる
term_frequency = np.array(tf.sum(axis=0))[0]
print(term_frequency.shape)  # (4476,)

# 出現回数上位100位までの単語と出現回数を表示
for i in term_frequency.argsort()[:-100:-1]:
    print(term_frequency[i], "\t", tf_vectorizer.get_feature_names()[i])

# 以下出力
'''
173592 	 the
86907 	 to
77192 	 of
73928 	 a
70083 	 and
59683 	 i
50898 	 in
48899 	 is
45942 	 that
38660 	 it
32302 	 for
30492 	 you
24449 	 s
23617 	 on
23519 	 this
22030 	 be
-- (以下略) --
'''

不要語の除去をやっていないので、当然のように冠詞や前置詞など超汎用的な単語が並びました。

最後の出力には以前の記事で紹介したargsortを使っています。
参考:numpyのarrayを並び替えた結果をインデックスで取得する
[:-100:-1]というスライスで、最後の100項を逆順にとっているのもポイント。

単語ごとに各ドキュメントの出現回数を足し合わせるところでは、少しだけ工夫をしました。
まず、BoWが格納されている変数tfですが、 csr_matrix というデータ型になっています。

これは疎行列というメモリを節約できて、うまく使えば計算時間も短縮できる便利なものなのですが、
一見わかりにくいので toarray()でarray型に変化してしまってる例をよく見かけます。

今回のコードでは、np.array(tf.sum(axis=0))[0]という風に、
先にsumした後に、arrayに変換して、1次元に変換しました。
こうすると、toarray()してから和を取るよりもかなり早く処理できます。

jupyterなら %timeit で手軽に時間を測れるので比較しましょう。


%timeit tf.toarray().sum(axis=0)
# 355 ms ± 5.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit np.array(tf.sum(axis=0))[0]
# 1.47 ms ± 75.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

240倍も違いますね。

列の和だけなら、 csr_matrix より csc_matrix の方が計算が早いと思うのですが、
変換にかかる時間もありますし、十分早いのでこれで良いでしょう。

scikit-learnのソースコードも確認しましたが、
csr_matrix で結果を返すようになっていて、
csc_matrix を使うオプションなどはなさそうです。

pythonで、2進法/8進法/16進法で数値を定義する

通常、pythonのコード中で数値を定義したい時は
a=123
のように10進法で数値を表します。

ただ、pythonにおいては、10進法以外で数値を定義する方法も用意されています。
特に、2,8,16進法については、それぞれ数値の前に、
0b, 0o, 0x をつけることで定義できます。


>>> 0b1010101
85
>>> 0o251
169
>>> 0xe3f8
58360

2進法、8進法の方は実用的に使ったことがないのですが、
16進法はユニコード表を読むときなどに使ったことがあります。

numpyのarrayを並び替えた結果をインデックスで取得する

データを大きい順や小さい順に並び替えることはよくある操作ですが、
その結果として、”n番目の値は何だったのか?”よりも、”何番目の要素がn番目だったのか?”を知りたいケースは地味にあります。
[241,631,362,222,2,44] のようなデータがあって、 最大値は631だってことよりも、
最大値のindexは1だ(0から始まることに注意)ってことを得たい場合ですね。

そのような時、ソートした結果をインデックスのリストで得られると便利です。
それは numpyのargsortで得られます。
numpy.argsort

通常の並べ替え結果を返してくれる sort とそれぞれ実行してみましょう。

それぞれドキュメントを見るといくつか引数が用意されていて、多次元の場合の挙動や、ソートアルゴリズムなどを指定できます。
しかし、自分にとってはその中に昇順/降順の指定が”ない”ことの方が重要です。
デフォルトで昇順にソートするので、降順がいい時はどは別途指定します。

それでは、sortとargsortを昇順降順で試します。


import numpy as np
data = [241, 631, 362, 222, 2, 44]

print(list(np.sort(data)))
# [2, 44, 222, 241, 362, 631]
print(list(np.argsort(data)))
# [4, 5, 3, 0, 2, 1]

# 降順にしたい時は[::-1]を使う
print(list(np.sort(data))[::-1])
# [631, 362, 241, 222, 44, 2]
print(list(np.argsort(data))[::-1])
# [1, 2, 0, 3, 5, 4]

うまく動いていますね。

このブログでもすでに次の記事のコード中で利用しています。
参考:pythonでトピックモデル(LDA)

magic function 自体の説明を見る

jupyter notebook の便利な機能に magic functionがあります。
% や %% で始まるあれですね。
このblog記事中でも、処理時間を測ったり、トレジャーデータに接続したりと使ってます。

ただ、個々の関数の使い方の説明はわかっても、そもそも magic functionとは何者か、
という点の理解をこれまでおろそかにしてました。

それで、何を読めばいいのかちょっと調べていたのですが、
jupyter notebookで、%magicを実行すると、magic functionの説明が出てくるようです。

結構な長文が表示されたので、この記事中に貼り付けるようなことはしないのですが、
ザーッと眺めた限りでも自分が知らなかった情報が多く含まれているようです。

よく使うものについてはこのブログでも今後取り上げていきたいですが、
取り急ぎ、%magicを実行して出てきた文章を読んでいただくと色々参考になるのではないでしょうか。

matplotlibのhist()の戻り値

matplotlibでヒストグラムを書く時、次のように、hist()を使います。(リンク先は公式ドキュメント)


import matplotlib.pyplot as plt
import numpy as np
data = np.random.randn(100) * 10
fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
ax.hist(data)
plt.show()

ついこの間まで知らなかったのですが、ドキュメントによると、ax.hist(data) は戻り値を返しています。
戻されるのは、各区間の度数と、区間の区切り位置、描写に使われているmatplotlibのオブジェクトの3つです。
これらのうち、度数と区切り位置を取れるのは可視化とその他の集計を整合性を保ちながら行うのに便利そうです。

とりあえず、戻り値を受け取れるようにしてもう一回やってみましょう。
(図は今回省略します。)


import matplotlib.pyplot as plt
import numpy as np
data = np.random.randn(100) * 10
fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
n, bins, patches = ax.hist(data)
plt.show()
print(n)
print(bins)
print(patches)

# 以下出力 (図は略)
[ 1.  5.  6. 18. 14. 16. 21. 13.  4.  2.]
[-29.53118806 -24.17059351 -18.80999896 -13.44940441  -8.08880985
  -2.7282153    2.63237925   7.9929738   13.35356835  18.7141629
  24.07475745]

ビンの数は今回何も指定していないので、デフォルトの10個です。
そのため、度数の配列nには10個の要素が含まれ、
区切り位置binsは右端左端が両方入っているので、11この要素を持つ配列になっています。

pythonの関数中でグローバル変数に代入する

前の記事のフィボナッチ数列の実装の中で、関数が呼び出された回数を数えるために少し仕掛けをいれました。
参考:pythonの関数をメモ化する

で、その中で globalと言うのを使っているのでその解説です。

ドキュメントはこちら。
global文

pythonでは、関数の中でグローバル変数を”参照”することができます。
しかし、そのままではグローバル変数に”代入”はできません。

やってみましょう。
まずは、”参照”ができることの確認。


a = 1


def sample1():
    # グローバル変数aの値を参照できるためaの値1を戻す。
    return a


sample1()  # 1

次に、代入を試してみます。ちょっとわかりにくいですが、グローバル変数bを用意し、
関数中でbに代入していますがこのbが実はローカルの変数で、
関数の外でもともと宣言されていたbに影響していないことがわかります。


b = 2


def sample2():
    # グローバル変数bへの代入はできないので、このbはローカル変数
    b = 3
    print(b)


sample2()  # 3
# 変数bは変更されていない
print(b)  # 2

これが、global文を使うと、グローバル変数への代入が可能になります。


c = 10


def sample3():
    global c
    c = 20
    print(c)


sample3()  # 20
# 関数中の変更が反映されている
print(c)  # 20

pythonの関数をメモ化する

プログラムを書いていると再帰呼び出しする関数や整数値を引数にとる関数など、同じ値を渡して何度も実行される関数があります。
そのような場合、メモリーが十分あるのであれば毎回毎回処理を実行するより結果を保存しておく方が効率的です。

それを、メモ化(Memoization)と言うそうです。
メモ化 – Wikipedia

自分で実装するのも難しくないですが、pythonでメモ化を行うには、
@functools.lru_cache
と言う便利なデコレーターが用意されています。

ちなみに、lruは、Least Recently Usedの略です。
キャッシュアルゴリズム – Wikipedia

フィボナッチ数列で実際に使ってみましょう。
また、実際に効率化できている(関数中の処理の実行回数が減っている)ことを確認するために、
その関数が何回呼び出されたのかを記録し、表示します。
また、実行時間も測りましょう。

まず、メモ化をしない例。(jupyter notebookで動作させることを前提としたコードです。)


%%time
fib_count = 0  # 関数が呼び出された回数記録用


def fib(n):
    global fib_count
    fib_count = fib_count + 1
    if n <= 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fib(n-1) + fib(n-2)


print("F(100)=", fib(30))
print("実行回数:", fib_count)

# 以下出力
F(100)= 832040
実行回数: 2692537
CPU times: user 449 ms, sys: 3.35 ms, total: 453 ms
Wall time: 456 ms

fibが何度も繰り返し実行され、非常に無駄の多い実装になっていることがわかります。

続いて、lru_cacheでメモ化した例です。


%%time
from functools import lru_cache
fib_memo_count = 0  # 関数が呼び出された回数記録用


@lru_cache(maxsize=None)
def fib_memo(n):
    global fib_memo_count
    fib_memo_count = fib_memo_count + 1
    if n <= 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fib_memo(n-1) + fib_memo(n-2)


print("F(100)=", fib_memo(30))
print("実行回数:", fib_memo_count)

# 以下出力
F(100)= 832040
実行回数: 31
CPU times: user 229 µs, sys: 120 µs, total: 349 µs
Wall time: 311 µs

こんどのfib_memoは31回しか実行されませんでした。
n=0,...,30 でそれぞれ1回ずつですね。
処理時間も桁違いに早くなっています。

pythonで集合(set)の包含関係を判定する

実は最近まで知らなかったのですが、pythonの集合(set)同士の包含関係を不等号で確認することができます。

ドキュメントはこちら
組み込み型 set(集合)型

念のため軽く用語の説明をしておくと、
集合Aと集合Bに対して、
Aの任意の要素xがBの要素でもある時、AはBの部分集合(subset)であるといいます。
それを記号で、 $A\subseteq B$ と書きます。
左右反転して、 $B\supseteq A$ と書くこともできます。
また、さらに集合Aと集合Bが等しくない時は、真部分集合(もしくは狭義の部分集合)といい、
$A\subset B$ もしくは $B\supset A$と書きます。

これをpythonではそれぞれ、 <=, >=, <, >, で計算できます。
戻り値はTrueかFalseです。
いくつかやってみましょう。


{1, 3, 5} <= {1, 2, 3, 4, 5}
# True

{1, 3, 5, 7} <= {1, 2, 3, 4, 5}
# False

{1, 3, 5} >= {1, 3, 5}
# True

{1, 3, 5} > {1, 3, 5}
# Flase

ハミング距離(Hamming distance)

二つの文字列がどのくらい異なるかを表す距離関数として、以前レーベンシュタイン距離と言うのを紹介しました。
参考:pythonで編集距離(レーベンシュタイン距離)を求める

これよりももっとシンプルな関数として、ハミング距離(Hamming distance)と言うのがあるのでその紹介です。
これは文字数が同じ二つの文字列に対して、単純に異なる文字を数えたものです。
ハミング距離 – Wikipedia

自分の場合、文字数が同じ時しか定義できないなどの理由により、レーベンシュタイン距離に比べて使う頻度は低いです。
ただ、文字数が同じでさえあれば高速に計算できます。

pythonでの実装ですが、レーベンシュタイン距離の時に使った、
python-Levenshtein に含まれています。

試しにやってみましょう。


import Levenshtein
print(Levenshtein.hamming("toned", "roses"))
# 3

この例の toned と roses では、ハミング距離もレーベンシュタイン距離もどちらも3ですが、
文字数が同じであってもこの2種類の距離の値が異なる例はあります。

例えば次のようなケースです。


text1 = 'abcdefg'
text2 = 'bcdefga'
print(Levenshtein.distance(text1, text2))
# 2
print(Levenshtein.hamming(text1, text2))
# 7