データ解析をする上で、もっとも重要な工程であるデータの前処理、今回はそんな前処理をPythonで行うための様々な方法をまとめました。もし、こんな処理も追加してほしいというご要望があれば、お気軽にコメントください(^^)
Rユーザの方にはこちらを
本記事は主にこちらを参照しています。
前処理大全[データ分析のためのSQL/R/Python実践テクニック]
- 作者:本橋 智光
- 発売日: 2018/04/13
- メディア: 大型本
- データセットの作成
- 列名変更
- 欠損値の処理
- 変数の追加・編集
- 条件での抽出(行)
- グルーピングと集計
- 結合
- 横持ち・縦持ち変換(long to wide, wide to long)
- 文字型処理
- 日時型処理
- まとめ
- 参考
データセットの作成
まずはデータを作成していきます。コピペして実行すれば、データセットが作成されますので、作り方は特に気にしなくて大丈夫です。今回は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 追記
その他にもよく使いそうな文字列処理をまとめてみました。
----追記終わり
日時型処理
多くのデータは、時系列的に格納されていることはよくあります。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割を占めるとも言われる重要な作業です。逆にここを効率的に処理することができれば、解析速度は格段に向上するでしょう。まだまだ、紹介しきれていない処理もありますので、こんな処理をいつもやってるよーなどがあれば、追加させて頂きます!
※本記事は筆者が個人的に学んだことをまとめた記事になります。所属する組織の意見・見解とは無関係です。また、数学の記法や詳細な理論、用語等で誤りがあった際はご指摘頂けると幸いです。
参考
今回は主に以下の書籍を参考にさせて頂きました。前処理のみをここまで網羅的に解説した書籍は他にはないでしょう。
前処理大全[データ分析のためのSQL/R/Python実践テクニック]
- 作者:本橋 智光
- 発売日: 2018/04/13
- メディア: 大型本