医療職からデータサイエンティストへ

統計学、機械学習に関する記事をまとめています。

Pythonによるデータ前処理手法の網羅的まとめ

データ前処理

データ解析をする上で、もっとも重要な工程であるデータの前処理、今回はそんな前処理をPythonで行うための様々な方法をまとめました。もし、こんな処理も追加してほしいというご要望があれば、お気軽にコメントください(^^)

Rユーザの方にはこちらを

www.medi-08-data-06.work

本記事は主にこちらを参照しています。

データセットの作成

まずはデータを作成していきます。コピペして実行すれば、データセットが作成されますので、作り方は特に気にしなくて大丈夫です。今回は2019年の仮想売り上げデータを想定します。

import numpy as np
import pandas as pd
import datetime

np.random.seed(123)

id = np.random.randint(1,100,1000)
day_lis = [(datetime.datetime.strptime("2019/01/01","%Y/%m/%d")+datetime.timedelta(days=num)).strftime("%Y/%m/%d") for num in range(1,365)]
date = np.random.choice(day_lis,1000,replace=True)
store  = np.random.choice(["A","B","C","D","E"],1000,replace=True)
purchase = np.random.randint(500,10000,1000)
purchase_num = np.random.randint(1,100,1000)

sales_data = pd.DataFrame({"id":id,
             "date":date,
             "store": store,
             "purchase":purchase,
             "purchase_num":purchase_num})
id day store purchase purchase_num
0 67 2019/01/09 C 8454 46
1 93 2019/01/24 C 4896 8
2 99 2019/04/24 E 6346 91
3 18 2019/09/02 B 3348 97
4 84 2019/11/24 C 7032 6

変数は以下の通りです。

  • id:購入者の個人ID
  • date:購入された日にち
  • store:購入されたお店
  • purchase:購入額
  • purchase_num:購入商品数

列名変更

まずは列名を変更してみましょう。変数名を変更するにはrename()を使います。

#idを大文字にする。
sales_data.rename(columns={"id":"ID"},inplace=FALSE)
ID day store purchase purchase_num
0 67 2019/01/09 C 8454 46
1 93 2019/01/24 C 4896 8
2 99 2019/04/24 E 6346 91
3 18 2019/09/02 B 3348 97
4 84 2019/11/24 C 7032 6

columnsとした後にdict形式で記述して、列名を変更します。引数のinplaceをTRUEとすると、実行した時点で元のデータフレームに変更が反映されます。ちなみにcolumnsではなく、indexを指定すると行名を変更することもできます。

欠損値の処理

データの前処理をする上で欠損値の処理はとても重要な作業です。この欠損値処理だけで本が一冊かけてしまうぐらい奥が深いものですが、今回は一番シンプルな方法で処理していきます。

まずは変数"purchase"と"purchase_num"にランダムに100個の欠損を作ります。

#欠損をランダムに100こ作成
np.random.seed(123)
na_index1 = np.random.choice(range(sales_data.shape[0]),50,replace=False)
na_index2 = np.random.choice(range(sales_data.shape[0]),50,replace=False)

sales_data.iloc[na_index1,3] = np.NAN
sales_data.iloc[na_index1,4] = np.NAN
#欠損の数を数える
sales_data.isnull().sum()


>output
iid               0
day              0
store            0
purchase        50
purchase_num    50

一番シンプルな方法は欠損値のある行を削除してしまう方法です。これにはdropna()を使います。

print(sales_data.shape)
sales_data.dropna(inplace=True)
print(sales_data.shape)

>output
(1000, 5)
(904, 5)

もともと1000行あったデータが904行になりました。しかし、1つでも欠損があるとその行全部が削除されるため、データとしてはもったいないです。そこでfillna()を使って、それぞれの変数の平均値で補完します。

#二つの変数の平均値を算出
purchase_mean = sales_data["purchase"].mean()
purchase_num_mean = sales_data["purchase_num"].mean()

#欠損値を補完
sales_data["purchase"].fillna(purchase_mean,inplace=True)
sales_data["purchase_num"].fillna(purchase_num_mean,inplace=True)

#確認
print(sales_data.shape)
print(sales_data.isnull().sum())
>output
(1000, 5)
id              0
day             0
store           0
purchase        0
purchase_num    0
dtype: int64

これでデータを無駄にすることなく、欠損値を補完することができました。

データ前処理

変数の追加・編集

