盆暗の学習記録

データサイエンス ,エンジニアリング,ビジネスについて日々学んだことの備忘録としていく予定です。初心者であり独学なので内容には誤りが含まれる可能性が大いにあります。

データ分析時のメモリ使用量を減らす方法

最近少し覚えたことをまとめます。

(基本的にpythonのコードと共に述べていきますが、Rの場合についても少し触れていきます。)

不要なオブジェクトの削除

「使わなくなったオブジェクトを削除してメモリを開放する」という考えです。

del

例えば以下のコードを試してみます。

import gc
import psutil
import numpy as np
import sys
nrows = 500000
ncols = 100
print('データを生成します')
X = np.random.rand(nrows, ncols)
print(f'データのサイズ: {sys.getsizeof(X) / 1024**2: .1f} MB')
mem_before = psutil.virtual_memory().used
print(f'del前のメモリ使用量: {mem_before / 1024**3 :.1f} GB')
del X
mem_after = psutil.virtual_memory().used
mem_diff = mem_before - mem_after
print(f'del後のメモリ使用量: {mem_after / 1024**3 :.1f} GB ({mem_diff / 1024**2 :.1f} MB reduced)')

上のコードを実行すると、以下のような結果になります。

データを生成します
データのサイズ:  381.5 MB
del前のメモリ使用量: 11.5 GB
del後のメモリ使用量: 11.2 GB (362.4 MB reduced)

手動でのガベージコレクションは不要

delのあとに明示的にガベージコレクションgc.collect())を使う方法も考えられますが、基本的に自動でのガベージコレクションが働くのでやらなくて問題ないみたいです。

また、ガベージコレクションを行ったとしても、メモリーがOSに返されるとは限らないようです1

Rの場合

一方でRはオブジェクトを削除しただけではメモリ使用量が変わらないため、手動でのガベージコレクションが有効なようです2

rm(X)
gc(reset = TRUE)

データ型の最適化

numpyにはuint8float32int64などの細かいデータ型が用意されています。

例えば以下のようにnp.iinfo()np.finfo()のメソッドを使うと、そのデータ型でもてる最小値・最大値などの情報を見ることができます。

import numpy as np
print(np.iinfo(np.int8))
print(np.iinfo(np.int16))
print(np.iinfo(np.int32))
print(np.iinfo(np.int64))
print(np.finfo(np.float16))
print(np.finfo(np.float32))
print(np.finfo(np.float64))
Machine parameters for int8
---------------------------------------------------------------
min = -128
max = 127
---------------------------------------------------------------

Machine parameters for int16
---------------------------------------------------------------
min = -32768
max = 32767
---------------------------------------------------------------
(以下略)

8bitのint8は-128~127の範囲の値しか表現できませんが、例えば二値変数(0, 1)や月(1~12)や日(1~31)のデータなどであればこのくらいの表現力で十分ですよね。なので最適化しましょう、というのがこのアプローチの考え方です。

自動で型変換するコード

こんな感じのやつを使っています。

import pandas as pd
import numpy as np

def reduce_mem_usage(df: pd.DataFrame) -> pd.DataFrame:
    """データフレームのすべてのカラムのデータ型を最適化してメモリ使用量を抑えるやつ"""
    start_mem = df.memory_usage().sum() / 1024**2
    print(f'Memory usage of dataframe is {start_mem:.2f} MB')
    df = df.apply(_optimize_dtype, axis=0)
    end_mem = df.memory_usage().sum() / 1024**2
    print(f'Memory usage after optimization is: {end_mem:.2f} MB')
    print(f'Decreased by {100 * (start_mem - end_mem) / start_mem:.1f}%')
    return df


