開発者がビッグデータ分析にPythonを使う時によくやる間違い

システムの構築、新しい技術の習得、PythonやDevOpsなどに情熱を注ぐソフトウェア開発者です。現在はチューリッヒを拠点とするビッグデータのスタートアップで働いており、データ分析およびデータ管理ソリューションのためのPythonの技術を磨いています。

1 はじめに

Python は開発時間を短縮できるという点で一般的に評価の高い言語です。しかし、Pythonを使って効率よくデータ分析をするには、思わぬ落とし穴があります。動的かつオープンソースのシステムであるという特徴は、初めは開発を容易にしてくれますが、大規模システムの破綻の原因になり得ます。ライブラリが複雑で実行時間が遅く、データの完全性を考慮した設計になっていないので、開発時間の短縮どころか、すぐに時間を使い果たしてしまう可能性があるのです。

この記事ではPythonやビッグデータで作業をする時に、最も時間を無駄にしがちな事柄について説明します。そして、本当に重要なことに時間をかけるために軌道修正をする方法も提案します。本当に重要なこととは、創造力と科学的方法を駆使して、膨大かつ多様なデータから洞察力を引き出すことです。

2 間違いその1:車輪の再発明

データ分析ライブラリに関して言えば、Pythonコミュニティは非常に管理が行き届いており、機能性が豊富で広範囲にテストされています。それなのに、なぜ車輪の再発明をするのでしょう?

このようなことはプログラミングコンテスト中によく目にします。 プログラミングコンテストでは、CSVファイルで作業するためには、参加者がCSVファイルをメモリにロードする必要があります。かなりの多くの人がカスタムCSVローディング機能を書き出すのに膨大な時間を費やし、いつもクエリが遅く、変換が難しい辞書の辞書で終わっています。そうすると、データから洞察力を引き出す自分たちの能力を印象付ける時間はほとんど残されません。

既に解決している問題を解決するのに時間を費やす理由は全くありません。Googleで検索するか自分より経験豊富な開発者にデータ分析ライブラリについての助言を求めれば、ほんの数分で済むことです。

ちなみに、この記事を書いている時点で広く使われているライブラリの1つはPython Pandasです。Python Pandasは、大規模なデータセットを処理するのに便利な抽象化、ETL(抽出、変換、ロード)のための機能、優れたパフォーマンスを備えています。簡潔なデータ変換式が有効になり、異なるソースやフォーマットからのデータをロードしたり、単一化したり、保存したりする機能を提供することによって開発時間が短縮されます。

データ変換式について説明するために、Product、ItemsSoldというヘッダのついたCSVファイルがあり、人気のある上位10位までの商品を探したいと仮定しましょう。普通のPythonに合理的に実装した場合と、Python Pandasの強力な抽象化を利用して実装した場合を比較してみましょう。

2.1 生のPython

 from collections import defaultdict
header_skipped = False
sales = defaultdict(lambda: 0)
with open(filename, 'r') as f:
    for line in f:
        if not header_skipped:
           header_skipped = True
           continue
        line = line.split(",")
        product = line[0]
        num_sales = int(line[1])
        sales[product] += num_sales
top10 = sorted(sales.items(), key=lambda x:x[1], reverse=True)[:10]

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

2.2 Pandas

import pandas as pd
data = pd.read_csv(filename) # header is conveniently inferred by default
top10 = data.groupby("Product")["ItemsSold"].sum().order(ascending=False)[:10]

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

注記:普通のPythonでタスク処理を行う利点は、メモリにファイル全体をロードする必要がないということです。一方、pandasはI/Oとパフォーマンスを最適化するために、気付かない間に仕事を進めます。さらに、普通のPythonのソリューションでも、メモリ内のセールス辞書は軽量ではありません。

3 間違いその2:パフォーマンスのための調整をしない

プログラムが出力を生成するのに時間がかかりすぎると、開発者の調子や集中力が乱れてしまいます。また、プログラムが遅いと、開発者が実験に費やせる時間が制限されます。プログラムが小さなデータセットの結果を出力するのに10分かかる場合は、1日30回程度しかプログラムを調整したり実行したりできない可能性があるのです。

つまり、コードが実行されるのをぼんやり座って待っている自分に気づいたら、障害を特定する時期かもしれないということです。開発者がコードをプロファイルし、加速するのを支援する特殊なユーティリティがあります。そのほとんどは、IPython対話シェル内で機能します。

IPython内でコードをプロファイルするもっとも簡単な方法は、%timeitという特殊コマンドを使って、Pythonの文のランタイムを得ることです。より高度なツールであるラインプロファイラは、ここからダウンロードできます。 IPythonを起動したら、以下を入力してください。

%load_ext line_profiler
%lprun -f function_to_profile statement_that_invokes_the_fuction

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