次は変数の追加や、編集を行ってみます。いろいろやり方があるのですが、個人的に一番スマートな書き方はassign()を使うやり方です。購入額”purchase”を購入数”purchase_num”で割って、商品一個当たりの購入単価を表す変数”purchase_one”を作ります。

sales_data = sales_data.assign(purchase_one = sales_data["purchase"]/sales_data["purchase_num"])
sales_data.head()
id day store purchase purchase_num purchase_one
0 67 2019/01/09 C 8454.0 46.0 183.782609
1 93 2019/01/24 C 4896.0 8.0 612.000000
2 99 2019/04/24 E 6346.0 91.0 69.736264
3 18 2019/09/02 B 3348.0 97.0 34.515464
4 84 2019/11/24 C 7032.0 6.0 1172.000000

今回は一変数のみの追加ですが、assign()を繋げることで、複数の変数を一気に追加することが出来ます。また、assign()は既存の変数の編集を行うこともできるので、先ほど作った”purchase_one”を四捨五入してみます。

sales_data.assign(purchase_one = round(sales_data["purchase_one"])).head()
id day store purchase purchase_num purchase_one
0 67 2019/01/09 C 8454.0 46.0 184.0
1 93 2019/01/24 C 4896.0 8.0 612.0
2 99 2019/04/24 E 6346.0 91.0 70.0
3 18 2019/09/02 B 3348.0 97.0 35.0
4 84 2019/11/24 C 7032.0 6.0 1172.0

また、ある条件分岐にしたがって新たな変数を作りたい場合、例えば購入額によって、"upper"、"low"などの記号をつけた変数を作りたい場合は、即時関数lambdaとif文、map()を使って以下のようにします。

#購入額5000円以上でhight、5000未満でlowとする。

sales_data.assign(rank = sales_data["purchase"].map( lambda x: "hight" if x > 5000 else "low")).head()
id day store purchase purchase_num purchase_one rank
0 67 2019/01/09 C 8454.0 46.0 183.782609 hight
1 93 2019/01/24 C 4896.0 8.0 612.000000 low
2 99 2019/04/24 E 6346.0 91.0 69.736264 hight
3 18 2019/09/02 B 3348.0 97.0 34.515464 low
4 84 2019/11/24 C 7032.0 6.0 1172.000000 hight

少しややこしいですね。map()は一つ一つの要素に関数を適応できるメソッドです。そして、lambdaで関数を定義し、if文を1行で記述しています。上記を分解すると以下のようになります。

#lambdaの書き方
lambda 引数 : 処理

#if文の書き方(1行で)
Trueの値 if 条件 else Falseの値

データ前処理

もし、より複雑な条件分岐をさせたい場合は、あらかじめ関数を定義しておく方が可読性が良くなります。今度は3水準に分岐させてみましょう。

#関数の定義
def def_lank(x):
    if x >= 8000:
        return "high"
    elif x > 5000:
        return "middle"
    else :
        return "low"

#適応
sales_data.assign(lank = sales_data["purchase"].map(def_lank)).head()
id day store purchase purchase_num purchase_one lank
0 67 2019/01/09 C 8454.0 46.0 183.782609 high
1 93 2019/01/24 C 4896.0 8.0 612.000000 low
2 99 2019/04/24 E 6346.0 91.0 69.736264 middle
3 18 2019/09/02 B 3348.0 97.0 34.515464 low
4 84 2019/11/24 C 7032.0 6.0 1172.000000 middle

うまく変換できていますね。

--- 追記(2020/5/20) ---

numpyを使った書き方

変数の修正はnumpyと組み合わせるともっと簡潔に書くことができます。 例えば、購入額によって、"upper"、"low"ラベルを付けたい場合は、np.whereを使って以下のように書きます。

#np.whereの書き方
np.where(条件 , Trueの値, Falseの値)

sales_data.assign(rank=np.where(sales_data["purchase"]>=5000, "upper", "low"))

先ほどのmapを使った書き方は、一つずつ値を確認しているのに対して、np.whereはベクトルで処理をしているので、True、Falseの値にベクトルで指定することもできます。例えば条件がFalseの場合は元の購入額を入れるといった処理を行うこともできます。

#購入額5000以上はupper、5000未満には元の購入額を入れたい場合

sales_data.assign(rank=np.where(sales_data["purchase"]>=5000, "upper", sales_data["purchase"]))

また、複数条件でラベルづけしたい場合、np.selectを使うと実現できます。

