どの犯罪を犯したのかを分類する(Kaggle体験記)

はじめに

データ分析初心者であるので、kaggleを通して学んだデータ解析の手法をまとめるためにかきます。初学者にもわかるようになるべく丁寧に説明します。

犯罪の分類

Kaggleとは、データ分析のコンペティションサイトです。多くのデータ分析者がデータに基づいてモデルを生成し、未知を予測をします。kaggleでよく話題にされる初学者向けの分析は、タイタニック号の乗船者情報による生存可否の予測人が書いた数字画像データから数字を分類することなどです。今回は、San Fransico Crime Classification(サンフランシスコにおける犯罪者の罪状の分類)を取り組みます。

データセットを見る

学習データの大きさは、22.09MBで約87万のレコード数になります。

$ wc -l train.csv
878050 train.csv

"Dates"(犯罪の発生日)、"Category"(罪状)、"Descript"(罪状の詳細)、"DayOfWeek"(曜日)、"PdDistrict"(警察署の名前)、"Resolution"(事件の解決方法)、"Address"(犯罪が発生した通り)、"X"(経度)、"Y"(緯度)の9種類の変数で構成されています。その中の"Category"は、今回予測すべき値です。この課題のスコアの基準は、各"Category"に割り当てられて正解の確率値と予測した確率値の対数誤差で計算されています。そのため、例えば、ある犯罪者の"Category"は放火が0.12の確率で、続いて麻薬取引が0.05の確率で...といった形で出力しなければいけません。

犯罪情報を分類をするには?

訓練データとその正解ラベルが事前に与えられているため、この問題を教師あり学習で解きます。今回は、この問題を解く手法としてランダムフォレストを採用します。数学的に難しい実装部分はライブラリに依存するため、考える必要はありません。分類器に入れるデータは数値データでなければいけないため、特徴量をどのようにして処理するかがポイントです。また、今回はkaggleに置かれたカーネル*1を参考にしたため、よく似た形になってしまいましたが、随所調べてまとめました。この後に実行する手順を簡単にまとめると、

  1. データを前処理
  2. 分類器のパラメータを指定(各分類器の設定)
  3. データを学習(fitメソッド)
  4. 予測(predictメソッド)

となる。

データセットの処理方法を考える

分類器に入れるデータを数値データに変換していくことを念頭に入れながら、データの前処理を考えていきます。

Dates 犯罪の発生日 "yyyy/MM/dd hh:mm:ss"のフォーマットを年・月・日・時のカテゴリカルデータに変換
Category 罪状 目的変数であるため除外
Descript 罪状の詳細 内容が細かすぎるため、使用不可
DayOfWeek 曜日 日月...土、とカテゴリカルデータに変換
PdDistrict 警察署の名前 種類がかなり限られているためカテゴリカルデータに変換
Resolution 事件の解決方法 Null値が多すぎるため、使用不可
Address 犯罪が発生した場所 種類がかなり限られているためカテゴリカルデータに変換
X 経度 数値データとしてそのまま使用
Y 緯度 数値データとしてそのまま使用

まず、"Descript"は、記述内容が多岐にわたるため、自然言語処理を含めないと処理ができないと考え、このデータを使用しないことにしました。"Resolution"は、Noneの値が多すぎるため、このデータを使用することは難しいと判断します。"Dates"と"DayOfWeek"は、カテゴリカルデータとして扱うため、前処理が必要です*2

カテゴリカルデータを変換する

カテゴリカルデータを数値として扱うために値をカラムとして変形します。

import pandas as pd

# csvファイルの読み込み train_df = pd.read_csv("sanfranciscoCrime/input/train.csv")
# カテゴリカルデータを数値データに変換
dummyhours = pd.get_dummies(test_df.Dates.map(lambda x: pd.to_datetime(x).hour), prefix="hour", drop_first=True) dummymonths = pd.get_dummies(test_df.Dates.map(lambda x: pd.to_datetime(x).month), prefix="month", drop_first=True) dummyyears = pd.get_dummies(test_df.Dates.map(lambda x: pd.to_datetime(x).year), prefix="year", drop_first=True) dummyweeks = pd.get_dummies(test_df.DayOfWeek, prefix="w", drop_first=True) dummydistricts = pd.get_dummies(test_df["PdDistrict"], prefix="p", drop_first=True)