次に、関数のどのラインにどれくらいの割合の実行時間が費やされているかを表すフォームを出力します。

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

ラインプロファイラを利用することで、前述したPython Pandasライブラリを使用する際の障害を自分で特定したり、実装の微調整によって10倍のスピードアップを実現したりでき、役立っています。

しかし、問題に対して最適な複雑性と実装であるアルゴリズムを既に特定している場合は、パフォーマンスのためにコードの一部をCython化すると、利益をもたらす可能性があります。前述した%timeitコマンドを利用すると、Cython化されていないバージョンとCython化されているバージョンの実行時間を比較することができます。

3.1 Cython化されていないバージョン

IPythonに以下のコードを貼り付けます。

def sum_uncythonized():
    a = 0
    for i in range(100000):
        a += i
    return a

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

3.2 Cython化されたバージョン

まだcythonmagicを持っていない場合はインストールして、IPython内に以下を入力してください。

 %load_ext cythonmagic

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

そして、以下のテキストを1つのブロックとしてコピー・アンド・ペーストします。

%%cython
def sum_cythonized():
    cdef long a = 0 # this directive defines a type for the variable
    cdef int i = 0
    for i in range(100000):
        a += i
    return a

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

そうすると、以下のような結果が示されます。

 %timeit sum_cythonized()
>>>10000 loops, best of 3: 52.3 µs per loop

 %timeit sum_uncythonized()
>>>100 loops, best of 3: 3.28 ms per loop

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

ただ型を定義するだけで100倍のスピードアップが実現します。まさにCython化の奇跡です。

4 間違いその3:時間やタイムゾーンについての理解不足

初めて時間を扱うプログラマは、エポックタイムの概念の理解に苦労するかもしれません。基本的に、エポックタイムで使われる数値は至って普通のものです。しかしその数値を日時に変換する場合には、タイムゾーンや季節(サマータイムが存在するため)を考慮しなければならないのです。Pythonでは、datetimepytzといったモジュールで変換が行えます。

C言語の時間ライブラリのように標準で用意されているPythonの時間系モジュールは、関数名の指定や異なる時間表現の変換方法などが分かりにくい場合もあります。しかし時系列フォーマットのデータは非常に一般的なものなので、こういったモジュールを正しく使うことは重要でしょう。気をつけなくてはならないのがタイムゾーンの扱いです。よくある誤解によって、下記のような文が書かれてしまうことが多くあります。

dt = datetime.fromtimestamp(utc_number)

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

タイムゾーンを初めて扱う人は、この文がUTCフォーマットの日時を返すものだと考えがちです。しかし実際は、この文はコマンドが実行されているマシンのタイムゾーンにおける日時を返すものでしかありません。つまり互換性がないということです。私はこれを、ローカルマシンと海外のマシンで同じコードを書き、あとで結果プロットを確認するとズレてしまっていた、という経験から学びました。

タイムゾーンの扱いには、datetimepytzを活用するとよいでしょう。このモジュールは、UTC時間が分かっていればローカルタイムを返してくれる上、サマータイムにも対応しています。下記で確認してみましょう。

from datetime import datetime
import pytz
ams = pytz.timezone('Europe/Amsterdam')
winter = datetime(2000, 11, 1, 10, 0, tzinfo=pytz.utc)
summer = datetime(2000, 6, 1, 10, 0, tzinfo=pytz.utc)
print summer.astimezone(ams) # CET time is +2 hours
>>>2000-06-01 12:00:00+02:00
print winter.astimezone(ams) # CEST time is +1 hour
>>>2000-11-01 11:00:00+01:00

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

しかし、このモジュールは公式にもアナウンスされている通りdatetimeとの相互運用性が完全ではないために、使い方によっては予期しない結果を返してくる場合もあります。下記は、冬のUTC時間とアムステルダムの時差を1時間としたはずが、変わってしまっている例です。

td = datetime(2000, 11, 1, 10, 0, tzinfo=pytz.utc) - datetime(2000, 11, 1, 10, 0, tzinfo=ams) 
print td.total_seconds() 
>>>1200 # 20 minutes ? (somehow pytz falls back to a long outdated timezone setting for Amsterdam)

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

つまり、Pythonに標準で用意されているモジュールで時間管理を行おうとすると、直感的に使えず不便な場合があるということです。新たな解決策が生み出されることもあるかもしれませんが、開かれたAPIは混乱を招きます。PythonにJavaのJoda-Timeのライブラリが実装される前、開発者たちはこれを注意深く扱うよう勧められていました。具体的には、メソッドが想定通りの動きをするかどうかを広範囲にテストしたり、メソッドがUTC時間とローカルマシンの時間のどちらを返すのかをきちんとチェックしたり、こまめに保存したり、可能な部分では変換にUTC時間を使ったりするなどです。

