【Python】pandas, SciPyで疎行列の演算速度・メモリ使用量を比較

本記事では以下の内容を扱っています.

  • scipy.sparsepandas.SparseDtypeの演算速度・メモリ使用量比較

バージョン情報

バージョンの情報は以下のとおりです.

  • Python 3.10.5
  • NumPy 1.23.1
  • pandas 1.4.3
  • SciPy 1.8.1

概要

scipy.sparseは疎行列を効率的に扱うためのクラスでSciPyが提供しています.
またpandasにおいても疎行列を扱えるpandas.SparseDtypeがあります.

本記事ではこれらの演算速度やメモリ使用量を比較していきます.

関連記事

疎行列の基本的な内容については以下の記事をご覧ください.

演算速度とメモリ使用量の比較

本記事で使用するライブラリと関数は以下です.


# 本記事中で使用するライブラリ
import numpy as np
import pandas as pd
from scipy import sparse as sp

# dfとscipy.sparseのメモリ使用量を算出する関数(単位を付与する関数を含む)
def format_bytes(size):
    power = 2**10
    n = 0
    power_labels = ['B', 'KB', 'MB', 'GB', 'TB']
    while size > power and n <= len(power_labels):
        size /= power
        n += 1
    return f'{size :.3f} {power_labels[n]}'

def get_size_of_df(df):
    return format_bytes(df.memory_usage().sum())

def get_size_sparse_matrix(sp_matrix):
    return format_bytes((sp_matrix.data.nbytes + sp_matrix.indices.nbytes + sp_matrix.indptr.nbytes))

以下のコードで演算速度,メモリ使用量を確認します.
行列内の0割合を変えた1000x1000行列で演算速度とメモリ使用量を見ています.

出力結果は以下のとおりです.
scipy.sparsepandas.SparseDtypeの結果がそれぞれ出ています)

  • メモリ使用量と非0割合(density1)
  • 行列積速度

density_list = [0.0001, 0.001, 0.01, 0.1, 0.3, 0.5, 0.7, 0.9, 1.0]

# 疎行列の0でない成分の割合を変えてそれぞれ計算
for density in density_list:
    scipy_sp = sp.rand(1000, 1000, density=density, format='csr', random_state=42)
    pandas_sp = pd.DataFrame.sparse.from_spmatrix(scipy_sp)
    print(f'scipy_sp: {get_size_sparse_matrix(scipy_sp)}, density={density}')
    %timeit scipy_sp * scipy_sp
    print(f'pandas_sp: {get_size_of_df(pandas_sp)}, density={density}')
    %timeit pandas_sp @ pandas_sp
    print('')
scipy_sp: 5.082 KB, density=0.0001
39.1 µs ± 80.7 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
pandas_sp: 1.297 KB, density=0.0001
37.5 ms ± 1.45 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

scipy_sp: 15.629 KB, density=0.001
44.1 µs ± 38.3 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
pandas_sp: 11.844 KB, density=0.001
36.3 ms ± 1.66 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

scipy_sp: 121.098 KB, density=0.01
560 µs ± 3.8 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
pandas_sp: 117.312 KB, density=0.01
39.6 ms ± 3.46 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

scipy_sp: 1.148 MB, density=0.1
24.6 ms ± 30.8 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
pandas_sp: 1.145 MB, density=0.1
39.9 ms ± 1.74 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

scipy_sp: 3.437 MB, density=0.3
103 ms ± 112 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
pandas_sp: 3.433 MB, density=0.3
40.1 ms ± 1.75 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

scipy_sp: 5.726 MB, density=0.5
257 ms ± 285 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)
pandas_sp: 5.722 MB, density=0.5
43.1 ms ± 2.12 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

scipy_sp: 8.015 MB, density=0.7
487 ms ± 697 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)
pandas_sp: 8.011 MB, density=0.7
42.6 ms ± 2.47 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

scipy_sp: 10.304 MB, density=0.9
787 ms ± 1.13 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
pandas_sp: 10.300 MB, density=0.9
49.7 ms ± 1.59 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

scipy_sp: 11.448 MB, density=1.0
966 ms ± 1.6 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
pandas_sp: 11.444 MB, density=1.0
29.8 ms ± 1.1 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

for文では順に以下の演算をしています.

  1. 0でない成分の割合が異なるscipy.sparseを作成(CSR format)
  2. 作成したscipy.sparseをpandasのpandas.SparseDtypeに変換
  3. それぞれの行列積にかかる時間を計測2
  4. メモリ使用量を算出する関数でそれぞれのメモリ使用量を確認し,densityと共に出力

