なぜデータの前処理は難しいのか
データ分析の業務工程のうち、半分以上は前処理で占められているという話がある。おそらく業界の人間であれば必ず一度は聞き、実際に実感する話であり、それ以外の人でも興味がある人であればこの話を聞いたことがあるだろう。
しかし実際のところなぜデータの前処理でそこまで時間を食うのか、あるいは何がそんなに難しいのかを解説されたことがほとんどない気がする1。ということで、1年ほど仕事をしてきた私見を書いてみようと思う。あくまでも個人の経験と偏見に基づいた話なのでこれが「普遍的な難しさである」と主張する気はない。しかし、なんとなく「似たようなこと」は日本中のそこかしこで起きているんじゃないかと根拠もなく思ってはいる。
この記事は個人の見解であり、筆者が所属する組織・団体などの意見を代表するものではありません2。
前提
繰り返しになるが、データの前処理で筆者がよく難しいと思うことを思いつく順に書いていこうと思う。
ただ、本論に入る前に少しだけ筆者のバックグラウンドを説明しておく。筆者は基本的にはエクセルシートやcsvファイルを受け取って、そのデータを用いて予測モデルを作ったり、先日も記事にした類似度計算をすることで リコメンドシステムを実装したりするのが仕事である。どの業務をする上でも、csvファイルから必要な列のみ取り出したり、データの形式を変えたりするなど前処理のプロセスは必ず生じる。また、画像の前処理の経験はなく、テキストデータの前処理しか経験がない。
データの不備の検知しづらさ
少し想像してみてほしい。Excelファイルに10,000人分の身長が記録されていたとする。このとき、身長が [cm] という単位で記録されるべきところ、入力者が誤って [mm] で入力してしまっている。どうやって探すだろうか?
予め「このデータには絶対に上記のような不備がある」とわかっているのであればそれを発見するのはそこまで難しい話ではない。大きい順、あるいは小さい順にソートして人間としては(cmでの数値だとしたとき)身長が高すぎる/低すぎるものを見つければいい。可視化して明らかなハズレ値を調べるというのも手だろう。
しかし、残念ながら経験上いきなりデータの不備を疑うことは稀だ。時間に追われていることも多いし、いきなり前処理のプログラムを書いてしまうだろう。データベースに登録したり、あるいは別のcsvファイルと結合したりする。プログラムは異常な値を含んでいても「回ってしまうから」だ。
時間が十分にあれば事前のチェックを時間をとってできるかもしれないが、時間がない場合は見逃されてしまうだろう。上の例では身長のみだったので外れ値を探すのは難しくなかったかもしれないが、身長、体重、年齢、血液型、住んでいる都道府県などなどデータが数十、数百あった場合。異常な値をいかにみつけるか?すべて手作業でやるしかないのなら、その処理がそのプロジェクトの成功にどれほど寄与するだろうか?
データの変更によるプログラムの変更
データその1を前処理したスクリプトで、データその2を前処理することがよくある。最初から大量のデータを扱うことは稀で、基本的には少ないデータで始めて徐々に多くしていったり、精度向上のため変数の変更、追加、削除をすることがあるからだ。
しかし、このときデータその1を処理したスクリプトを一切変更せずその2を前処理できることは極めて稀だと言っていいと思う。具体的に何が起きるかを例で示そう。
具体例#
かんたんのため小さいデータセットで書く。
foo | bar | |
---|---|---|
1 | 1 | 2 |
2 | 3 | 4 |
3 | 5 | 6 |
こんなデータがあるとして foo
列と bar
列を標準化したい3とする。さっと書くと次のようなコードになる(上のデータが data1.csv
に書き出されているものとする)。
import pandas as pd
from sklearn.preprocessing import StandardScaler
d = pd.read_csv('data1.csv')
sc = StandardScaler()
print(sc.fit_transform(d))
# 実行結果
# [[-1.34164079 -1.34164079]
# [-0.4472136 -0.4472136 ]
# [ 0.4472136 0.4472136 ]
# [ 1.34164079 1.34164079]]
なお、本来ならcsvなどに書き出すのが前処理だが、ここではその処理を書くのは冗長なので省いた。
これで前処理のコードができたわけだが、この分析には boo
列が必要だとわかり boo
列が追加された data2.csv
が送られてきたとしよう。
foo | bar | boo | |
---|---|---|---|
1 | 1 | 2 | 1 |
2 | 3 | 4 | |
3 | 5 | 6 | 1 |
4 | 7 | 8 |
これをさっきのスクリプトに投げてみる。
import pandas as pd
from sklearn.preprocessing import StandardScaler
d = pd.read_csv('data2.csv')
sc = StandardScaler()
print(sc.fit_transform(d))
# 実行結果: エラー
# ValueError: Input contains NaN, infinity or a value too large for dtype('float64').
この通りエラーになってしまう。理由は NaN
が混じっているからだ。
今はたった4行なので NaN
が混じっていることにはすぐ気づけるが、これが 10,000 行あるうちの 6,000 行目くらいに NaN
があるとたいてい気づかない。NaN
を含んでいるかどうかあらかじめチェックしておけという意見もあろうが、いつもいつも万全の状態で前処理ができるとは限らないのだ。
そして NaN
があることに気づいたとしても問題はここで終わりではない。 NaN
をどう処理するか? という問題も生じる。今回の場合、 boo
は フラグデータっぽいので NaN
は 0 で埋めればよさそうに思える。一方 NaN
は消してほしいときもあれば、平均値で補間して欲しいときもある。NaN
をどう処理するかはその時と場合による。そして上に挙げた3つをすべて試して精度がどう変わるのか調べてほしいと言われることもある。もうこの時点で前処理の方法を3種類表現しなければならない。
以上のように、入力データの変更により前処理スクリプトが動作しなくなることは日常茶飯事である。入力データのみならず、出力データをどうしたいかにより、コマンドライン引数を作って場合分けをしたりするなどの処理が生じる。
「データに合わせてコードを変える」し、「データの構造は安定しない」状態でコードを書き続ける必要がある。
アドホックな処理に陥りがち
上の例では、 NaN
が混じっていたが、他にも数値が入っているべき列に A
などの文字が入っていることがある。この場合例えば A
を含む行は削除する処理を書くのだが、データの中を見ていない人にとってはこの処理はよくわからない処理になってしまう。
「不適切な値の除去」という一般的な処理ではあるのだが、コード上ではアドホック(場当たり的)な処理として記述されることになる。もし不適切な値が5種類くらいあったら、5回アドホックな処理を書くことになる。そしてそれも「削除」と「平均で埋める」の両方試してほしいとなると…‥また考える処理が増えることとなる。
「正しさ」がわからない
最終的には CSV ファイルや JSON ファイルに書き出したり、Python の Pickle ファイルや Nnumpy の配列(.npy
)とかに書き出してファイル化することが前処理の最終目標である。が、エラーなくファイル書き出しが終われば前処理終了ではない。何をもって前処理が「正しく」終えられていると判断するか。これは簡単な話ではない。
たとえば、上の標準化済みのデータの場合、標準化後のデータを見るとその数値から何かを(人間が)読み取ることは難しい。標準化前ののデータであれば身長の列と体重の列はおそらく数字だけで区別できるだろうが、標準化後はわからなくなってしまう。
他にも、レコードの id
と対応が壊れていないか、重複している行はないか、入れ漏らしているデータはないか。前処理が正しくできたか、それはプログラムを書いた本人でさえわからないのだ。
他にも、「どう前処理をするべきか」は常に変わるということが挙げられる。以下でも少しテストの話をするが、テストは「入力と出力が決まっている」関数について書くと強い効果を発揮してくれる。しかしデータ分析案件においては、入力と出力が昨日と今日どころか、一時間前と今で変わっていたりする。「一時間前の自分」が想定した出力形式になっているコードが、「今の自分」にとってはバグになっていることがある。
テストが書きにくい
では正しさを規定すればいいということで「テストを書け」という意見が出るだろう。僕もテストを書こうと思っている。しかし、さてどうやって書けばいいのかと途方に暮れる。
わかりやすいこととしては、そもそも行数と列数が想定と違っていてはだめだと assert
する、というのはひとつの手だ。
# ※このコードは動きません。こんな雰囲気ってことです
import numpy as np
from sklearn.some_module import SomeModel
# ... X, y データ作成処理...
X = train_data
y = label_data
assert X.shape[1] == 10 # 変数の数が正しいかどうか確認
assert x.shape[0] == y.shape[0] # データの数が正しいかどうか確認
model = SomeModel()
model.fit(X, y)
print(model.predict(X), y)
こんなふうに、変数が 10 個のときに学習をさせることを想定すれば X
の列数が 10 であることを確認し、あと学習データの数と教師ラベルの数が一致しているかを事前に確認しておく。
非常に簡易ではあるが、ないよりはマシだろう。これ以上テストに力を入れるのは実は難しい。頻繁に変数の数を変える業務もあるからだ。変数の数が10個のケースと20個のケースと30個のケースで予測結果の違いを見る業務だってある。
ユニットテストとか書こうと努力したこともあるが、データの形式の変更が上記のように頻繁すぎてテストケースそのものが役に立たなくなるケースが発生し、それ以来ある程度固まるまでテストは書かないようにしている。時間の無駄になる可能性が高いためだ。
一方で、間違えやすい上に間違えると致命的なので、入力するデータの形式は正しいのかをテストすることは重要だと思っている。頻繁に形式を変更する場合、Jupyter Notebook とかを使用して処理している場合は assert
を書き、 Webシステムに関わる場合はそのフレームワークのテスト機能を使用して書いている。
だが開発中のシステムとかだと、かなり頻繁にテストを書き直す羽目になりこともある。これは僕が悪いのかもしれない。
まとめ
まとめると、以下の2つが僕の思うデータの前処理が難しい理由だ。
- データの入力・出力形式が頻繁に変わるため「正しさ」を規定することが難しい
- その結果としてテストを書いて正しい挙動をしていることを示し続けることが難しい
- データの増減などに対応した汎用的なコードを書くことは難しく、そのデータに依存したアドホックな処理を記述状態に陥りがち
この問題意識を持ち始めたのは実は働き始めてから1, 2ヶ月ほどしたときからである。なんとか改善案はないかと個人的に色々な方法を業務中に試した。しかし未だに抜本的な対策はない。
もちろん「ひとつのスクリプトとオリジナルのデータがあれば前処理が完遂する」とか、どうしてもスクリプトを分けたい場合は Makefile
などを使うことで依存関係を人間が管理しない、などの前処理以前のプログラムを書く者として当たり前のことは言える。が、「前処理の一般論」として何か大きな、革新的なアイディアは何も思いついていない。この1年間、頭の片隅で考え続けてきたはずだが。
僕の勝手な感覚だがデータの前処理が難しいことは、しばらくは変わらないのではないかと思う。早くディープラーニングとかいうやつで前処理をよしなにやってほしい。