Pythonでの時間管理のような複雑な処理において直感的なAPIを提供するためのライブラリと言えば、最近ではDeloreantimes、そしてarrowが挙げられるでしょう。

5 間違いその4:重い処理や異なるスクリプトを実行するために手動の処理を行う

数十GBに及ぶデータを分析する場合には、どう最適化されていようとPythonのようなスクリプト言語だけでは十分ではありません。より速いフレームワークで重い処理(基本的なフィルタリングやスライシング)をしてから、Pythonで(より小さい)結果のデータセットを処理する開発者は数多く存在します。探索的分析にはPythonが便利だからです。

全体的なプロセスの流れとしては、以下のような例が挙げられます。まずはデータセット上で、特定のブランドの商品オーダーをフィルタリングするJavaのMapReduceジョブを起動します。それが完了したら、その結果をHDFSからローカルファイルシステムへコピーするコマンドラインを実行し、その後、最も人気のある商品や最も売り上げの高かった日を見つけるためのPythonスクリプトを実行します。これによって得られた結果ファイルは、次に実行するPythonスクリプトによって可視化もできるでしょう。

このアプローチの問題点は、手動の処理を行ってもタスクの数は減らないこと、そしてファイルをコピーするという単純な作業には下記の欠点があることです。
1. 繰り返しの処理が必要で、疲れてしまう
2. ファイルの場所や名前を誤認してしまったり、ファイルの更新を忘れてしまったりなどのヒューマンエラーが起こりやすくなる
3. ファイルのコピーは単純な作業であるとはいえ、開発者が得た最終的な結果を別の共同制作者が再現する場合には、やはり詳細なドキュメントが必須となる

解決策は“自動化”です。異なる処理や分析手段の統合を、それ自体に自力で行わせればよいのです。複雑なデータ分析パイプラインを扱うためのフレームワークは数多く存在します。Pythonを好むなら、Spotifyが公開しているluigiも使いやすいかもしれません。

luigiを使えば、異なるタイプのタスク(JavaのMapReduce、Spark、Python、bashスクリプト)をつなぎ合わせたり、自由にタスクを作ったりすることができます。タスク依存グラフや入力、出力、そして各タスクの動きを定義することもできるのです。

最後に実行したいタスクの名前(この例では最も人気のある商品の可視化)でluigiスケジューラを呼び出せば、必要なタスクが順番に(あるいは可能な場合は同時に)起動され、最終結果が導き出されるのをのんびり座って待つことができます。ワンクリックするだけでデータ分析レポートが作成されるのは気分のいいことですし、効率的です。そして、空いた時間を利用して、データの分析により多くの創造力を発揮することができます。

6 間違いその5:データ型やスキーマを把握しない

多様なデータソースを扱う場合、分析の完全性を保ち、時間内に是正措置を講じるために重要な必須条件が2つあります。データの有効性に自信を持つこと、そして有効でない場合は早めに失敗することです。このような場合、データの完全性は柔軟性に勝ります。

Pythonには型の検証のサポートが付属しておらず、実際、そうしないように設計されています。このため、最初のエラー発生後、あるいは予期しない値の生成後からしばらく経ってからコードが失敗するという状況が発生します。データの分析時には、2つの異なるデータソースを共通のカラムに結合しなければならないのに、パイプラインのある時点でそのカラムが別のタイプに暗黙的に変換(strからintなど)されたために、結合に失敗するという状況が起こり得ます。

あるいは、データセットに欠けたフィールドがあるのに、それが明らかになるのは何ステップも先、つまりそのフィールドがアクセスされた時に初めて発覚する場合もあります。そうなるとデバッグはより困難になり、数ステップを再計算する必要も出てきます。その結果、特に今回のようにビッグデータを扱う場合は、大変な時間の無駄になる可能性があるのです。

Pythonは設計上、このようなことが発生します。スーパークラスに実装されていないメソッドをサブクラスが実装しないようにしながら、インスタンス化はするのと同じです(そしてこれらのメソッドがアクセスされた場合、実行時に失敗します)。これは、abcモジュール(ここで初めて正式に紹介されました)を使用し、これらのメソッドを抽象メソッドとして装飾しなかった場合という意味です。以下はabcモジュールの機能を示す例です。

from abc import ABCMeta, abstractmethod

class MyABC:
    __metaclass__ = ABCMeta

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

カスタムの、簡潔に定義されたルールに従って早めに失敗するというこのコンセプトは、型の追跡の問題を軽減するためにも役立ちます。

基本的に、解決策は主張型プログラミングです。パイプラインの全てのステップで、生成されたデータが満たすべき事前条件と事後条件をチェックする必要があります。これには、単純なdocstringよりもコードに関する詳しいドキュメンテーションを提供するという副次的な効果もあります。これをPython風に行うには、データの変換を行う全ての関数の入力と出力のプロパティをチェックするデコレータを使用します。以下に実装例を示します。

