MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

PythonでDataFrameを省メモリに縦横変換する

マイクロアドの京都研究所で機械学習エンジニアをしている田中です。
機械学習を利用したユーザーの行動予測の研究開発などを担当しています。
今回は、データの前処理に関するお話をしたいと思います。

データの縦横変換

機械学習や統計解析をする際に頻出するデータの前処理の1つに、データの縦横変換があります。

縦横変換とは、縦持ち(またはlong型)のデータと、横持ち(またはwide型)のデータを互いに変換することを指します。

  • 縦持ちのデータの例
    image.png

  • 横持ちのデータの例
    image.png

例示したこの2つのテーブルは、表現形式こそ異なりますが、表しているデータ自体はどちらも同じものになります。

ユーザーの行動予測をする際は、webページの閲覧履歴といったユーザーの行動ログから特徴量を作成しますが、多くの場合、そのような行動ログはHadoopや並列RDB上のテーブルに縦持ち形式で保持されています。
しかし、そのデータを機械学習処理で利用する際は、1レコードが1サンプルとなるような横持ち形式に変換した上で、モデルの学習や予測処理へ投入することになります。

この記事では特に、縦持ちデータを横持ちに変換するにあたって、規模の大きなデータを対象とするときにも利用可能な方法を検討します。

縦横変換するためのpandasの関数

Pythonでプログラムを書く際は、pandasのデータフレーム形式を使ってデータを操作することが多くありますが、そのpandasには縦横変換のための関数も整備されています。

横持ちへの変換は pivot(), pivot_table() 1
縦持ちへの変換は melt(), wide_to_long() 2
などがあります。

縦持ちから横持ちへ変換すると、テーブルのカラム数が大きくなりやすいため、変換後の(横持ち形式の)データがメモリを大量に消費してしまう可能性があります。また、マシンスペックによってはメモリが足らず変換自体ができないこともありえます。 そのため、特に大規模データを横持ちに変換する際には少し注意が必要になります。

横持ちに変換したデータがいわゆるスパースな行列(大半のセルが0である行列)である場合、 横持ち変換処理の出力を、pandas.DataFrameではなくpandas.SparseDataFrameとすることで、メモリを大幅に節約することができます。

しかしながら、上述したpandasのpivot()pivot_table()には、縦持ちのデータを横持ちのSparseDataFrameとして直接出力する(=DataFrame形式を経由せずに省メモリに変換する)機能は実装されていないようです。

よって、このような変換を実現するためには別の方法を考える必要がありそうです。

省メモリに縦横変換する

サンプルデータの準備

ここからは、具体的なプログラムを見ながら検討していきましょう。 最初に以降のプログラムで利用するライブラリをロードしておきます。

import itertools
import random
import numpy as np
import pandas as pd
import string
from scipy.sparse import csr_matrix

np.random.seed(990)

まず、以下のようにして縦持ちのサンプルデータを作成します。

## サンプルデータ(縦持ち)の作成
urls = ['http://{}.com'.format(letter*3) for letter in string.ascii_lowercase]
user_ids = ['user{}'.format(format(num, '03')) for num in range(100)]

df = pd.DataFrame(list(itertools.product(user_ids,urls)) , columns=['user_id','url'])
df['access'] = np.random.randint(1, 20, len(df)).tolist()

df = df.sample(frac=0.05).reset_index(drop=True)

df.head(10)

image.png

そして、この縦持ちのデータを[行:ユーザーID] × [列:URL] の横持ち形式に変換する事を考えます。pivot_tableを利用して最終的に得たい横持ちデータを確認しておきましょう。

# サンプルデータを横持ち変換
df_wide = df.pivot_table(index='user_id', columns='url', values='access', aggfunc=np.sum, fill_value=0)
df_wide.head()

image.png URLのような一般にカーディナリティの高いデータをカラムに展開する場合、カラム数が爆発的に増えるため、メモリが足らなくなることが往々にして起こりうる、というわけです。

上で示した横持ちデータは、pd.pivot_table() を利用して作成することも可能ですが、その場合返り値が DataFrame クラスのデータフレームになるのは上述したとおりです。 この場合、各セルの値は大半が0で占められていますが、そのすべての0がメモリを消費しており、これではメモリ効率がよくありません。

サンプルデータに対するpivot_tableの返り値を確認すると、以下のように20.5 KBのメモリを消費しています。

In [1]: df_wide.info(memory_usage='deep')
Out[1]: 
<class 'pandas.core.frame.DataFrame'>
Index: 77 entries, user000 to user099
Data columns (total 26 columns):
http://aaa.com    77 non-null int64
...<省略>...
http://zzz.com    77 non-null int64
dtypes: int64(26)
memory usage: 20.5 KB

一方、この返り値をSparseDataFrameに変換すると、メモリの消費量を5.8 KBにまで節約できることがわかります。

In [1]: df_wide.to_sparse(fill_value=0).info(memory_usage='deep')
Out[1]: 
<class 'pandas.core.sparse.frame.SparseDataFrame'>
Index: 77 entries, user000 to user099
Data columns (total 26 columns):
http://aaa.com    77 non-null int64
...<省略>...
http://zzz.com    77 non-null int64
dtypes: int64(26)
memory usage: 5.8 KB

to_sparse()を用いることでメモリ消費の少ないSparseDataFrameに変換することはできますが、規模が大きなデータを扱おうとする場合、スパース変換を行う対象であるDataFrameを作成しようとする時点でメモリが足らずに失敗してしまう事が多くあります。 よって、縦持ちのデータから直接横持ちのSparseDataFrameを得る方法が必要になる場面が出てきます。

