LoginSignup
63
60

More than 5 years have passed since last update.

dplyr のアレを Pandas でやる

Last updated at Posted at 2018-10-08

概要

N番煎じですが、R の dplyr でやっていたことを Python の Pandas で実現する方法をまとめました。
私が苦戦したものに絞って、試行錯誤の末に得た自分なりの解法を記載しています。
より良い方法をご存知でしたら教えてください!

dplyr と Pandas の対比まとめ

dplyr, tidyr, ... Pandas (おすすめ) Pandas (イマイチ) 備考
%>% . or pipe
select .loc[] filter or drop
filter [] query
mutate assign
group_by groupby
summarise assign + agg
*_join merge まだ調査中です
gather stack melt
spread unstack pivot

Pandas と dplyr のより網羅的な対比は、既に記事が出ています。
私が参考にしたものをこの記事の末尾で紹介していますので、詳しくはそちらをご覧ください 。

前置き|テーブルのキーの扱い方の違い

Pandas を使い始める前に理解すべきこととして、dplyr と Pandas ではテーブルのキーの扱い方に大きな違いがあります。この違いを認識していなかった私はかなり苦戦しました……

以下の2点を理解して (覚悟して?) おくと、学習コストが多少減るのではないでしょうか。

  • Pandas ではテーブルのキーが Index という別オブジェクトで管理されていること
  • Pandas ではキーや列名に階層構造を持たせられること

dplyr におけるキーの扱い

R のデータフレームにはテーブルのキーを表す方法として row names という仕組みが用意されています。しかし、dplyr (が利用している、データフレームを拡張したオブジェクトである tibble) では row names をサポートせず (参考:Hadley さんの Advanced R)、テーブルのキーも値と同様に列として扱います (Pandas のドキュメントの表現を借りるなら "SQL-style" で扱います)。そのため、普段 dplyr を使っている人はテーブルのキーを値と分けて管理するという感覚がないのではないかと思います。

以下の表は dplyr で iris データセットを集計した例です。キーを表す species も値と同様にひとつの列として扱われており、row names のように別枠で管理されてはいません。

species sepal_length_min sepal_length_max sepal_width_min sepal_width_max petal_length_min petal_length_max petal_width_min petal_width_max
setosa 4.3 5.8 2.3 4.4 1 1.9 0.1 0.6
versicolor 4.9 7 2 3.4 3 5.1 1 1.8
virginica 4.9 7.9 2.2 3.8 4.5 6.9 1.4 2.5

Pandas におけるキーの扱い

一方 Pandas は真逆で、テーブルのキーを表す方法として Index という仕組みを用意しており、テーブルのキーと値を積極的に分けて管理します。そのため、Pandas のデータフレームに対する操作には以下の2種類があり、dplyr と比べるとかなり複雑です。

  • dplyr のように列を指定するメソッド
    • 例) sort_values (dplyr::arrange に相当), melt (tidyr::gather に相当)
  • Index を指定するメソッド
    • 例) sort_index (dplyr::arrange に相当), stack (tidyr::gather に相当)

以下の表は Pandas で iris データセットを集計した例です。species と書かれている列が Index で、値を表す他の列とは別に管理されています。また、列名が2段になっていますが、これは MultiIndex と呼ばれるもので、Pandas ではキーや列名に階層構造を持たせることができます。

sepal_length sepal_width petal_length petal_width
min max min max min max min max
species
setosa 4.3 5.8 2.3 4.4 1.0 1.9 0.1 0.6
versicolor 4.9 7.0 2.0 3.4 3.0 5.1 1.0 1.8
virginica 4.9 7.9 2.2 3.8 4.5 6.9 1.4 2.5

本題|dplyr のアレを Pandas でやる方法

サンプルデータの読み込み

Pandas の使い方を説明するためのサンプルデータとして、iris データセットを使います。

import numpy as np
import pandas as pd
from sklearn import datasets