def check_args(*types):
    def real_decorator(func):
        def wrapper(*args, **kwargs):
            for val, typ in zip(args, types):
                assert isinstance(val, typ), "Value {} is not of expected type {}".format(val, typ)
            return func(*args, **kwargs)
        return wrapper
    return real_decorator

def do_long_computation(name):
    """ dummy function """
    time.sleep(10)
    return "FruitMart"

@check_args(str, int, int)
def print_fruit(name, apples, oranges):
    store_name = do_long_computation(name) 
    fruit = apples + oranges
    print "{} who works at {} sold {} pieces of fruit".format(name, store_name, str(fruit))

print_fruit("Sally", 12, 5)
>>>Sally bought 17 pieces of fruit
print_fruit("Sally", "1", 6)
>>>AssertionError in decorator, so we don't need to wait for do_long_computation() to finish before failing in the addition

同様の記事をもっと読みたいですか? 購読の申し込みはこちらから!

注意:このソリューションでは、簡潔にするためにkwargsを使用していません。Pythonで型のチェックを行うのは邪道だと思う人は、全ての場所、全ての変数で行わなくても構いません。型の検証を行うことでメリットを得られる、プログラム内の最も重要な場所(例えば、特にコストのかかる計算の前など)を、ご自身で判断してください。

7 間違いその6:データの来歴を追跡しない

データ分析のワークフローで重要なことは、どのバージョンのデータやアルゴリズムがどの結果を生成するために使用されたかを追跡することです。コードベースやデータ入力は絶えず変化するので、これを行わないと、結果ファイルがどのように生成されたかを示す明確な記録がないため、2週間後、あるいは3カ月後などに再作成する必要が生じても、ほとんど不可能になってしまいます。また、何かデータの品質に問題が生じた場合に、原因はデータの欠陥または不足であって分析に誤りがあったためではないと明確に示すこと、あるいは合理的な疑問を残さない程度の証明をすることは大変困難になります。

「間違いその5」で説明したようにluigiフレームワークを使用することで、タスクや入力のDAG(有向非循環グラフ)が得られます。これにより、簡単なグラフアルゴリズム(この場合、シンプルな後方深さ優先探索)を使用して、特定の出力の生成に関わった全ての入力データとコードを追跡することが可能になります。未来の自分のために、この情報を出力の次のテキストファイルに保存することをプロセスに取り入れましょう。

2週間後に上司から「top10products_2014Nov12.csvは間違っているようだ。11月12日に青のウィジェットを売らなかったのは確かなのか?」と尋ねられても、top10products_2014Nov12.csv.metadataを見れば、使用された入力ファイルは外部ソースから直接提供されたSalesNov12.jsonで、そこでは青のウィジェットについて触れられていないことが確認できるので、あなたのトップ10の計算はその問題に関係ないと自信を持って結論付けることができます。

8 間違いその7:(回帰)テストをしない

データ分析パイプラインのテストは、一般のソフトウェアテストに比べていくらか注意が必要です。探索的な性質の場合には、100%確実な「正しい」答えがないことがあるからです。ただし、入力Xが出力Yを返すコードは、変更があったとしても常に同じ結果を返さなければなりません。出力が変更される場合は、改善の方向で変更されるべきで、かつ、既定の、明確に定義されたカスタム基準を満たす必要があります。

小さいデータセットでの機能性のユニットテストは有益ですが、十分ではありません。定期的に正しいサイズのアプリケーションの実データをテストすることは、特に大きな変更があった後で、何も壊れていないことを合理的に確認するための唯一の方法です。ビッグデータを扱う場合、テストは長時間実行することになるので、全てのプルリクエストに対してテストを実行する、連続統合システムを通してプロセスを自動化することが有用です。「間違いその5」と関連して、これはパイプライン全体を実行して、各要素が想定どおりに引き続き相互作用することを確認することを意味します。

9 まとめ

どんなツールでもうまく使いこなすには、結局のところ、その欠点(特に自分の状況に最も影響を与えるもの)を知る必要があります。この記事はビッグデータを実際に扱った経験に基づいており、「もっとうまくやる方法はないか」と絶えず自問した結果、書かれたものです。

最終目標に役立たないこと(例えば、CSVファイルのロードや、理解しないままdatetimeライブラリを使用しようとすることなど)に過度の時間を費やしていると気付いた場合、一歩引いてプロセスを確認し、もっと有効な方法はないか検討してみましょう。問題を解決するために既存のツールを発見したり新たに作成したりできるかもしれません(データの来歴を追跡するために私がluigiライブラリを構築したように)。

それでは健闘を祈ります。楽しいプログラミングを!

主な画像ソースmikael altemark(この記事で使用するために編集)