#np.selectの書き方
np.select([条件1,条件2, ...],
                  [条件1がTrueの値,条件2がTrueの値,...])

sales_data.assign(rank = 
                  np.select([sales_data["purchase"]>=8000,
                            (sales_data["purchase"]<8000)&(sales_data["purchase"]>=5000),
                            sales_data["purchase"]<5000],
                            ['high', 'middle', 'low'])
                  )

関数を作成しなくても複数条件の変数変換ができるので、便利ですね!

--- 追記終わり ---

条件での抽出(行)

ある条件で、行を絞り込みたいときは、query()を使うのが良いでしょう。and(&)やor(|)などを使うことが出来ます。

#購入額が5000以上かつお店Cの売り上げデータ
sales_data.query("purchase>=5000 & store=='C'")

#購入額が9000以上または購入額が1000以下の売り上げデータ
sales_data.query("purchase>=9000 | purchase<=1000")

queryの中は文字列で指定することに注意してください。boolean型を使う方法もあり、こちらの方が速いですが、可読性や記述のしやすさから私はqueryを使っています。

グルーピングと集計

データの前処理では、変数とグループごとにデータを集約したい時がよくあります。例えば、お店ごとに総売上を計算してみましょう。

sales_data_agg=sales_data.groupby("store").agg({"purchase":"sum"}).reset_index()
sales_data_agg.columns  =["store","purchase_sum"]
sales_data_agg.head()
store purchase_sum
0 A 1.100837e+06
1 B 9.807251e+05
2 C 1.064319e+06
3 D 1.163067e+06
4 E 9.252148e+05

まずはgrooupby()でグルーピングする変数名を指定します。そして、agg()で集約したい変数名と処理を書いていきます。ここでは、”store”ごとに”purchase”を”sum”するという処理をしています。

データ前処理

ちなみにグルーピングに使った変数は行名(index)になってしまうため、reset_index()で変数として扱えるようにしています。また、分かりやすいように結果を変数に代入し、列名も変更しておきました。rename()を使わない方法としては、上記のような方法もあります。

また、複数の変数に対して処理を行ったり、同じ変数に違う関数を適応したりもできます。

sales_data_agg = sales_data.groupby("store").agg({"purchase":["sum","mean"],
                                                  "id":"nunique"}).reset_index()
sales_data_agg.columns  =["store","purchase_sum","purchase_mean","id_count"]
sales_data_agg.head()
store purchase_sum purchase_mean id_count
0 A 1.100837e+06 5267.163359 93
1 B 9.807251e+05 5189.021805 85
2 C 1.064319e+06 5166.599259 83
3 D 1.163067e+06 5564.912113 89
4 E 9.252148e+05 4947.672812 86

ここでは、お店ごとの総売上と購入額の平均値を算出し、さらにお客数もカウントしています。nunique()とは、重複無しでユニークな値の数を数える関数です。例えば(1,1,1,2,3)という配列にnunique()を使うとユニークな値は1,2,3なので3と返ってきます。同じidを持つ人が異なる日付で購入しており、idの重複カウントを避けるためにこれを使用しています。

結合

複数のデータセットをあるルールに基づいて結合するという処理も簡単に行うことができます。例えば、2019年の上半期、下半期それぞれでお店ごとの売り上げを集約し、それを結合してみましょう。

#上半期のお店ごと売上
sales_data_first = sales_data.query("day<='2019/06/30'").groupby("store").agg({"purchase":"sum"}).assign(year="first").reset_index()
#下半期のお店ごと売上
sales_data_second = sales_data.query("day>'2019/06/30'").groupby("store").agg({"purchase":"sum"}).assign(year="second").reset_index()

#行方向に結合
sales_data_first_secod=pd.concat([sales_data_first,sales_data_second],axis=0)
sales_data_first_secod
store purchase year
0 A 509675 first
1 B 525230 first
2 C 582978 first
3 D 609213 first
4 E 481503 first
0 A 579089 second
1 B 462257 second
2 C 488565 second
3 D 551947 second
4 E 447711 second

まずは、上半期、下半期のデータを抽出し、お店ごとの総売上を集計します。さらにassign()を使って上半期、下半期が分かる変数を追加し、結果をそれぞれ変数に代入しました。データフレームの結合にはpandasのconcat()を使って結合しています。”axis=0”で行方向、”axis=1”で列方向に結合されます。