iris = datasets.load_iris()
iris = \
    pd.DataFrame(iris.data, columns = iris.feature_names). \
    rename(columns = {
        "sepal length (cm)": "sepal_length",
        "sepal width (cm)": "sepal_width",
        "petal length (cm)": "petal_length",
        "petal width (cm)": "petal_width"
    }). \
    assign(species = iris.target). \
    assign(species = lambda df: df.species.apply(lambda x: iris.target_names[x])). \
    assign(id = np.arange(0, iris.target.size))
sepal_length sepal_width petal_length petal_width species id
0 5.1 3.5 1.4 0.2 setosa 0
1 4.9 3.0 1.4 0.2 setosa 1
2 4.7 3.2 1.3 0.2 setosa 2
3 4.6 3.1 1.5 0.2 setosa 3
4 5.0 3.6 1.4 0.2 setosa 4

パイプ magrittr::%>%

. を使う方法

DataFrame オブジェクトのメソッドは基本的に DataFrame オブジェクトを返すので、ひたすら . でメソッドチェーンをつなげていけば magrittr::%>% のような書き方ができます。

# iris %>% head(10) %>% tail(5)
iris.head(10).tail(5)

pipe を使う方法

DataFrame オブジェクトが持っていない関数を適用したい場合には、pipe メソッドを使います。pipe メソッドには DataFrame オブジェクトを受け取って DataFrame オブジェクトを返す関数を渡します。DataFrame オブジェクトが持っているメソッドでは不足することが多々あるので、必然的に pipe メソッドを頻繁に使うことになります。この記事の中でも何度も使います。

iris.pipe(lambda df: df.iloc[5:10, :])

列の選択 dplyr::select

.loc[] + pipe を使う方法

DataFrame オブジェクトには filterdrop (後で紹介します) というメソッドで列の選択を行うことができますが、これらのメソッドはできることに制約があり使える場面が限られています。いろいろ試した結果、結局 .loc[] を使うのが一番だという結論に至りました。

基本構文は次の通りです。直接 iris.loc[:, ...] とせずに pipe メソッドを噛ませているところがポイントです。こうすることで、この後紹介する DataFrame オブジェクトのパラメータを参照した高度な列の絞り方を行う場合でもメソッドチェーンをつなぐことが可能になります。

# iris %>% select(sepal_length, sepal_width)
iris.pipe(lambda df: df.loc[:, '''ここに列の選択条件を書く'''])

[2018/10/10 追記]
.loc[] に関数を指定できることを知りました。次のように書くことで pipe メソッドを噛ませなくてもメソッドチェーンをつなぐことができます。

iris.loc[:, lambda df: '''ここに列の選択条件を書く''']

特定のパターンを含む列だけを取り出したい場合は、DataFrame オブジェクトの列名を表す columns パラメータ (Index オブジェクト) と、Index オブジェクトに対する文字列処理メソッド群へアクセスするための str エイリアスを使います (参考:str エイリアスでアクセスできる文字列処理メソッドの一覧)。

# iris %>% select(startswidth("sepal"))
iris.pipe(lambda df: df.loc[:, df.columns.str.startswith("sepal")])

# iris %>% select(endswith("length"))
iris.pipe(lambda df: df.loc[:, df.columns.str.endswith("length")])

# iris %>% select(contains("^.*_.*$"))
iris.pipe(lambda df: df.loc[:, df.columns.str.contains("^.*_.*$")])

[2018/10/10 追記]
pipe メソッドを使わなくても、次のように書けます。

iris.loc[:, lambda df: df.columns.str.startswith("sepal")]

特定のパターンを含まない列だけを取り出したい場合は、~ で反転させます (boolean 型の NumPy 配列をビット演算子 ~ で反転させて上手く行く根拠を見つけられていないので、ダメな場合があるかもしれません……numpy.invert のドキュメント を読むと Examples の一番下に boolean 型は boolean 型として反転させる旨が記載されているので、おそらく numpy.__invert__ も同様の実装になっているのではないでしょうか)。

# iris %>% select(-startswidth("sepal"))
iris.pipe(lambda df: df.loc[:, ~df.columns.str.startswith("sepal")])

# iris %>% select(-endswith("length"))
iris.pipe(lambda df: df.loc[:, ~df.columns.str.endswith("length")])