pandas.Categoricalの活用

それでは、pivot_tableを使わずに(=DataFrameを経由せずに)、横持ちのSparseDataFrameを作成してみましょう。 以下のコードはこちらのページを参考にしました。

まず縦持ちのデータフレームの中から、横持ちに展開する際に利用するカラム(user_id, url)に対してcategory型の変数を作成します。pandasのcategoricalデータの詳細はドキュメントに記載されていますが、R言語のfactor型と似た挙動をします。

user_id_categorical = pd.api.types.CategoricalDtype(categories=sorted(df.user_id.unique()), ordered=True)
url_categorical = pd.api.types.CategoricalDtype(categories=sorted(df.url.unique()), ordered=True)

ここでは、まず sorted(df.user_id.unique()) によって、データフレームのuser_idカラムからユニークな値を配列として取得し、それをアルファベット順に並び替えたリストを得ます。

そして並び替えたuser_idのリストを利用して CategoricalDtype クラスのインスタンスを作成します。これは、category型変数を作成する際に値の順序や取りうる値の範囲を制御するためのオブジェクトです3ordered=Trueとすることで、リスト内のuser_idに対してその順番通りにインデックス番号(整数値)が振られます。

row = df.user_id.astype(user_id_categorical).cat.codes
col = df.url.astype(url_categorical).cat.codes

ここでは、上記で作成したuser_idのCategoricalDtypeのインスタンスを利用して、データフレームのuser_idカラムの値をcategory型へと変換した結果を得ます。そして、category型データのアクセサであるcat 4 を通じてcodeメソッドを呼び、データフレームのuser_idカラムの各値に対応するインデックス番号(=user_idカテゴリの何番目の値なのか)を得ます。

同様の処理をurlカラムに関しても行います。

scipy.sparseの疎行列クラスの活用

さて、ここまでの前処理で得られた変数を使って、縦持ちのデータフレームを横持ちに変換した疎行列(csr_matrix)を作成します。

csr_matrixのインスタンスを作成する方法はいくつかありますが、ここでは以下の方法を利用します5
csr_matrix((data, (row_ind, col_ind)), [shape=(M, N)])

  • data: 横持ちにした行列の各セルに配置するデータ
  • row_ind: データを横持ちの行列に配置する時の行方向のインデックス
  • col_ind: データを横持ちの行列に配置する時の列方向のインデックス
  • M: 横持ちにした行列の行方向の大きさ
  • N: 横持ちにした行列の列方向の大きさ

この定義に合うように、上で作成した変数を当てはめていきましょう。

sparse_matrix = csr_matrix((df["access"], (row, col)), \
                           shape=(user_id_categorical.categories.size, \
                                  url_categorical.categories.size))
In [1]: print(sparse_matrix.todense()[0:3])
Out[1]:
[[ 0  0  0  0  9  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]
 [15  0  0  0  0 10  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]
 [ 0  0  0 14  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0 13  0  0  0  0  0 11]
 [ 0  0  0  0  0  0  0  0  0 18  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]
 [ 6  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]]

これで縦持ちのデータフレームを横持ち変換した行列の完成です。 最後に、この行列を利用してデータフレームを作成します。indexとcolumnsには縦持ちから展開する際に使ったキー(user_idとurl)のカテゴリ変数をそれぞれ指定します。

df_pivot = pd.SparseDataFrame(sparse_matrix, \
                         index = user_id_categorical.categories, \
                         columns = url_categorical.categories, \
                         default_fill_value = 0, \
                         dtype = 'int')
In [1]: df_pivot.head(10).to_dense()
Out[1]:

image.png

ここで得られたデータフレームがpivot_table()による変換結果と一致することを確認しておきましょう。

In [1]: df_wide.equals(df_pivot.to_dense())
Out[1]: True

めでたしめでたし。これで正しく横持ちへの変換ができたようです。
メモリもしっかり節約できています。

In [1]: df_pivot.info(memory_usage='deep')
Out[1]:
<class 'pandas.core.sparse.frame.SparseDataFrame'>
Index: 77 entries, user000 to user099
Data columns (total 26 columns):
http://aaa.com    77 non-null int64
...<省略>...
http://zzz.com    77 non-null int64
dtypes: int64(26)
memory usage: 8.3 KB
In [1]: df_wide.info(memory_usage='deep')
Out[1]:
<class 'pandas.core.frame.DataFrame'>
Index: 77 entries, user000 to user099
Data columns (total 26 columns):
http://aaa.com    77 non-null int64
...<省略>...
http://zzz.com    77 non-null int64
dtypes: int64(26)
memory usage: 20.5 KB

さいごに

この記事では、pandasのpivot_table()などの関数を使わずに縦横変換する方法を検討しました。 pivot_table()が出力する横持ちのデータは、素朴に作るとメモリ消費量が大きくなりやすいため、スパースな行列やデーターフレームとして変換結果を得る方法が必要です。しかし、現状のpandasにはそのような方法が用意されていません。

そこで縦持ちのデータを省メモリに横持ちに変換する方法として、pandas.categoricalとscipy.sparseを活用した方法を見てきました。この方法では、処理の途中で横持ちの通常のデータフレームや密行列を経由しないため、メモリを大量に消費することなく横持ち変換した結果を得ることができました。

データの前処理をする際の参考になれば幸いです。

参考

https://stackoverflow.com/questions/31661604/efficiently-create-sparse-pivot-tables-in-pandas