しかし、行方向の結合は問題ないですが、列方向の結合はconcat()では痒い所に手が届かないことがあります。例えば、お店のデモグラフィックな情報(地域やCEOの名前など)を保存してあるデータセットがあるとします。それをお店の名前と紐づけて列方向に結合させるにはmerge()を使うのがベターです。

#お店データを作成
np.random.seed(123)

store = ["A","B","C","D","E","F","G"]
region = ["Tokyo","Tokyo","Oosaka","Nagoya","Nagoya","Fukuoka","Sendai"]
CEO=["Yamada","Tanaka","Kato","Yamamoto","Sato","Mori","Kanayama"]

store_data = pd.DataFrame({"store":store,
             "region":region,
             "CEO": CEO})
store_data
store region CEO
0 A Tokyo Yamada
1 B Tokyo Tanaka
2 C Oosaka Kato
3 D Nagoya Yamamoto
4 E Nagoya Sato
5 F Fukuoka Mori
6 G Sendai Kanayama

このデータを先ほど上半期、下半期で集計したデータと結合します。

sales_data_merge = pd.merge(sales_data_first_secod,store_data,left_on="store",right_on="store",how="left")
sales_data_merge
store purchase year region CEO
0 A 509675 first Tokyo Yamada
1 B 525230 first Tokyo Tanaka
2 C 582978 first Oosaka Kato
3 D 609213 first Nagoya Yamamoto
4 E 481503 first Nagoya Sato
5 A 579089 second Tokyo Yamada
6 B 462257 second Tokyo Tanaka
7 C 488565 second Oosaka Kato
8 D 551947 second Nagoya Yamamoto
9 E 447711 second Nagoya Sato

“left_on, right_on”に結合を紐づける変数名を指定します。今回はどちらも”sotre”なので”on=store”と書くこともできます。そして”how”には結合方法を指定します。結合方法には以下のようなものがあります。

データ前処理データ前処理

横持ち・縦持ち変換(long to wide, wide to long)

データの横持ち型(wide)と縦持ち型(long)は、言葉で説明するのは難しいですが、この2つの型を変換したくなるときは多々あります。pythonでは、stack()unstack()を使うこともありますが、個人的にはpivot_table()melt()を使う方が好きです。まずは縦持ちから横持ちに変換していきます。使用するデータが先ほどの上半期、下半期の総売上を結合したデータです。

#long to wide
sales_data_first_secod.pivot_table(index="store",columns="year",values="purchase").reset_index()
year store first second
0 A 509675 579089
1 B 525230 462257
2 C 582978 488565
3 D 609213 551947
4 E 481503 447711

少しややこしいですが、”index”には残したい変数名を、”columns”には変数にしたい列名を、”values”には、変数に対応させたい値を指定します。(言葉での説明は難しいので、実際にやってみるのが速いかと思います^^;) また、”index”と”columns”に対応する値が2つ以上ある場合は、pivot_table()はデフォルトで平均値を取ります。先ほどのお店のでもグラフィックデータをマージしたデータセットを使います。

sales_data_merge.pivot_table(index="region",columns="year",values="purchase").reset_index()
year region first second
0 Nagoya 547262.152632 496878.571053
1 Oosaka 574758.468421 489560.978947
2 Tokyo 519051.071053 521730.060526

ここでは、Nagoya×yearとTokyo×yearの組み合わせで重複する”purchase”があるため、平均値が計算されています。”aggfunc”を指定すれば、平均値以外を算出することもできますが、今回は省略します。

さらに、複数のカラムをindexにしたい場合は、set_index()でカラムをindexに変換した後、pivot_table()を使います。

long2wide = sales_data_merge.set_index(["store","region","CEO"]).pivot_table(index=["store","region","CEO"],columns="year",values="purchase").reset_index()
long2wide
year store region CEO first second
0 A Tokyo Yamada 509675 579089
1 B Tokyo Tanaka 525230 462257
2 C Oosaka Kato 582978 488565
3 D Nagoya Yamamoto 609213 551947
4 E Nagoya Sato 481503 447711

逆に縦持ちにしたい場合はpandasのmelt()を使います。先ほど変換した"long2wide"を縦持ちにしてみます。