# iris %>% select(-contains("^.*_.*$"))
iris.pipe(lambda df: df.loc[:, ~df.columns.str.contains("^.*_.*$")])

filterdrop を使う方法

列を選択には filter メソッドを使うこともできます。選択する列を列挙できるなら、filter メソッドを使うほうが .loc[] + pipe よりも簡潔に表現できます。

# iris %>% select(sepal_length, sepal_width)
iris.filter(["sepal_length", "sepal_width"])

filter メソッドは正規表現が使えるので、柔軟な列選択が可能です。

# iris %>% select(starts_with("sepal"))
iris.filter(regex = "^sepal")

特定の列を除きたい場合は、drop メソッドを使うこともできます。除去する列を列挙できるなら、drop メソッドを使うほうが .loc[] + pipe よりも簡潔に表現できます。drop メソッドは行と列の両方に対応したメソッドなので、列を除きたい場合には引数 column に列名を列挙します。

# iris %>% select(-petal_length, -petal_width)
iris.drop(columns = ["petal_length", "petal_width"])

行の選択 dplyr::filter

[] + pipe を使う方法

行を選択するには、[] を使います。

# iris[iris$sepal_length > 5, ]
iris[iris.sepal_length > 5]

ただし、素直に [] を使うとメソッドチェーンをつなげなくなってしまう (条件部で自分自身を参照しているので、連続して行選択を行うには一度チェーンを切って代入しなければならない) ので、メソッドチェーンをつなげたい場合には pipe メソッドを噛ませます。

# iris %>% filter(sepal_length > 5)
iris.pipe(lambda df: df[df.sepal_length > 5])

[2018/10/10 追記]
.loc[] と同様に、[] も 関数を指定することができます。

iris[lambda df: df.sepal_length > 5]

query メソッドを使う方法

[] の他に query メソッドを使う方法もありますが、イマイチ使い勝手が良くないです。

iris.query("sepal_length > 5")

行を絞る条件を文字列で渡す都合上、いろいろ制約があります。例えばメソッドを呼び出そうとすると、デフォルトの評価エンジン (numexpr) ではエラーになります。評価エンジンに python を指定すると動くようになりますが、そこまでするなら [] + pipe でいいかなという感じです。

# Error
iris.query("sepal_length.notnull()")
# OK
iris.query("sepal_length.notnull()", engine = "python")

新しい列の追加 dplyr::mutate

assign を使う方法

新しい列を追加するには、assign メソッドを使います。assign メソッドには DataFrame オブジェクトを受け取り Series オブジェクトを返す関数を渡します。

# iris %>% mutate(sepal_area = sepal_length * sepal_width)
iris.assign(sepal_area = lambda df: df.sepal_length * df.sepal_width)
sepal_length sepal_width petal_length petal_width species id sepal_area
0 5.1 3.5 1.4 0.2 setosa 0 17.85
1 4.9 3.0 1.4 0.2 setosa 1 14.70
2 4.7 3.2 1.3 0.2 setosa 2 15.04
3 4.6 3.1 1.5 0.2 setosa 3 14.26
4 5.0 3.6 1.4 0.2 setosa 4 18.00

assign で特定の値を書き換える方法

ある条件を満たす行の値を書き換えるには、mask メソッドを使います。mask メソッドは条件を満たす場合には指定した値を、満たさない場合には元の値を返します。

# iris %>% mutate(sepal_length = if_else(sepal_length < 5, NA_real_, sepal_length))
iris.assign(sepal_length = lambda df: df.sepal_length.mask(df.sepal_length < 5, np.nan))
sepal_length sepal_width petal_length petal_width species id
0 5.1 3.5 1.4 0.2 setosa 0
1 NaN 3.0 1.4 0.2 setosa 1
2 NaN 3.2 1.3 0.2 setosa 2
3 NaN 3.1 1.5 0.2 setosa 3
4 5.0 3.6 1.4 0.2 setosa 4

同様に、ある条件を満たさない行の値を書き換えるには、where メソッドを使います。where メソッドは条件を満たす場合には元の値を、満たさない場合には指定した値を返します。