演算速度の結果

  • 0割合が多い(=疎な行列の)場合はscipy.sparseの方が高速.
  • 0割合が少ない(=密な行列の)場合はpandas.SparseDtypeの方が高速.
  • pandas.SparseDtype0割合と速度に明確な関係がないが0成分がないと演算速度が向上する.

メモリ使用量の結果

  • 0割合が少ない場合のメモリ効率はpandas.SparseDtypeの方がよい.
  • 0成分が多い場合は両者に大きな差はない.

なお,密行列のDataFrame(データ型float64)を作成し,演算速度とメモリ使用量を確認すると,以下のようになります.


csr = sp.rand(1000, 1000, density=1.0, format='csr', random_state=42)
normal_df = pd.DataFrame.sparse.from_spmatrix(csr).sparse.to_dense()
%timeit normal_df @ normal_df
print(get_size_of_df(normal_df))
20 ms ± 1.2 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
7.630 MB

密行列の演算速度・メモリ使用量は通常のDataFrameの方がよいですね.(当たり前ですが)

まとめ

疎行列を扱う際にはscipy.sparseを使うと高速かつメモリ効率も向上するため,基本的にはscipy.sparseを使うとよいでしょう.

一方でメモリ観点ではpandas.SparseDtypeの方がよい場合もあります.疎行列を扱う際にDataFrameをそのまま使用するケースはあまりないと思いますが,必要性が出てきた時の選択肢として知っておくと吉ですね.

疎行列と密行列を合わせて演算する場合

実際にデータを扱う際には密行列と疎行列を組み合わせることが多いと思います.
そのため,ここでは簡単な実験で演算速度とメモリ使用量を確認してみます.

準備

以下で前準備として密行列(1000x5),疎行列(1000x995)を作成します.
(後ほど行列を結合させて1000x1000行列を2パターン作成するためです)


# 乱数シードの固定
np.random.seed(42)

# DataFrameの作成
df = pd.DataFrame({
    'col_1': np.ones(1000, dtype=np.int8), 
    'col_2': np.random.rand(1000), 
    'col_3': np.ones(1000, dtype=np.float32),
    'col_4': np.random.rand(1000), 
    'col_5': np.random.rand(1000)
})
df.head(3)
col_1 col_2 col_3 col_4 col_5
0 1 0.374540 1.0 0.185133 0.261706
1 1 0.950714 1.0 0.541901 0.246979
2 1 0.731994 1.0 0.872946 0.906255

sparse_csr = sp.rand(1000, 995, density=0.005, format='csr', random_state=42)
sparse_csr
<1000x995 sparse matrix of type '<class 'numpy.float64'>'
    with 4975 stored elements in Compressed Sparse Row format>
route1: scipy.sparseで揃えた行列

route1ではscipy.sparseで1000x1000行列を作成します.


# ルート1:両方をscipy.sparseに揃えて結合

# dfをsparse matrixに変換
df_sparse = sp.csr_matrix(df)

# 変換したdfとsparse_csrを結合
route_1 = sp.hstack((df_sparse, sparse_csr), format='csr')
route_1
<1000x1000 sparse matrix of type '<class 'numpy.float64'>'
    with 9975 stored elements in Compressed Sparse Row format>
route2: pandas.SparseDtypeで揃えた行列

route2ではpandas.SparseDtypeで1000x1000行列を作成します.


# ルート2:scipy.sparse を pandas.SparseDtype に変換

# sparse_csr を pandas sparse に変換
sparse_pds = pd.DataFrame.sparse.from_spmatrix(sparse_csr)

route_2 = pd.concat([df, sparse_pds], axis=1)
route_2.shape
(1000, 1000)

結果

演算速度とメモリ使用量はそれぞれ以下のとおりになりました.


print(f'route_1: {get_size_sparse_matrix(route_1)}')
%timeit route_1 * route_1

print('')

print(f'route_2: {get_size_of_df(route_2)}')
# 行列積の計算のため,index, columnsを揃える
route_2.index = route_2.columns
%timeit route_2 @ route_2
route_1: 120.805 KB
361 µs ± 560 ns per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

route_2: 86.746 KB
40 ms ± 1.66 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

演算速度はscipy.sparse,メモリ使用量はpandas.SparseDtypeの方がいいですね.
今回は簡易的な実験ですので,実際のデータ型やデータ量によって異なることがあると思います.
しかし「まとめ」でも記載したとおり通常はscipy.sparseを使う方がよさそうです.

ひとこと

scipy.sparsepandas.SparseDtypeで色々と実験してみましたが,より巨大でデータ型が混在する行列だと結果が異なってくるかもしれません.
何らかの参考になれば幸いです.


  1. この値が小さいほど行列中に含まれる0成分が多くなります. 

  2. Jupyter環境であれば%timeitというマジックコマンドで行ごとの演算速度を計測できます.