Pythonの文字列型strとNumpyの文字列型numpy.unicode_のメモリ使用効率の比較

Numpyといえば実数を使った数値計算をするライブラリの印象が強いと思いますが、Numpyはintやfloatの配列の他にstrの配列を作ることができます。Numpyでintやfloatの配列を作る場合のメモリ効率の良さは言わずもがなですが、strの配列の場合はどの程度の効率になるのか気になったため、Pythonのビルトインの文字列型であるstrと、Numpyの文字列型であるnp.unicode_のメモリ効率を比較してみました。

比較方法

strとnp.unicode_のそれぞれの配列の作成にあたっては、データとしてアメリカ英語のコーパスであるブラウンコーパス(Brown Corpus)内の単語を使用しました。ブラウンコーパスのデータはNLTK(Natural Language Toolkit)経由で取得しました。ブラウンコーパス内の単語の内訳はざっと以下のようになっています。

from nltk.corpus import brown

words = list(brown.words())
str_ = ''.join(words)
print(len(words), 'words,', len(str_), 'chars')

d = dict()
for w in words:
    if len(w) in d:
        d[len(w)].append(w)
    else:
        d[len(w)] = [w]
    
for k in sorted(d.keys()):
    print('Number of {:>2}-letter words: {:>6}'.format(k, len(d[k])))
1161192 words, 4965882 chars
Number of  1-letter words: 158011
Number of  2-letter words: 194182
Number of  3-letter words: 214199
Number of  4-letter words: 160575
Number of  5-letter words: 109803
Number of  6-letter words:  85639
Number of  7-letter words:  78200
Number of  8-letter words:  56996
Number of  9-letter words:  40801
Number of 10-letter words:  27935
Number of 11-letter words:  15995
Number of 12-letter words:   9217
Number of 13-letter words:   4932
Number of 14-letter words:   2590
Number of 15-letter words:    956
Number of 16-letter words:    436
Number of 17-letter words:    248
Number of 18-letter words:    209
Number of 19-letter words:    101
Number of 20-letter words:     48
Number of 21-letter words:     47
Number of 22-letter words:     27
Number of 23-letter words:     10
Number of 24-letter words:      6
Number of 25-letter words:     11
Number of 26-letter words:      3
Number of 27-letter words:      6
Number of 28-letter words:      3
Number of 29-letter words:      1
Number of 30-letter words:      4
Number of 33-letter words:      1

コーパス内の単語の文字数は1文字から33文字までと結構幅があります。比較実験はブラウンコーパス内のすべての単語、1文字の単語のみ、2文字の単語のみ、3文字の単語のみを使う場合の4種類のデータを使い分けます。

Rank0(次元数0)の配列を作成した場合の性能

まずはRank0の配列、もとい単なる文字列をstrとnp.unicode_それぞれで作成した場合のメモリ効率を比較してみました。データはブラウンコーパス内のすべての単語を1つに連結した文字列です。申し訳程度にstrとnp.unicode_両者の文字列関数findの実行時間も計測しています。

import sys
import time

import numpy as np

def py_str_vs_np_str_rank_0(words, str_):

    print('Python str: {:>10} bytes'.format(sys.getsizeof(str_)))

    np_str = np.array(str_, dtype=np.unicode_)
    print('Numpy str:  {:>10} bytes'.format(sys.getsizeof(np_str)))
    
    start = time.time()
    for w in words[:1000]:
        _ = str_.find(w)
    print('Python find: {:>10.5f} sec'.format(time.time() - start))
    
    start = time.time()
    for w in words[:1000]:
        _ = np.char.find(np_str, w)
    print('Numpy find:  {:>10.5f} sec'.format(time.time() - start))

print('Rank0')
py_str_vs_np_str_rank_0(words, str_)
Rank0
Python str:    4965931 bytes
Numpy str:    19863608 bytes
Python find:    0.00112 sec
Numpy find:    12.88793 sec

結果を見ると、strに比べてnp.unicode_はメモリは食うわ文字列関数は遅いわでいいとこなしです。とはいっても、Numpyを使ってわざわざ単なる文字列を作る必要性がないのであくまで参考程度にどうぞ。

Rank1(次元数1)の配列を作成した場合の性能

次はstrとnp.unicode_それぞれで次元数1の配列を作成してメモリ効率を比較してみました。厳密にはstrのlistとdtypeがnp.unicode_のnp.ndarrayです。データはブラウンコーパス内のすべての単語です。

def py_str_vs_np_str_rank_1(words):

    print('Python str: {:>10} bytes'.format(sys.getsizeof(words)))
    
    np_words = np.array(words, dtype=np.unicode_)
    print('Numpy str:  {:>10} bytes, dtype={}'.format(
        sys.getsizeof(np_words), np_words.dtype))

print('Rank1')
py_str_vs_np_str_rank_1(words)
Rank1
Python str:   10450840 bytes
Numpy str:   153277440 bytes, dtype=<U33

strの配列のほうが約15倍効率が良い結果になりました。配列に入れる単語の文字数が疎らである場合は、普通にstrを使ったほうがよさそうです。