# iris %>% mutate(sepal_length = if_else(sepal_length < 5, sepal_length, NA_real_))
iris.assign(sepal_length = lambda df: df.sepal_length.where(df.sepal_length < 5, np.nan))
sepal_length sepal_width petal_length petal_width species id
0 NaN 3.5 1.4 0.2 setosa 0
1 4.9 3.0 1.4 0.2 setosa 1
2 4.7 3.2 1.3 0.2 setosa 2
3 4.6 3.1 1.5 0.2 setosa 3
4 NaN 3.6 1.4 0.2 setosa 4

assign で既存の列の値に応じて新しい列の値を変える方法

既存の列の値に応じて新しい列の値を変えるには、デフォルト値を代入した新しい列をあらかじめ作っておき、その列に対して mask メソッドや where メソッドを使って既存の列の値に応じて作った列の値を書き換えます。dplyr::if_else (base::ifelse でもいいですが) に相当するメソッドがあればより簡潔に書けそうですが、私が探した限りでは見つけられませんでした。

# iris %>% mutate(islong = if_else(sepal_length >= 5 & petal_length >= 1.3, "long", "short"))
iris. \
assign(islong = "short"). \
assign(islong = lambda df: df.islong.mask((df.sepal_length >= 5) & (df.petal_length >= 1.3), "long"))
sepal_length sepal_width petal_length petal_width species id islong
0 5.1 3.5 1.4 0.2 setosa 0 long
1 4.9 3.0 1.4 0.2 setosa 1 short
2 4.7 3.2 1.3 0.2 setosa 2 short
3 4.6 3.1 1.5 0.2 setosa 3 short
4 5.0 3.6 1.4 0.2 setosa 4 long

グループ化 dplyr::group_by と集約 dplyr::summarise

dplyr::summarise に該当するメソッドはありません。一応 agg がありますが、動作としては dplyr::summarise_alldplyr::summarise_at に近いです。

aggdplyr::summarise_all 相当の操作をやる方法

agg の引数に集約関数のリストを指定すると、DataFrame オブジェクトの全列に対して指定した全関数を適用した集計結果が得られます (dplyr::summarise_all 相当)。

# iris %>% group_by(species) %>% summarise_all(funs(min, max))
iris. \
groupby("species"). \
agg([min, max])
sepal_length sepal_width petal_length petal_width id
min max min max min max min max min max
species
setosa 4.3 5.8 2.3 4.4 1.0 1.9 0.1 0.6 0 49
versicolor 4.9 7.0 2.0 3.4 3.0 5.1 1.0 1.8 50 99
virginica 4.9 7.9 2.2 3.8 4.5 6.9 1.4 2.5 100 149

aggdplyr::summarise_at 相当の操作をやる方法

agg の引数に列名と集約関数のリストをペアにした辞書を渡すと、指定した列に対して指定した関数を適用した集計結果が得られます (dplyr::summarise_at 相当)。

# iris %>% group_by(Species) %>% summarise_at(vars(Sepal.Length, Sepal.Width), funs(min, max))
iris. \
groupby("species"). \
agg({ "sepal_length": [min, max], "sepal_width": [min, max] })
sepal_length sepal_width
min max min max
species
setosa 4.3 5.8 2.3 4.4
versicolor 4.9 7.0 2.0 3.4
virginica 4.9 7.9 2.2 3.8

aggdplyr::summarise 相当の操作をやる方法

agg は既存の列それぞれに対して集約関数を適用するメソッドなので、dplyr::summarise のように複数の列を組み合わせて集約したり、その結果を新しい列に格納したりすることができません。そういった処理を書きたい場合には、assign で事前に列を作り、その列に集約直前の状態まで計算した結果を格納し、その列に対して agg で集約関数を適用します。