#wide to long
pd.melt(long2wide,id_vars=["store","region","CEO"],value_vars=["first","second"])
store region CEO year value
0 A Tokyo Yamada first 509675
1 B Tokyo Tanaka first 525230
2 C Oosaka Kato first 582978
3 D Nagoya Yamamoto first 609213
4 E Nagoya Sato first 481503
5 A Tokyo Yamada second 579089
6 B Tokyo Tanaka second 462257
7 C Oosaka Kato second 488565
8 D Nagoya Yamamoto second 551947
9 E Nagoya Sato second 447711

pandasのmelt()では、"id_vars"で残したい変数名を、"value_vars"で縦持ちにしたい変数名を指定します。うまく変換できていますね。

データ前処理

文字型処理

文字型の扱いは、モデル作成や解析を行う上では重要です。例えば、今回の変数"sotre"はアルファベットで記載されているため数値型に変換する必要があります。ここでは最もよく利用されるダミー変数化を行なっていきます。pythonでダミー変数化を行うにはpandasのget_dummies()を使います。

#ダミー変数化
dumy_store=pd.get_dummies(sales_data["store"],drop_first=True)
pd.concat([sales_data,dumy_store],axis=1).head()
id day store purchase purchase_num B C D E
0 67 2019/01/09 C 8454.0 46.0 0 1 0 0
1 93 2019/01/24 C 4896.0 8.0 0 1 0 0
2 99 2019/04/24 E 6346.0 91.0 0 0 0 1
3 18 2019/09/02 B 3348.0 97.0 1 0 0 0
4 84 2019/11/24 C 7032.0 6.0 0 1 0 0

"drop_first"はダミー変数化した最初の変数(ここではダミー変数A)を除くかどうかを指定します。なぜこれが必要かというと、B,C,D,Eが全て0であればAであることが明白だからです。余分な変数はない方が良いのです。また、get_dummies()ではデータフレームをそのまま渡せば、文字型の変数を判断して勝手にダミー変数化してくれますが、予期しない変数まで変換されることもあるため、上記のように個別に変換を行なっています。

---- 2020/07/12 追記

その他にもよく使いそうな文字列処理をまとめてみました。

www.medi-08-data-06.work

----追記終わり

日時型処理

多くのデータは、時系列的に格納されていることはよくあります。Pythonでは変数を日時型として扱うことで、色々便利なことができます。まずは文字として認識されている変数"day"を日時型に変換してみましょう。pandasのto_datetime()を使います。

print(type(sales_data.day[1]))
sales_data["day"]=pd.to_datetime(sales_data["day"],format="%Y/%m/%d")
print(type(sales_data.day[1]))

>oupput
<class 'str'>
<class 'pandas._libs.tslibs.timestamps.Timestamp'>

"str"から、"Timestamp"に変わったことが分かりますね。to_datetimeで変換すると"datetime64"型となり、dt.~~とすると年や日にち、曜日アクセスすることができます。

sales_data.assign(year = sales_data["day"].dt.year).\
                    assign(month = sales_data["day"].dt.month).\
                    assign(days=sales_data["day"].dt.day).\
                    assign(week=sales_data["day"].dt.dayofweek).\
                    head()
id day store purchase purchase_num year month days week
0 67 2019-01-09 C 8454 46 2019 1 9 2
1 93 2019-01-24 C 4896 8 2019 1 24 3
2 99 2019-04-24 E 6346 91 2019 4 24 2
3 18 2019-09-02 B 3348 97 2019 9 2 0
4 84 2019-11-24 C 7032 6 2019 11 24 6

assignを使って、変数dayの様々な変換後の変数を作ってみました。"year"、"month"、"days"はお分かりかと思います。"week"は曜日を表し、0:日曜日~6:土曜日を表します。また、今回は時刻データを含んでいませんが、"dt.hour"や"dt.minutes"とすることで、時刻データを扱うこともできます。

まとめ

データの前処理はデータ解析の7~8割を占めるとも言われる重要な作業です。逆にここを効率的に処理することができれば、解析速度は格段に向上するでしょう。まだまだ、紹介しきれていない処理もありますので、こんな処理をいつもやってるよーなどがあれば、追加させて頂きます!

※本記事は筆者が個人的に学んだことをまとめた記事になります。所属する組織の意見・見解とは無関係です。また、数学の記法や詳細な理論、用語等で誤りがあった際はご指摘頂けると幸いです。

参考

今回は主に以下の書籍を参考にさせて頂きました。前処理のみをここまで網羅的に解説した書籍は他にはないでしょう。

dplyr のアレを Pandas でやる - Qiita