1、2、3文字の単語のみでそれぞれRank1の配列を作成した場合の性能

今度は、配列に入れる単語の文字数が固定長の場合ではメモリ効率がどのようになるか比較してみました。データは、ブラウンコーパス内の1文字の単語のみ、2文字の単語のみ、3文字の単語のみの場合の3種類です。それぞれの実験結果は以下の通りです。

for i in range(1, 4):
    print('Rank1({}-letter words)'.format(i))
        py_str_vs_np_str_rank_1(d[i])
Rank1(1-letter words)
Python str:    1347480 bytes
Numpy str:      632140 bytes, dtype=<U1
Rank1(2-letter words)
Python str:    1705504 bytes
Numpy str:     1553552 bytes, dtype=<U2
Rank1(3-letter words)
Python str:    1918736 bytes
Numpy str:     2570484 bytes, dtype=<U3

ブラウンコーパスの3文字の単語のみを与えた場合はnp.unicode_が依然としてstrに劣る結果ですが、1文字の単語のみ、2文字の単語のみを与えた場合はnp.unicode_がstrのメモリ効率を上回りました。1文字の単語のみを与えた場合に至ってはstrの半分以下のサイズに収まっています。

Rank2(次元数2)の配列を作成した場合の性能

最後に、Rank2の配列を作成した場合です。まずコーパス内のすべての単語を与えて、且つshape[1]が10または100の場合です。配列の形状を指定する都合上、端数となる単語が除外されていることに注意してください。この影響でnp.unicode_のdtypeの値がRank1での’U33’から変化しています。

def py_str_vs_np_str_rank_2(words, shape1):

    shape0 = len(words) // shape1
    print('shape=({}, {})'.format(shape0, shape1))
    
    rank2_words = list()
    for i in range(0, shape0):
        rank2_words.append(words[i:i+shape1])
    print('Python str: {:>10} bytes'.format(
        sys.getsizeof(rank2_words) + \
        sum([sys.getsizeof(e) for e in rank2_words])))
    
    np_words = np.array(rank2_words, dtype=np.unicode_)
    print('Numpy str:  {:>10} bytes, dtype={}'.format(
        sys.getsizeof(np_words), np_words.dtype))

print('Rank2')
py_str_vs_np_str_rank_2(words, 100)
print('Rank2')
py_str_vs_np_str_rank_2(words, 10)
Rank2
shape=(11611, 100)
Python str:   10130528 bytes
Numpy str:    92888112 bytes, dtype=<U20
Rank2
shape=(116119, 10)
Python str:   17764696 bytes
Numpy str:   130053392 bytes, dtype=<U28

単語の数は減っていますが、入れ子になっているlistのオーバーヘッドの影響でstrはサイズが大きくなりました。一方でnp.unicode_はRank1のときよりは小さくなりましたが、それでもstrよりは遥かに大きいサイズです。

1、2、3文字の単語のみでそれぞれRank2の配列を作成した場合の性能

今度はRank1の場合と同じく、配列に与えるデータを1文字の単語のみ、2文字の単語のみ、3文字の単語のみの3種類にしてそれぞれRank2の配列を作成しました。shape[1]は先程と同一です。

for i in range(1, 4):
    print('Rank2({}-letter words)'.format(i))
        py_str_vs_np_str_rank_2(d[i], 100)

for i in range(1, 4):
    print('Rank2({}-letter words)'.format(i))
        py_str_vs_np_str_rank_2(d[i], 10)
Rank2(1-letter words)
shape=(1580, 100)
Python str:    1378128 bytes
Numpy str:      632112 bytes, dtype=<U1
Rank2(2-letter words)
shape=(1941, 100)
Python str:    1693584 bytes
Numpy str:     1552912 bytes, dtype=<U2
Rank2(3-letter words)
shape=(2141, 100)
Python str:    1868496 bytes
Numpy str:     2569312 bytes, dtype=<U3
Rank2(1-letter words)
shape=(15801, 10)
Python str:    2415928 bytes
Numpy str:      632152 bytes, dtype=<U1
Rank2(2-letter words)
shape=(19418, 10)
Python str:    2954392 bytes
Numpy str:     1553552 bytes, dtype=<U2
Rank2(3-letter words)
shape=(21419, 10)
Python str:    3262360 bytes
Numpy str:     2570392 bytes, dtype=<U3

shape[1]=100の場合はRank1の結果とさほど差がありませんが、shape[1]=10の場合は3文字の単語のみを与えたときのnp.unicode_の性能がstrを上回るようになりました。str型のオーバーヘッドに加えて、入れ子になる多数のlist型のオーバーヘッドでstrの効率が落ちた影響と考えられます。

これらの結果から、配列に入れる文字列の文字列長が小さい、または次元数が2以上で内包する配列の数が多い場合(あるいはその両方)はnp.unicode_がstrの性能を上回ることが窺えます。ごく小さい大量の文字列を扱うケースや、大量の配列を伴って文字列を扱わなければならないケースであれば、Numpyは文字列の取り扱いでも活躍してくれそうです。

参考リンク

Share - この記事をシェアする