# iris %>%
# group_by(Species) %>%
# summarise(
#     n = n(),
#     n_sepal_over5 = sum(Sepal.Length >= 5),
#     n_sepal_over6 = sum(Sepal.Length >= 6),
#     n_sepal_over7 = sum(Sepal.Length >= 7)
# )
iris. \
assign(
    n = 1,
    n_sepal_over5 = lambda df: df.sepal_length.apply(lambda x: 1 if x >= 5 else 0),
    n_sepal_over6 = lambda df: df.sepal_length.apply(lambda x: 1 if x >= 6 else 0),
    n_sepal_over7 = lambda df: df.sepal_length.apply(lambda x: 1 if x >= 7 else 0)
). \
filter(regex = "species|^n"). \
groupby("species"). \
agg(sum)
n n_sepal_over5 n_sepal_over6 n_sepal_over7
species
setosa 50 30 0 0
versicolor 50 49 24 1
virginica 50 49 43 12

[参考] MultiIndex をフラットにする方法

agg で集約するとグループ化した列が Index に、列名が 列名×集約関数名の MultiIndex になってしまい、dplyr 勢としては気持ち悪いですね。次のようにすることで tibble 風に変形できます (参考:How to flatten a hierarchical index in columns - Stack Overflow)。

  • pipe(...) で MultiIndex をフラットにすることができます。
    • df.columns.tolist()(第一階層の列名, 第二階層の列名) というタプルのリストを得ます。
    • [e[0] + "_" + e[1] ...] で各階層の列名を "_" 区切りで結合し、フラットな列名を生成します。
    • pd.DataFrame(df.values, index = df.index, column = ...) で列名を付け替えます。
  • reset_index で Index を列に戻すことができます。
iris. \
groupby("species"). \
agg([min, max]). \
pipe(lambda df: pd.DataFrame(
    df.values,
    index = df.index,
    columns = [e[0] + "_" + e[1] for e in df.columns.tolist()])
). \
reset_index()
species sepal_
length_
min
sepal_
length_
max
sepal_
width_
min
sepal_
width_
max
petal_
length_
min
petal_
length_
max
petal_
width_
min
petal_
width_
max
id_
min
id_
max
0 setosa 4.3 5.8 2.3 4.4 1.0 1.9 0.1 0.6 0.0 49.0
1 versicolor 4.9 7.0 2.0 3.4 3.0 5.1 1.0 1.8 50.0 99.0
2 virginica 4.9 7.9 2.2 3.8 4.5 6.9 1.4 2.5 100.0 149.0

Pandas を使うからには Pandas の文化に従うべきだとは思いますが……

データフレームの結合 dplyr::*_join

(調査中です)

縦持ち横持ち変換 tidyr::gather tidyr::spread

Pandas で tidyr::gathertidyr::spread をやるためには、冒頭の前置きで触れた Index とまじめに向き合う必要があります。SQL-style を貫き通すことはできません……

stacktidyr::gather 相当の操作をやる方法

縦持ちにするには、stack メソッドを使います。縦持ちしたくない列はすべて set_index メソッドであらかじめ Index に移しておく必要があります。

# iris %>% gather(key = NA, value = NA, -id, -species)
iris. \
set_index(["id", "species"]). \
stack()
実行結果
id   species                
0    setosa     sepal_length    5.1
                sepal_width     3.5
                petal_length    1.4
                petal_width     0.2
1    setosa     sepal_length    4.9
                sepal_width     3.0
                petal_length    1.4
                petal_width     0.2
...

stack メソッドで縦持ちにすると MultiIndex の Series オブジェクトが返ってきます。普段 dplyr を使っている身としては SQL-style の DataFrame オブジェクトに直したい気持ちでいっぱいですが、MultiIndex を積極的に使っていけば後続の処理も Series オブジェクトのままで特に困りません。例えば、縦持ちした後に species 別・変数別の最小値と最大値を集計するには次のようにします。

# iris %>%
# gather(key = key, value = value, -Species) %>%  # 本当は key = NA, value = NA 相当
# group_by(Species, key) %>%
# summarise_all(funs(min, max))
iris. \
set_index(["id", "species"]). \
stack(). \
groupby(level = [1, 2]). \
agg([min, max])
min max
species
setosa sepal_length 4.3 5.8
sepal_width 2.3 4.4
petal_length 1.0 1.9
petal_width 0.1 0.6
versicolor sepal_length 4.9 7.0
sepal_width 2.0 3.4
petal_length 3.0 5.1
petal_width 1.0 1.8
virginica sepal_length 4.9 7.9
sepal_width 2.2 3.8
petal_length 4.5 6.9
petal_width 1.4 2.5