# train_dfを破壊的に更新する train_df.drop(["Descript", "Resolution", "Dates", "DayOfWeek", "Address", "PdDistrict"], axis=1, inplace=True) train_df = pd.concat([train_df, dummyyears, dummymonths, dummyweeks, dummyhours, dummydistricts], axis=1)

ランダムフォレストで予測

使用可能な分類器はランダムフォレスト以外にもサポートベクターマシンやロジスティック回帰、k近傍法などたくさんある。ランダムフォレストを選択した理由は、ある程度精度が高く、高速に分類できたためです。ランダムフォレストの分類器に与えたパラメータは、この後に述べるグリッドサーチで決めました。

from sklearn.ensemble import RandomForestClassifier

randomForest = RandomForestClassifier(n_estimators=100, max_depth=10, max_features=30)
y_train_df = train_df["Category"]
x_train_df = train_df.drop("Category", axis=1)

最適なパラメータを見つける

ランダムフォレストに与えるパラメータは、幾つか用意されています*3

最適なパラメータを見つける手法の一つとして、グリッドサーチがあります。これを使うと、指定した全てのパラメータについて、モデルを生成し最適なパラメータを見つけてくれます。数学的な知識があればある程度どのようなパラメータを指定すればよいかわかるかもしれない。もしくは、時間と計算機資源があるならば地道にパラメータを探すのも良いかもしれない。ただし、今回のデータを用いてグリッドサーチを実行すると、組み合わせ総数の数だけモデルの生成とその精度の測定を実行しなければいけなくなるため、非常に時間がかかります。そのため、ここでは訓練データの1%のみしか用いていません*4

from sklearn.model_selection import GridSearchCV

parameters = {
        'n_estimators'      : [20, 30, 50, 100],
        'max_features'      : [15, 20, 25, 30],
        'max_depth'         : [3, 5, 10, 15]
}
clf = GridSearchCV(RandomForestClassifier(), parameters)
clf.fit(x_train_df, y_train_df)
clf.grid_scores_
[mean: 0.19943, std: 0.00083, params: {'max_depth': 3, 'max_features': 5, 'n_estimators': 10},
 mean: 0.20057, std: 0.00191, params: {'max_depth': 3, 'max_features': 5, 'n_estimators': 20},
 mean: 0.20046, std: 0.00221, params: {'max_depth': 3, 'max_features': 5, 'n_estimators': 30},
 mean: 0.19920, std: 0.00064, params: {'max_depth': 3, 'max_features': 5, 'n_estimators': 50},
...
 mean: 0.23223, std: 0.00724, params: {'max_depth': 15, 'max_features': 20, 'n_estimators': 50},
 mean: 0.23850, std: 0.00771, params: {'max_depth': 15, 'max_features': 20, 'n_estimators': 100}]

その結果選ばれたパラメータは以下のとおりでした。場合によりますが、選ぶパラメータによって数%の精度の差が出るため、パラメータの選択は重要と言えるでしょう。

print(clf.best_params_)
print(clf.best_score_)
{'max_depth': 10, 'max_features': 30, 'n_estimators': 100}
0.242824601367

モデルの生成と分類

fit関数でモデルの学習を行い、predict関数でテストデータを分類します。ここでは、分類されたクラスを取得するpredictメソッドではなく、分類されるクラスの確率値を取得するpredict_probaメソッドを使用します。

randomForest.fit(x_train_df, y_train_df)
result = pd.DataFrame(randomForest.predict_proba(test_df), index=test_df.index, columns=randomForest.classes_)

結果