def _optimize_dtype(col):
    """あるカラム/変数のデータ型を最適化するやつ"""
    col_type = col.dtype
    if col_type != 'object' and col_type != 'datetime64[ns]':
        c_min = col.min()
        c_max = col.max()
        # intにできるなら変換するが、NAがあればfloatのままにする(intはNAを保持できない)
        is_int = False
        if _is_int(col):
            if col.isnull().sum() == 0:
                is_int = True
            else:
                print(f'{col.name} was not convert to int because it has NA')
        # bit数の最適化
        if is_int:
            if c_min > np.iinfo(np.int8).min and c_max < np.iinfo(np.int8).max:
                col = col.astype(np.int8)
            elif c_min > np.iinfo(np.int16).min and c_max < np.iinfo(np.int16).max:
                col = col.astype(np.int16)
            elif c_min > np.iinfo(np.int32).min and c_max < np.iinfo(np.int32).max:
                col = col.astype(np.int32)
            elif c_min > np.iinfo(np.int64).min and c_max < np.iinfo(np.int64).max:
                col = col.astype(np.int64)
        else:
            if c_min > np.finfo(np.float16).min and c_max < np.finfo(np.float16).max:
                # feather-formatがfloat16を受け入れないためfloat32にする
                col = col.astype(np.float32)
            elif c_min > np.finfo(np.float32).min and c_max < np.finfo(np.float32).max:
                col = col.astype(np.float32)
            else:
                col = col.astype(np.float64)
    return col


def _is_int(col):
    """あるカラム/変数が整数かどうかを判定する"""
    fractional, _ = np.modf(col)
    if np.sum(fractional) == 0: # 小数部がすべて0
        return True
    else:
        return False

使用例は次のような感じです。

import numpy as np
import pandas as pd
import sys

# データを生成
nrows = 500_000
ncols = 100
X = [np.random.randint(low=0, high=50, size=nrows) for x in range(ncols)]
X = pd.DataFrame(np.array(X).T)
print('データを生成 ---------------------------')
print('dtypes: ')
print(X.dtypes.value_counts().to_frame(name='rows'))
print(f'データ型変換前のオブジェクトのサイズ: {sys.getsizeof(X) / 1024**2 : .1f} MB')

print('\nデータ型を変換 -------------------------')
X = reduce_mem_usage(X)
print('dtypes: ')
print(X.dtypes.value_counts().to_frame(name='rows'))
print(f'データ型変換後のオブジェクトのサイズ: {sys.getsizeof(X) / 1024**2 : .1f} MB')
データを生成 ---------------------------
dtypes:
       rows
int64   100
データ型変換前のオブジェクトのサイズ:  381.5 MB

データ型を変換 -------------------------
Memory usage of dataframe is 381.47 MB
Memory usage after optimization is: 47.68 MB
Decreased by 87.5%
dtypes:
      rows
int8   100
データ型変換後のオブジェクトのサイズ:  47.7 MB

このコードはkaggleのkernelを元にえじさんが改造したものを自分用に改造したものです。

えじさんのものからの変更点は

  • loggerに関する部分をprintに置き換え(私がまだloggerに慣れていないため)
  • floatであっても小数点以下が全部0でNAの無いカラムはintに変換する
  • forループでなくapply()を使う(applyの方が何倍か速い3

です。

疎行列クラスを使う

ダミー変数(One Hot Encodingした列)が多いデータの場合、非ゼロ要素の情報しか保持しない疎行列用のクラスを使うことでメモリ使用量を大幅に削減できます。

pythonの場合はscipy.sparseを、Rの場合は{Matrix}パッケージを使うことで効率よく疎行列を保持できます。

import pandas as pd
import numpy as np
import sys
import scipy.sparse
nrows = 500000
ncols = 500
categories = pd.Series(np.random.randint(low=0, high=ncols, size=nrows))
X = pd.get_dummies(categories)
print(f'dtypes: {X.dtypes.value_counts().to_dict()}')
print(f'データのサイズ: {sys.getsizeof(X) / 1024**2: .1f} MB')
X_csr = scipy.sparse.csr_matrix(X, shape=X.shape)
print(f'csr_matrixのサイズ: {sys.getsizeof(X_csr): .1f} Byte')
dtypes: {dtype('uint8'): 500}
データのサイズ:  238.4 MB
csr_matrixのサイズ:  56.0 Byte

  1. アプリケーションのメモリー使用量の最適化

  2. Rでメモリを解放したい - まずは蝋の翼から。

  3. 使用例で使った50万行x100列のデータの変換を10回行って実行時間の平均をとると、applyを使う場合1.62±0.01秒、forループの場合19.668±0.292秒でした。