melttidyr::gather 相当の操作をやる方法

一応、melt メソッドを使えば tidyr::gather と同等の操作を実現できます。メソッドの使い勝手も得られる結果もほぼ tidyr::gather と同じです。ただし、この後で説明しますが tidyr::spread に対応するメソッドが Pandas には存在しないため、縦持ち横持ちの往復を SQL-style で貫き通すことができません。貫けないのであれば stack で縦持ち変換を行うほうがよいと判断しました。

# iris %>% gather(key = key, value = value, -id, -species)
iris.melt(var_name = "key", value_name = "value", id_vars = ["id", "species"])
id species key value
0 0 setosa sepal_length 5.1
1 1 setosa sepal_length 4.9
2 2 setosa sepal_length 4.7
3 3 setosa sepal_length 4.6
4 4 setosa sepal_length 5.0

unstacktidyr::spread に相当する操作をやる方法

横持ちにするには、unstack メソッドを使います。stack メソッドと同じで、横持ちしたくない列はすべて set_index メソッドであらかじめ Index に移しておく必要があります。

# iris_stacked <-iris %>% gather(key = key, value = value, -id, -species)
iris_stacked = iris.melt(var_name = "key", value_name = "value", id_vars = ["id", "species"])

# iris_stacked %>% spread(key = key, value = value, -id, -species)
iris_stacked. \
set_index(["id", "species", "key"]). \
unstack()
value
key petal_length petal_width sepal_length sepal_width
id species
0 setosa 1.4 0.2 5.1 3.5
1 setosa 1.4 0.2 4.9 3.0
2 setosa 1.3 0.2 4.7 3.2
3 setosa 1.5 0.2 4.6 3.1
4 setosa 1.4 0.2 5.0 3.6

DataFrame オブジェクトに対して unstack メソッドを呼び出すと複数の列を横持ちにする想定の挙動になるため、得られる結果の列名が MultiIndex になります。横持ちしたい列が一つしかない場合は、set_index メソッドで横持ち不要な列を Index に移した後に [] で横持ちしたい列だけ取り出して (Series オブジェクトにして) unstack メソッドを呼ぶことで得られる結果の列名がフラットになります。合わせて reset_index メソッドで Index を列に移すと、SQL-style にできます。

iris_stacked. \
set_index(["id", "species", "key"])["value"]. \
unstack(). \
reset_index()
key id species petal_length petal_width sepal_length sepal_width
0 0 setosa 1.4 0.2 5.1 3.5
1 1 setosa 1.4 0.2 4.9 3.0
2 2 setosa 1.3 0.2 4.7 3.2
3 3 setosa 1.5 0.2 4.6 3.1
4 4 setosa 1.4 0.2 5.0 3.6

pivottidyr::spread 相当の操作をやる方法

一応、pivot メソッドを使えば tidyr::spread と同等の操作を実現できます。メソッドの使い勝手もほぼ tidyr::spread と同じです。ただし大きな違いとして、横持ちしない列は一つしか選べない (pivot(index = ...)... には列を一つしか指定できない) という制約があります。そのため、複合キーを持つ場合や一部の列のみ横持ちさせたい場合に pivot メソッドは使えません。

# iris_stacked %>% spread(key = key, value = value, -id)
iris_stacked.pivot(index = "id", columns = "key", values = "value")
key petal_length petal_width sepal_length sepal_width
id
0 1.4 0.2 5.1 3.5
1 1.4 0.2 4.9 3.0
2 1.3 0.2 4.7 3.2
3 1.5 0.2 4.6 3.1
4 1.4 0.2 5.0 3.6

参考

Pandas と dplyr の対比については、以下のページを参考にしました。

各メソッドの細かい使い方については、以下のページを参考にしました。

63
60
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
63
60