訓練データの規模でもモデルの学習までは行えましたが、レコード数があまりにも多かったためにテストデータの分類は実行できませんでした。おそらくディスク上に結果を書き出しつつ計算する方法もあるはずだと思いますが、今はそこまでは調べていません。ひとまず、小さなデータでは実行可能になったため、これで終わりとします。

その他のテクニック

このデータの分析を通して、学んだことを以降に記述します。

使用しない変数はdelメソッドで削除する

12MBのメモリを積んで学習と予測を実行してみたが、この規模のレコード数になるとOut of Memoryとなり、処理ができなかった。メモリの使用量を減らすことができるかどうかは、分析者にかかっている。delメソッドを使えば、明示的にメモリの解放もできる。

import gc

del train_df, dummyhours, dummyweeks, dummydistricts, dummymonths, dummyyears
gc.collect()
なるべく破壊的代入を行う

例えば訓練データからあるカラムを削除したいとするとき、新たにそのための変数を用意することはメモリを大きく消費することになります。具体的には、その変数に1GBの情報が詰められていたとするならば、ほんの少しだけ変更した変数を作成すると、前の変数の情報と合わせて2GBのメモリを消費することになります。dropメソッドにも"inplace"という破壊的な代入を行うための属性が存在するように、積極的に破壊的代入をしてべきでしょう*5

train_df.drop(["Descript", "Resolution", "Dates", "DayOfWeek", "Address", "PdDistrict"], axis=1, inplace=True)
作成したモデルはディスクへ書き出す(うっかりデータが飛んでしまい、再計算する羽目になったから)

自分の動作環境があまり良くないためか、分析実行時にパソコンが制御不能に陥ることがよくありました。そのたびにモデルを学習し直さないといけなくなるととても厄介です。そういった時はsave&reloadができるようにしておいたほうがよいでしょう。

sklearn.externals import joblib
joblib.dump(randomForest, "sanfranciscoCrime/input/randomForest.pkl")
randomForest = joblib.load("sanfranciscoCrime/input/randomForest.pkl")
巨大なcsvファイルのデータを確認する

タイタニックのレコード数と比較すると、1,000倍規模になります。Excelを始めとするGUIツールを用いて巨大なcsvファイルを確認しようとすると、非常に時間がかかる。そのため、columnコマンドを使用すると、ファイルの中身を確認しやすい*6

column -s, -t < train.csv | less -#2 -N -S
多重共線性を排除する

kaggleの幾つかのカーネルを見ていたとき、カテゴリカル変数の値を一つのカラムとして扱う際に、1つだけカラムを削除しているのをよく見かけました。これは多重共線性*7が発生することを回避するためであるようです*8。カテゴリカル変数からダミー変数を作り出すget_dummyメソッドにはdrop_first属性が与えられており、Trueのとき、最初の変数を削除してくれます。

dummyyears = pd.get_dummies(test_df.Dates.map(lambda x: pd.to_datetime(x).year), prefix="year", drop_first=True)
少量のデータで実験する

今回のデータは大きかったため、一度訓練データをtrain_dfに読み込んだ後に、サンプリングをしました。少量のデータで実験する場合にはサンプリングをするのもよいでしょう。

train_df = train_df.sample(frac=0.01)

*1:https://www.kaggle.com/maite828/test-random-forest

*2:もしかしたら、時間に関連するものは時系列データとして扱うことで処理ができるのかもしれない。

*3:3.2.4.3.1. sklearn.ensemble.RandomForestClassifier — scikit-learn 0.19.0 documentation

*4:そのため、精度が20%程度とかなり低い値になっている

*5:昔に関数型言語で実装していたため、副作用を発生させる文法を推奨するのには違和感がある

*6:エスケープ処理に対応していなかったためこれでも不十分だったが、純粋にlessコマンドを使用して開くよりはよい

*7:多重共線性とは? 〜 概要と対応方法 〜 | 株式会社サイカ

*8:実際に、N個のダミー変数を作らずともN-1のダミー変数で全てを表現できる