StatsFragments

Python, R, Rust, 統計, 機械学習とか

Python pandas 図でみる データ連結 / 結合処理

なんかぼやぼやしているうちにひさびさの pandas エントリになってしまった。基本的な使い方については網羅したい気持ちはあるので、、、。

今回は データの連結 / 結合まわり。この部分 公式ドキュメント がちょっとわかりにくいので改訂したいなと思っていて、自分の整理もかねて書きたい。

公式の方はもう少し細かい使い方も載っているのだが、特に重要だろうというところだけをまとめる。

連結 / 結合という用語は以下の意味で使っている。まず憶えておいたほうがよい関数、メソッドは以下の 4 つだけ。

  • 連結: データの中身をある方向にそのままつなげる。pd.concat, DataFrame.append
  • 結合: データの中身を何かのキーの値で紐付けてつなげる。pd.merge, DataFrame.join

連結 (concatenate)

柔軟な連結 pd.concat

ふたつの DataFrame の連結は pd.concat で行う ( DataFrame.concat ではない)。元データとする DataFramedf1df2 としてそれぞれ以下のように定義する。

import pandas as pd
df1 = pd.DataFrame({'A': ['A0', 'A1', 'A2', 'A3'],
                    'B': ['B0', 'B1', 'B2', 'B3'],
                    'C': ['C0', 'C1', 'C2', 'C3'],
                    'D': ['D0', 'D1', 'D2', 'D3']},
                   index=[0, 1, 2, 3])

df2 = pd.DataFrame({'A': ['A4', 'A5', 'A6', 'A7'],
                    'B': ['B4', 'B5', 'B6', 'B7'],
                    'C': ['C4', 'C5', 'C6', 'C7'],
                    'D': ['D4', 'D5', 'D6', 'D7']},
                   index=[4, 5, 6, 7])
基本的な連結

この 2 つのデータに対する pd.concat の結果は下図 Result で示すもの = 縦方向の連結になる。pd.concat の引数は連結したい DataFrame のリスト [df1, df2] になる。Result の上半分が df1, 下半分が df2 に対応している。

pd.concat([df1, df2])

f:id:sinhrks:20150125225941p:plain

引数には 3つ以上の DataFrame からなるリストを渡してもよい。結果は 各 DataFrame が順に縦方向に連結されたものになる。

df3 = pd.DataFrame({'A': ['A8', 'A9', 'A10', 'A11'],
                    'B': ['B8', 'B9', 'B10', 'B11'],
                    'C': ['C8', 'C9', 'C10', 'C11'],
                    'D': ['D8', 'D9', 'D10', 'D11']},
                   index=[8, 9, 10, 11])

pd.concat([df1, df2, df3])

f:id:sinhrks:20150125225948p:plain

列名が異なる場合の連結

上ふたつの例では連結する各 DataFrame の 列名 ( columns )はすべて ['A', 'B', 'C', 'D'] で同一だったので気にする必要はなかったが、内部的には 各列は 列名で紐付けされてから連結されている。

以下のように 列名が異なる場合、元データ両方に存在する 列 ['B', 'D'] はそれそれ紐付けられて縦連結され、片方にしか存在しない列は 空の部分が NaN でパディングされる。

df4 = pd.DataFrame({'B': ['B2', 'B3', 'B6', 'B7'],
                    'D': ['D2', 'D3', 'D6', 'D7'],
                    'F': ['F2', 'F3', 'F6', 'F7']},
                   index=[2, 3, 6, 7])

pd.concat([df1, df4])

f:id:sinhrks:20150125230000p:plain

補足 連結方向のラベル ( 縦方向の場合 行名 = index ) 同士は紐付けされず 常にそのまま連結される。出力をみると、 df1index である [0, 1, 2, 3]df2index である [2, 3, 6, 7] は一部重複しているが、紐付けされず元の順序のまま連結されている。連結方向のラベルを重複させたくない場合の処理は後述。

横方向の連結

横方向に連結したい場合は axis=1 を指定。このとき 直前の例とは逆に 紐付けは 連結方向でないラベル = index について行われる。 連結方向のラベルにあたる columns はそのまま維持される。

pd.concat([df1, df4], axis=1)

f:id:sinhrks:20150125230033p:plain

連結処理の指定

上でみたとおり、pd.concat による連結では、データを "連結方向でないラベル" で紐付けしてから 連結していた。この紐付けは元データをすべて残す形 = 完全外部結合のような形で行われている。

引数の各データに共通のラベルのみを残して連結したい場合は join='inner' を指定する。下の例では横連結を指定しているため、共通の index である [2, 3] に対応する行のみが残る。

pd.concat([df1, df4], axis=1, join='inner')

f:id:sinhrks:20150125230043p:plain

また、紐付け時に特定のラベルのみを残したい場合もある。そのときは join_axes で残したいラベルの名前を指定すればよい。

例えば axis=1 横方向に連結するとき、join_axes に ひとつめの DataFrameindex を指定すると、それらだけが残るため 左外部結合のような処理になり、

pd.concat([df1, df4], axis=1, join_axes=[df1.index])

f:id:sinhrks:20150125230051p:plain

ふたつめの DataFrameindex を指定すると 右外部結合のような処理になる。

pd.concat([df1, df4], axis=1, join_axes=[df4.index])

f:id:sinhrks:20150127234220p:plain

連結方向のラベルの指定

pd.concat は既定では 連結方向のラベル ( 縦連結の場合は index、横連結の場合は columns ) に対しては特に変更を行わない。そのため、上の例のように 連結結果に同名のラベルが重複してしまうことがある。

こういう場合、連結元のデータごとに keys キーワードで指定したラベルを追加で付与することができる。このとき、結果は 複数のレベルをもつ MultiIndex になる ( MultiIndex については別途)。これは図だけでもわかりにくいので テキストでの出力も添付。

pd.concat([df1, df4], axis=1, keys=['X', 'Y'])

#      X                   Y          
#      A    B    C    D    B    D    F
# 0   A0   B0   C0   D0  NaN  NaN  NaN
# 1   A1   B1   C1   D1  NaN  NaN  NaN
# 2   A2   B2   C2   D2   B2   D2   F2
# 3   A3   B3   C3   D3   B3   D3   F3
# 6  NaN  NaN  NaN  NaN   B6   D6   F6
# 7  NaN  NaN  NaN  NaN   B7   D7   F7

f:id:sinhrks:20150125230058p:plain

もしくは、ignore_index=True を指定して 連結方向のラベルを 0 から振りなおすことができる。これは縦連結のときに index を連番で振りなおす場合に便利。

pd.concat([df1, df4], ignore_index=True)

f:id:sinhrks:20150128002540p:plain

縦方向のシンプルな連結 DataFrame.append

pd.concat でたいていの連結はできる。うち、よく使う 縦方向の連結については DataFrame.append でよりシンプルに書ける。

df1.append(df2)

f:id:sinhrks:20150125230116p:plain

引数には DataFrame のリストも渡せる。

df1.append([df2, df4])

f:id:sinhrks:20150125230124p:plain

また、データに一行追加したい、なんて場合も DataFrame.append。このとき、引数は 追加する行に対応した Series になる。

このとき、連結対象の DataFramecolumnsSeriesindex どうしが紐付けられて連結される。そのため、行として追加する Series は以下のような形で作る。Seriesname 属性を持っている場合は 連結後の行の indexname で指定されたもの (ここでは 10 ) になる。

s1 = pd.Series(['X0', 'X1', 'X2', 'X3'],
               index=['A', 'B', 'C', 'D'], name=10)
df1.append(s1)

f:id:sinhrks:20150125230133p:plain

name 属性のない Series を連結したい場合は ignore_index=True を指定しないとエラーになる。

s2 = pd.Series(['X0', 'X1', 'X2', 'X3'],
               index=['A', 'B', 'C', 'D'])

# NG!
df1.append(s2)
# TypeError: Can only append a Series if ignore_index=True or if the Series has a name

# OK
df1.append(s2, ignore_index=True)

f:id:sinhrks:20150125230141p:plain

補足 append の際、内部では毎回 元データも含めた全体のコピー処理が走るので、ループで一行ずつ追加するような処理は避けたほうがよい。

結合 (merge)

列の値による結合 pd.merge

ふたつの DataFrame の結合は pd.merge もしくは DataFrame.merge で行う。結合もとの DataFrameleftright としてそれぞれ以下のように定義する。

left = pd.DataFrame({'key': ['K0', 'K1', 'K2', 'K3'],
                     'A': ['A0', 'A1', 'A2', 'A3'],
                     'B': ['B0', 'B1', 'B2', 'B3']})

right = pd.DataFrame({'key': ['K1', 'K3', 'K5', 'K7'],
                      'C': ['C1', 'C3', 'C5', 'C7'],
                      'D': ['D1', 'D3', 'D5', 'D7']},
                     index=[1, 3, 5, 7])

この例では 作成した DataFrame 中の key カラムを結合時のキーとし、この列の値が同じ行どうしを結合したい。結合時のキーとなる列名は on キーワードで指定する。既定では内部結合となり、両方のデータに共通の ['K1', 'K3'] に対応する行どうしが結合されて残る。

pd.merge(left, right, on='key')

f:id:sinhrks:20150125230151p:plain

結合方法は how キーワードで指定する。指定できるのは、

  • inner: 既定。内部結合。両方のデータに含まれるキーだけを残す。
  • left: 左外部結合。ひとつめのデータのキーをすべて残す。
  • right: 右外部結合。ふたつめのデータのキーをすべて残す。
  • outer: 完全外部結合。すべてのキーを残す。

それぞれの出力を順に図示する。

pd.merge(left, right, on='key', how='left')

f:id:sinhrks:20150125230158p:plain

pd.merge(left, right, on='key', how='right')

f:id:sinhrks:20150125230208p:plain

pd.merge(left, right, on='key', how='outer')

f:id:sinhrks:20150125230218p:plain

複数のキーによる結合

キーとして複数の列を指定したい場合は、on キーワードにキーとする列名のリストを渡せばよい。

left = pd.DataFrame({'key1': ['K0', 'K0', 'K1', 'K2'],
                     'key2': ['K0', 'K1', 'K0', 'K1'],
                     'A': ['A0', 'A1', 'A2', 'A3'],
                     'B': ['B0', 'B1', 'B2', 'B3']})

right = pd.DataFrame({'key1': ['K0', 'K1', 'K1', 'K2'],
                      'key2': ['K0', 'K0', 'K0', 'K0'],
                      'C': ['C0', 'C1', 'C2', 'C3'],
                      'D': ['D0', 'D1', 'D2', 'D3']})
pd.merge(left, right, on=['key1', 'key2'])

f:id:sinhrks:20150125230229p:plain

index による結合 DataFrame.join

各データの index をキーとして結合したい場合は、DataFrame.join が便利。既定は左外部結合となり、結合方法は how で変更できる。指定できるオプションは pd.merge と同じ。

left = pd.DataFrame({'A': ['A0', 'A1', 'A2'],
                     'B': ['B0', 'B1', 'B2']},
                    index=['K0', 'K1', 'K2'])

right = pd.DataFrame({'C': ['C0', 'C2', 'C3'],
                      'D': ['D0', 'D2', 'D3']},
                     index=['K0', 'K2', 'K3'])
left.join(right)

f:id:sinhrks:20150125230238p:plain

left.join(right, how='inner')

f:id:sinhrks:20150125230257p:plain

left.join(right, how='right')

f:id:sinhrks:20150125230304p:plain

left.join(right, how='outer')

f:id:sinhrks:20150125230248p:plain

補足 pd.merge でも left_index ならびに right_index キーワードによって index をキーとした結合はできる。

まとめ

pandas でのデータ連結 / 結合まわりを整理した。これ以外の データ変形 (行持ち / 列持ち変換とか) は R の {dplyr}{tidyr} との対比でまとめたやつがあるのだが、列名 や行名が複数のレベルを持つ = MultiIndex の場合など pandas 固有のものもあるのでまた別途。

2015/05/12追記

公式ドキュメントに反映した。こちらのほうが網羅的。

Merge, join, and concatenate — pandas 0.16.2 documentation

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Python pandas strアクセサによる文字列処理

概要

今週の 週刊 pandas は文字列処理について。やたらと文字数が多くなったのだが、これはデータを都度表示しているせいであって自分の話がムダに長いわけではない、、、と思いたい。

今回はこちらの記事に書いた内容も使うので、適宜ご参照ください。

サンプルデータ

なんか適当な実データないかな?と探していたら 週間少年ジャンプの過去作品の連載作品 / ジャンルなどがまとめられているサイトをみつけた。これを pandas で集計できる形まで整形することをゴールにしたい。

KTR's Comic Room: Weekly Jump Database

データの読み込み

上記リンクの "ジャンプ連載データ表" を、ファイル名 "jump_db.html" としてローカルに保存した。

補足 pd.read_html では引数に URL を渡して 直接ネットワークからファイルを読むこともできる。が、今回は データ元サイトへの負荷をさけるため、ローカルの HTML ファイルを読むことにした。

依存パッケージのインストール

pd.read_html を利用するためには 以下のパッケージが必要。インストールしていない場合はインストールする。

pip install lxml html5lib beautifulsoup4

準備

numpy, pandas をロードする。

# おまじない
from __future__ import unicode_literals
import numpy as np
import pandas as pd

# 表示する行数を指定
pd.options.display.max_rows = 10

HTML ファイルの読み込み

pd.read_html では 対象の HTML ファイルに含まれる TABLE 要素 を DataFrame として読み出す。ひとつの html ファイルには複数TABLE が含まれることもあるため、pd.read_html の返り値は DataFrame のリストになっている。

# read_htmlの返り値は DataFrame のリストになる
dfs = pd.read_html('jump_db.html', header=0)
type(dfs)
# list

# 最初のDataFrameを取り出す
df = dfs[0]

df
#        開始    終了   回数           タイトル      作者  原作者・その他          ジャンル
# 0    6801  7144  131            父の魂   貝塚ひろし      NaN           NaN
# 1    6801  6811   11          くじら大吾   梅本さちお      NaN           NaN
# 2    6804  6913   21        おれはカミカゼ   荘司としお      NaN           NaN
# 3    6808  7030   60       漫画コント55号    榎本有也      NaN           NaN
# 4    6810  6919   21           男の条件   川崎のぼる  原作/梶原一騎           NaN
# ..    ...   ...  ...            ...     ...      ...           ...
# 667  1442   連載中  12+      ハイファイクラスタ    後藤逸平      NaN  近未来,SF,刑事,一芸
# 668  1443   連載中  11+  Sporting Salt  久保田ゆうと      NaN     スポーツ医学,学園
# 669  1451   連載中   3+         卓上のアゲハ     古屋樹      NaN    卓球,エロ,ミステリ
# 670  1452   連載中   2+        E−ROBOT    山本亮平      NaN       エロ,ロボット
# 671  1501   連載中   1+           学糾法廷     小畑健   原作/榎伸晃         学園,裁判
# 
# [672 rows x 7 columns]

# 後続処理でのエラーチェックのため、最初のレコード数を保存しておく
original_length = len(df)

最近の新連載はエロばっかりなの、、、??ちょっと心惹かれるがまあそれはいい。

各カラムのデータ型を確認するには df.dtypes 。文字列が含まれるカラムは object 型になっている。

# カラムのデータ型を表示
df.dtypes
# 開始         float64
# 終了          object
# 回数          object
# タイトル        object
# 作者          object
# 原作者・その他     object
# ジャンル        object
# dtype: object

str アクセサ

以降の処理は pandasstr アクセサを中心に行う。pandas では内部のデータ型が文字列 ( str もしくは unicode ) 型のとき、 str アクセサを使って データの各要素に対して文字列メソッドを適用することができる。str アクセサから使えるメソッドの一覧はこちら

補足 v0.15.1 時点では str アクセサからは使えない Python 標準の文字列メソッドもある。そんなときは applyapply についてはこちら

例えば、文字列を小文字化する lower メソッドを各要素に対して適用する場合はこんな感じ。

# 各要素が大文字の Series を作成
d.Series(['AAA', 'BBB', 'CCC'])
# 0     AAA
# 1     BBB
# 2     CCC
# dtype: object

# str アクセサを通じて、各要素に lower メソッドを適用
pd.Series(['AAA', 'BBB', 'CCC']).str.lower()
# 0    aaa
# 1    bbb
# 2    ccc
# dtype: object

# str アクセサを使わないと AttributeError になり NG!
pd.Series(['AAA', 'BBB', 'CCCC']).lower()
# AttributeError: 'Series' object has no attribute 'lower'

また、str アクセサに対するスライシングは各要素へのスライシングになる。各要素の最初の二文字を取り出す場合、

pd.Series(['AAA', 'BBB', 'CCC']).str[0:2]
# 0    AA
# 1    BB
# 2    CC
# dtype: object

データの処理

ここからサンプルデータへの処理を始める。

連載継続中かどうかのフラグ立て

元データでは、各作品が現在も連載中かどうかは以下いずれかでわかる。

  • "終了" カラムの値が "連載中" である
  • "回数" カラムの末尾に + がついている
# 回数 カラムの値を確認
df['回数']
# 0    131
# 1     11
# 2     21
# ...
# 669    3+
# 670    2+
# 671    1+
# Name: 回数, Length: 672, dtype: object

が、このままでは機械的に扱いにくいため、連載中かどうかで bool のフラグをたてたい。そのためには、以下どちらかの処理を行えばよい。

  • "終了" カラムに対する論理演算を行う
  • "回数" カラムの末尾を str.endswith で調べる
# 終了カラムの値で判別する場合
df['終了'] == '連載中'
# 0    False
# 1    False
# 2    False
# ...
# 669    True
# 670    True
# 671    True
# Name: 終了, Length: 672, dtype: bool

# 回数カラムの値で判別する場合
df['回数'].str.endswith('+')
# 0    False
# 1    False
# 2    False
# ...
# 669    True
# 670    True
# 671    True
# Name: 回数, Length: 672, dtype: bool

# 結果を 連載中 カラムとして代入
df['連載中'] = df['回数'].str.endswith('+')

連載回数のパース

続けて、現在は文字列 ( object ) 型として読み込まれている連載回数を数値型として扱いたい。が、上のとおり 連載中のレコードには末尾に +, 確実な連載回数が取得できているレコードは [ ] で囲われているため、そのままでは数値型へ変換できない。

# そのまま変換しようとすると NG!
df['回数'].astype(float)
# ValueError: invalid literal for float(): 1+

数値に変換するためには、文字列中の数値部分だけを切り出してから数値に変換すればよい。str.extract を使うとデータに対して指定した正規表現とマッチする部分を抽出できる。抽出した数値部分に対して astype を使って float 型へ変換する。

# 数値部分だけを抽出 (この時点では各要素は文字列)
['回数'].str.extract('([0-9]+)')
# 0    131
# 1     11
# 2     21
# ...
# 669    3
# 670    2
# 671    1
# Name: 回数, Length: 672, dtype: object

# float 型へ変換 (表示の dtype 部分で型がわかる)
df['回数'].str.extract('([0-9]+)').astype(float)
# 0    131
# 1     11
# 2     21
# ...
# 669    3
# 670    2
# 671    1
# Name: 回数, Length: 672, dtype: float64

補足 1件 元データが "??" のものがある。この文字列は数値を含まないため、str.extract の結果は NaN になる。NaN を含むデータは int 型へは変換できない。

# NG! NaN があるため int 型へは変換できない
df['回数'].str.extract('([0-9]+)').astype(int)
# ValueError: cannot convert float NaN to integer

上記の結果を元のカラムに代入して上書きする。その後、dropnaNaN のデータを捨てる。dropna についてはこちら

# 結果を上書き
df['回数'] = df['回数'].str.extract('([0-9]+)').astype(float)

# 回数 が NaN のデータを捨てる
df = df.dropna(axis=0, subset=['回数'])

# 1レコードがフィルタされたことを確かめる
assert len(df) + 1 == original_length

df.shape
# (671, 8)

連載開始時のタイムスタンプ取得

"開始" カラムの値から 年度, 週を取得。元データは、通常は "9650" のように年下二桁 + 週 の4桁、合併号では "9705.06" のようなドット区切りの値が入っている。そのため、read_heml では float 型としてパースされている。これを datetime 型に変換したい。

ここでは N 号は その年の N 週 月曜に発行されたものとして日付を埋める。実際には号数と週番号はずれる / 合併号は発行曜日がずれる / 昔は発行曜日が違っていた、と様々な問題があるがここでは無視する。

素直にやるなら apply を使ってこんな感じで書ける。pd.offsets についてはこちら

import datetime

def parse_date(x):
    # 小数点を切り捨て
    x = np.floor(x)
    y, w = divmod(x, 100)
    # y が50より大きければ 1900年代, それ以外なら2000年代として扱う
    y += 1900 if y > 50 else 2000
    d = datetime.datetime(int(y), 1, 1)
    # 週次のオフセットを追加
    d += pd.offsets.Week(int(w), weekday=0)
    return d

df['開始'].apply(parse_date)
# 0   1968-01-08
# 1   1968-01-08
# 2   1968-01-29
# ...
# 669   2014-12-22
# 670   2014-12-29
# 671   2015-01-05
# Name: 開始, Length: 671, dtype: datetime64[ns]

今回は文字列処理の記事なのであえて文字列として処理する。最初の手順は以下。

  • 処理対象のカラムを astype で文字列型に変換し、適当な変数に代入する。
  • str.split で小数点前後で文字列を分割。返り値を DataFrame で受け取るため return_type='frame' を指定。
  • 小数点の前の文字列のみ = 1列目のみ を処理対象の変数に代入しなおす。
# 処理対象のカラムを文字列型に変換
dt = df['開始'].astype(str)
dt
# 0    6801.0
# 1    6801.0
# 2    6804.0
# ...
# 669    1451.0
# 670    1452.0
# 671    1501.0
# Name: 開始, Length: 671, dtype: object

# str.split で小数点前後を分割
dt.str.split('.', return_type='frame')
#         0  1
# 0    6801  0
# 1    6801  0
# 2    6804  0
# 3    6808  0
# 4    6810  0
# ..    ... ..
# 667  1442  0
# 668  1443  0
# 669  1451  0
# 670  1452  0
# 671  1501  0
# 
# [671 rows x 2 columns]

# 小数点の前の文字列のみを処理対象の変数に代入
dt = dt.str.split('.', return_type='frame')[0]
dt
# 0    6801
# 1    6801
# 2    6804
# ...
# 669    1451
# 670    1452
# 671    1501
# Name: 0, Length: 671, dtype: object

ここで、values プロパティを使って内部の値を確認する。00年代の値は数値の桁数が異なるため、文字列長も異なっていることがわかる。このままではパースしにくいため、以下の処理を行う。

  • str.pad で指定した長さまで空白文字でパディングする。
  • str.replaceで空白文字を "0" に置き換える。
# 変数内部の値を確認
dt.values
# array([u'6801', u'6801', u'6804', u'6808', u'6810', u'6811', u'6811',
#        .....
#        u'9933', u'9934', u'9943', u'9943', u'9944', u'1', u'2', u'12',
#        u'13', u'23', u'24', u'32', u'33', u'34', u'38', u'47', u'48',
#        .....
#        u'1451', u'1452', u'1501'], dtype=object)

# str.pad で指定した長さまでパディング
dt.str.pad(4).values
# array([u'6801', u'6801', u'6804', u'6808', u'6810', u'6811', u'6811',
#        .....
#        u'9933', u'9934', u'9943', u'9943', u'9944', u'   1', u'   2',
#        u'  12', u'  13', u'  23', u'  24', u'  32', u'  33', u'  34',
#        .....
#        u'1441', u'1442', u'1443', u'1451', u'1452', u'1501'], dtype=object)

# str.replace で空白文字を "0" に置換
dt.str.pad(4).str.replace(' ', '0').values
# array([u'6801', u'6801', u'6804', u'6808', u'6810', u'6811', u'6811',
#        .....
#        u'0012', u'0013', u'0023', u'0024', u'0032', u'0033', u'0034',
#        .....
#        u'1441', u'1442', u'1443', u'1451', u'1452', u'1501'], dtype=object)

# ここまでの処理結果を代入
dt = dt.str.pad(4).str.replace(' ', '0')

ここまでで、変数 dt の中身は "年2桁 + 週2桁" の4文字からなる文字列になった。こいつに対して、

  • スライシングによって文字列を 年, 週の部分文字列に分割する。
  • 年については下2桁しかないため、 where で場合分けして 西暦4桁の文字列にする。where についてはこちら。これで pd.to_datetime で処理できる形になる。
  • pd.to_datetime で日付型にパース。入力は年のみのため、format='%Y' を指定する。pd.to_datetime についてはこちら
# スライシングで年, 週に分割
year = dt.str[0:2]
week = dt.str[2:4]

# 西暦4桁の文字列を作成
year = ('19' + year).where(year > '50', '20' + year)
year
# 0    1968
# 1    1968
# 2    1968
# ...
# 669    2014
# 670    2014
# 671    2015
# Name: 0, Length: 671, dtype: object

# pd.to_datetime で日付型にパース
year = pd.to_datetime(year, format='%Y')
year
# 0   1968-01-01
# 1   1968-01-01
# 2   1968-01-01
# ...
# 669   2014-01-01
# 670   2014-01-01
# 671   2015-01-01
# Name: 0, Length: 671, dtype: datetime64[ns]

ここまでできれば、あと必要な処理は、

  • 週の文字列から、pd.offsets.Week インスタンスを作る
  • 年から生成したタイムスタンプを Week 分ずらす
# 週の文字列から、pd.offsets.Week インスタンスを作る
week = [pd.offsets.Week(w, weekday=0) for w in week.astype(int)]
week
# [<Week: weekday=0>,
#  <Week: weekday=0>,
#  <4 * Weeks: weekday=0>,
#  .....
#  <52 * Weeks: weekday=0>,
#  <Week: weekday=0>]

# 10. 年から生成したタイムスタンプを 週番号分ずらす
dt = [y + w for y, w in zip(year, week)]
dt
# [Timestamp('1968-01-08 00:00:00'),
#  Timestamp('1968-01-08 00:00:00'),
#  Timestamp('1968-01-29 00:00:00'),
#  .....
#  Timestamp('2014-12-29 00:00:00'),
#  Timestamp('2015-01-05 00:00:00')]

# 元の DataFrame に対して代入
df.loc[:, '開始'] = dt

原作有無でのフラグ立て

元データの "原作者・その他" カラムをみると、ここには原作ほか関係者 (監修とか) の名前も入っている。

df[~pd.isnull(df['原作者・その他'])]['原作者・その他']
# 4       原作/梶原一騎
# 9     原作/スドウテルオ
# 28      原作/藤井冬木
# ...
# 660    原作/成田良悟
# 661    原作/下山健人
# 671     原作/榎伸晃
# Name: 原作者・その他, Length: 111, dtype: object

"原作者・その他" カラムの値から 原作者がいる場合だけフラグを立てたい。具体的には、カラムに "原作/"を含む値が入っているときは 原作ありとして扱いたい。 そんな場合は str.contains

補足 元データでは 原作者は常に先頭に来ているので、str.startswith でもよい。

df['原作者・その他'].str.contains('原作/')
# 0    NaN
# ...
# 671    True
# Name: 原作者・その他, Length: 672, dtype: object

# 結果をカラムに代入
df['原作あり'] = df['原作者・その他'].str.contains('原作/')

重複データの削除

一部のシリーズでは 全体と 各部(第一部、二部など) で重複してデータが取得されている。目視でそれっぽいシリーズを抽出して表示してみる。

# 重複しているシリーズ候補
series = ['コブラ', 'ジョジョの奇妙な冒険', 'BASTARD!!',
          'みどりのマキバオー', 'ONE PIECE', 'ボボボーボ・ボーボボ',
          'DEATH NOTE', 'スティール・ボール・ラン', 'トリコ', 'NARUTO']

for s in series:
    # 重複しているシリーズ候補を タイトルに含むデータを取得
    dup = df[df['タイトル'].str.contains(s)]
    print(dup[['開始', '回数', 'タイトル']])

#           開始     回数                     タイトル
# 299  8701.02    593               ジョジョの奇妙な冒険
# 300  8701.02     44          ジョジョの奇妙な冒険(第1部)
# 314  8747.00     69          ジョジョの奇妙な冒険(第2部)
# 335  8916.00    152          ジョジョの奇妙な冒険(第3部)
# 378  9220.00    174          ジョジョの奇妙な冒険(第4部)
# 433  9552.00    154          ジョジョの奇妙な冒険(第5部)
# 489     1.00  [158]  ジョジョの奇妙な冒険第6部・ストーンオーシャン

# .....後略

上の例で言うと、"ジョジョの奇妙な冒険" シリーズの回数として 1部 〜 5部の回数分が入っているようだ。同様にシリーズ全体の連載回数に含まれていると思われるレコードを目視で抽出、削除。

# 削除対象タイトル
dups = ['ジョジョの奇妙な冒険(第1部)', 'ジョジョの奇妙な冒険(第2部)', 'ジョジョの奇妙な冒険(第3部)',
        'ジョジョの奇妙な冒険(第4部)', 'ジョジョの奇妙な冒険(第5部)',
        'BASTARD!!〜暗黒の破壊神〜(闇の反逆軍団編)', 'BASTARD!!〜暗黒の破壊神〜(地獄の鎮魂歌編)',
        'みどりのマキバオー(第1部)', 'みどりのマキバオー(第2部)',
        'ONE PIECE《サバイバルの海 超新星編》', 'ONE PIECE《最後の海 新世界編》',
        'ボボボーボ・ボーボボ(第1部)', '真説ボボボーボ・ボーボボ',
        'DEATH NOTE(第1部)', 'DEATH NOTE(第2部)',
        'スティール・ボール・ラン 1st Stage', 'スティール・ボール・ラン 2nd Stage',
        'トリコ〜人間界編〜', 'トリコ〜グルメ界編〜', 'NARUTO(第一部)', 'NARUTO(第二部)']

# タイトルの値が dups に含まれるレコードを除外
df = df[~df['タイトル'].isin(dups)]

# dups 分のレコードがフィルタされたことを確かめる
assert len(df) + len(dups) + 1 == original_length

df.shape
# (652, 9)

ジャンルデータの作成

"ジャンル" カラムには複数のジャンルが カンマ区切りで含まれている。

df['ジャンル']
# 0    NaN
# 1    NaN
# 2    NaN
# ...
# 669    卓球,エロ,ミステリ
# 670       エロ,ロボット
# 671         学園,裁判
# Name: ジャンル, Length: 652, dtype: object

これを集計しやすい形にするため、各ジャンルに該当するかどうかを bool でフラグ立てしたい。 このカラムには "秘密警察" とか "ゴム人間" なんて値も入っているため、ユニークな件数をカウントして上位ジャンルのみフィルタする。

まずは、カンマ区切りになっている値を分割して、ジャンル個々の値からなる Series を作る。

# NaN をフィルタ
genres = df['ジャンル'].dropna()
# カンマ区切りの値を split して、一つのリストとして結合
genres = reduce(lambda x, y: x + y, genres.str.split(','))
# 
genres = pd.Series(genres)
genres
# 0      硬派
# 1      不良
# 2    アニメ化
# ...
# 1358    ロボット
# 1359      学園
# 1360      裁判
# Length: 1361, dtype: object

Series 内の要素がそれぞれいくつ含まれるかをカウントするには value_counts()

genres.value_counts()
# ギャグ     76
# 学園      75
# アニメ化    74
# ...
# 演歌           1
# 学園ショートギャグ    1
# スポーツ?        1
# Length: 426, dtype: int64

# 上位10件を表示
genres.value_counts()[:10]
# ギャグ       76
# 学園        75
# アニメ化      74
# 格闘        36
# 一芸        32
# ファンタジー    32
# ゲーム化      30
# 変身        28
# ラブコメ      28
# 不良        23
# dtype: int64

とりあえず 以下の3ジャンルをみることにする。各レコードについてジャンルは複数あてはまりうる。そのため、それぞれのフラグについて dummy のカラムを作成し、ジャンルに該当する場合に True を入れる。

genres = ['ギャグ', '格闘', 'ラブコメ']

# str アクセサを利用するため NaN 値をパディング
df['ジャンル'] = df['ジャンル'].fillna('')
for genre in genres:
    df[genre] = df['ジャンル'].str.contains(genre)

できあがり

# 必要なカラムのみにフィルタ
df = df[['開始', '回数', 'タイトル', '連載中', '原作あり', 'ギャグ', '格闘', 'ラブコメ']]
df

できたデータはこんな感じ。俺たちの分析はここからだ!

index 開始 回数 タイトル 連載中 原作あり ギャグ 格闘 ブコメ
0 1968-01-08 131 父の魂 False NaN False False False
... ... ... ... ... ... ... ... ...
667 2014-10-20 12 ハイファイクラスタ True NaN False False False
668 2014-10-27 11 Sporting Salt True NaN False False False
669 2014-12-22 3 卓上のアゲハ True NaN False False False
670 2014-12-29 2 E−ROBOT True NaN False False False
671 2015-01-05 1 学糾法廷 True True False False False

まとめ

pandas で文字列処理する場合は str アクセサ。

補足 また、以下のサイトには毎週の掲載順位を含むデータがある。こちらのほうが興味深いが、前処理グレードがちょっと高かったのであきらめた。

おまけ: かんたんに集計

自分としては 想定の前処理ができた時点でもういいかって感じなのだが、簡単に集計してみる。

新連載数

各年 (年度ではなく calendar year) の新連載開始数を時系列でプロット。69年10月に週刊化されたらしく、直後の新連載数が特に多い。

import matplotlib.pyplot as plt
summarised = df.groupby(df['開始'].dt.year)['タイトル'].count()
ax = summarised.plot(figsize=(7, 3))
ax.set_title('新連載数')

f:id:sinhrks:20141206231815p:plain

連載回数

連載回数の頻度分布をみてみると、80年より前には10週以下で短期連載 or 連載終了した作品が比較的 多そう。80年代以降の分布が現在の感覚に近いと思う。

# 連載終了したデータのみにフィルタ
fin = df[~df['連載中']]
fin1 = fin[fin['開始'].dt.year < 1980]
fin2 = fin[fin['開始'].dt.year >= 1980]

fig, axes = plt.subplots(2, figsize=(7, 4))
bins = np.arange(0, 1000, 5)
fin1['回数'].plot(kind='hist', ax=axes[0], bins=bins, xlim=(0, 100),
                label='80年より前に連載開始', legend=True)
fin2['回数'].plot(kind='hist', ax=axes[1], bins=bins, xlim=(0, 100),
                label='80年以降に連載開始', legend=True, color='green')
fig.suptitle('連載回数の頻度分布')

f:id:sinhrks:20141206231925p:plain

連載の継続率

連載の継続率 = 生存率とみて lifelines を使って Kaplan-Meier 曲線を引く。横軸が時間経過 (週)、縦軸がその時点まで連載継続している確率になる。

lifelines についてはこちら

from lifelines import KaplanMeierFitter

def to_pct(y, position):
    s = str(100 * y)
    return s + '%'
from matplotlib.ticker import FuncFormatter
pctformatter = FuncFormatter(to_pct)

df2 = df[df['開始'].dt.year >= 1980]
kmf = KaplanMeierFitter()
kmf.fit(df2['回数'], event_observed=~df2['連載中'])
ax = kmf.plot(figsize=(7, 3), xlim=(0, 200))
ax.yaxis.set_major_formatter(pctformatter)

50週 = 1年 連載継続できるのは 30 % くらいか、、、厳しい世界だホント。

f:id:sinhrks:20141206232325p:plain

ジャンル別の連載の継続率

上と同じ、でジャンル別。

ax = None
for genre in genres:
    group = df2[df2[genre]]
    kmf = KaplanMeierFitter()
    kmf.fit(group['回数'], event_observed=~group['連載中'],
            label='ジャンル=' + genre)

    # 描画する Axes を指定。None を渡すとエラーになるので場合分け
    if ax is None:
        ax = kmf.plot(figsize=(7, 3), xlim=(0, 200), ci_show=False)
    else:
        ax = kmf.plot(ax=ax, figsize=(7, 3), xlim=(0, 200), ci_show=False)
ax.yaxis.set_major_formatter(pctformatter)

もう少し偏るかと思ったがそうでもなかった。ラブコメは 150 回くらいに壁がありそう。ニセコイがこの壁を乗り越えてくれることを切に願う。

f:id:sinhrks:20141206232543p:plain

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Python pandas の算術演算 / 集約関数 / 統計関数まとめ

概要

恒例の pandas 記事。今回は 基本的な算術演算についてまとめた。このあたりの挙動は numpy と一緒で直感的だと思うが、知っていないとハマるポイントがいくつかあるので。

準備

サンプルは DataFrame のみ。だが内容は Series でも同じ ( 行/列 2次元のデータに関するに記載は除く)。

import numpy as np
import pandas as pd

df = pd.DataFrame({'C1': [1, 1, 1],
                   'C2': [1, 1, 1],
                   'C3': [1, 1, 1]})
df
#    C1  C2  C3
# 0   1   1   1
# 1   1   1   1
# 2   1   1   1

四則演算

まずは基本的な例。

# スカラーの加算はデータ全体に対して適用 ( ブロードキャスト ) される
df + 1
#    C1  C2  C3
# 0   2   2   2
# 1   2   2   2
# 2   2   2   2

# np.array の加算は 各行に対する加算
df + np.array([1, 2, 3])
#    C1  C2  C3
# 0   2   3   4
# 1   2   3   4
# 2   2   3   4

# 要素の長さが違う場合は ValueError
df + np.array([1, 2])
# ValueError: Wrong number of items passed 2, place

とはいえ、演算をデータ全体へブロードキャストさせたいことはマレだと思う。特定の列 あるいは 特定セルに対して演算を行う場合は、以下の記事でまとめたデータ選択記法を使って計算結果を代入する。

補足 データへの演算自体は非破壊的な処理だが、代入によって元のデータが変更されることに注意。

# 1列目に [1, 2, 3] を足す
df['C1'] = df['C1'] + np.array([1, 2, 3])
df
#    C1  C2  C3
# 0   2   1   1
# 1   3   1   1
# 2   4   1   1

# 3行目 / 3列目の値に 5 を足す
df.iloc[2, 2] += 5
df
#    C1  C2  C3
# 0   2   1   1
# 1   3   1   1
# 2   4   1   6

# 複数列に対する演算も OK
# (裏側では DataFrame 選択 -> 計算 -> 代入 をしているだけなので)
df[['C1', 'C2']] -= 5
df
#    C1  C2  C3
# 0  -3  -4   1
# 1  -2  -4   1
# 2  -1  -4   6

算術演算メソッド

演算の挙動をもうすこし制御したい場合は、DataFrame.addDataFrameSeries は、add のほかにも Python 標準モジュールの operators と対応する算術演算メソッド / 論理演算メソッドを持つ。利用できるメソッドの一覧はこちら。オプションはどれも重要なので順番に。

列に対するブロードキャスト (axis=0)

上記のような1列の加算ではなく、列全体に対してブロードキャストをしたい場合は axis=0 を指定。

  • axis=0 もしくは axis='index' で列に対する演算
  • axis=1 もしくは axis='columns'で行に対する演算 (デフォルト)
# データは元に戻す
df = pd.DataFrame({'C1': [1, 1, 1],
                   'C2': [1, 1, 1],
                   'C3': [1, 1, 1]})
df
#    C1  C2  C3
# 0   1   1   1
# 1   1   1   1
# 2   1   1   1

df.add(np.array([1, 2, 3]), axis=0)
#    C1  C2  C3
# 0   2   2   2
# 1   3   3   3
# 2   4   4   4

df.add(np.array([1, 2, 3]), axis='index')
# 略

欠測値 ( NaN ) 要素へのパディング (fill_value)

データに 欠測値 ( NaN ) が含まれるとき、その要素への演算の結果も NaN になる。これは numpy の挙動と同じ。

# numpy の挙動
np.nan + 1
# nan

# NaN を含む DataFrame を定義する
df_nan = pd.DataFrame({'C1': [1, np.nan, np.nan],
                       'C2': [np.nan, 1, np.nan],
                       'C3': [np.nan, np.nan, 1]})
df_nan
#    C1  C2  C3
# 0   1 NaN NaN
# 1 NaN   1 NaN
# 2 NaN NaN   1

# NaN を含むセルの演算結果は NaN 
df.add(df_nan)
#    C1  C2  C3
# 0   2 NaN NaN
# 1 NaN   2 NaN
# 2 NaN NaN   2

これに気づかず四則演算していると、なんか合計があわないな?ということになってしまう。この挙動を変えたい場合は fill_value。演算の実行前に fill_value で指定した値がパディングされる。ただし、演算対象の要素が 両方とも NaN の場合は NaN のまま。

df.add(df_nan, fill_value=0)
#    C1  C2  C3
# 0   2   1   1
# 1   1   2   1
# 2   1   1   2

# データの順序が変わっても有効 ( fill_value は演算対象 両方のデータに適用される)
df_nan.add(df, fill_value=0)
#    C1  C2  C3
# 0   2   1   1
# 1   1   2   1
# 2   1   1   2

# 要素が 両方とも NaN の場合はパディングされない
df_nan.add(df_nan, fill_value=0)
#    C1  C2  C3
# 0   2 NaN NaN
# 1 NaN   2 NaN
# 2 NaN NaN   2

# DataFrame について、対象データが array, Series の場合の処理は未実装 (NotImplementedError) なので注意
df.add(np.array([1, np.nan, 1]), fill_value=0)
# NotImplementedError: fill_value 0 not supported

最後のオプション、 levelindex複数の階層 (レベル) 持つ場合 ( MultiIndex を持つ場合 ) の制御を行う。このオプションの挙動は近日公開予定 (?) の MultiIndex 編で。

行列積

*DataFrame 同士の積をとった場合は、各要素同士の積になる。

df2 = pd.DataFrame({'C1': [2, 3],
                    'C2': [4, 5]})
df2
#    C1  C2
# 0   2   4
# 1   3   5

df3 = pd.DataFrame({'C1': [1, -2],
                    'C2': [-3, 4]})
df3
#    C1  C2
# 0   1  -3
# 1  -2   4

df2 * df3
#    C1  C2
# 0   2 -12
# 1  -6  20

行列の積をとりたい場合は DataFrame.dot。ただし、行列の積をとるためには元データの columns と 引数の index のラベルが一致している必要がある (説明 次節)。そのため、上のように columns / index が一致しないデータから直接 行列の積をとることはできない。そんなときは DataFrame.values プロパティで引数側の内部データを numpy.array として取り出せばよい。

# NG!
df2.dot(df3)
# ValueError: matrices are not aligned

# values プロパティで内部データを numpy.array として取り出せる
df3.values
# array([[ 1, -3],
#        [-2,  4]], dtype=int64)

# OK!
df2.dot(df3.values)
#    0   1
# 0 -6  10
# 1 -7  11

引数側を転置すれば、元データの columns と 引数の index のラベルが一致する。このようなデータはそのまま DataFrame.dot で積をとれる。

df3.T
#     0  1
# C1  1 -2
# C2 -3  4

df2.dot(df3.T)
#     0   1
# 0 -10  12
# 1 -12  14

補足 そもそも Python には行列積の演算子がない。が、現在 PEP-465 で行列積として @ 演算子が提案されている。

ラベルによる演算の制御

ここまでは index, columns が一致するデータのみを対象にしてきた。実際には演算を行うデータの index, columns が異なることもある。その場合の挙動には少し注意が必要。

Series, DataFrame 同士の演算は、 index, columns のラベルが一致する要素同士で行われる。例えば以下のように indexcolumns がずれているとき、対応しない要素は NaN となる。

df4 = pd.DataFrame({'C1': [1, 1, 1],
                    'C2': [1, 1, 1],
                    'C3': [1, 1, 1]})
df4
#    C1  C2  C3
# 0   1   1   1
# 1   1   1   1
# 2   1   1   1

df5 = pd.DataFrame({'C2': [1, 1, 1],
                    'C3': [1, 1, 1],
                    'C4': [1, 1, 1]},
                   index=[1, 2, 3])

df5
#    C2  C3  C4
# 1   1   1   1
# 2   1   1   1
# 3   1   1   1

df4 + df5
#    C1  C2  C3  C4
# 0 NaN NaN NaN NaN
# 1 NaN   2   2 NaN
# 2 NaN   2   2 NaN
# 3 NaN NaN NaN NaN

どう直せばよいかは状況による。index, columns のずれ自体は OK で、 NaN でのパディングが気に入らない場合は fill_value

df4.add(df5, fill_value=0)
#    C1  C2  C3  C4
# 0   1   1   1 NaN
# 1   1   2   2   1
# 2   1   2   2   1
# 3 NaN   1   1   1

index, columnsに関係なく、対応する位置の要素同士で演算したい場合は DataFrame.values を使って取得した numpy.array のみを渡せばよい。

df4 + df5.values
#    C1  C2  C3
# 0   2   2   2
# 1   2   2   2
# 2   2   2   2

もしくは、以下のようにして データの index もしくは columns を揃えてから演算する。

  • df4indexdf5 にそろえる場合は、df4.index に代入。
  • df5indexdf4 にそろえる場合は、df5.index に代入。もしくは、DataFrame.reset_index(drop=True)index を 0 から振りなおし
# df4 の index を df5 にそろえる
df4.index = [1, 2, 3]
df4
#    C1  C2  C3
# 1   1   1   1
# 2   1   1   1
# 3   1   1   1

# df5 の index を df4 にそろえる 
df5.reset_index(drop=True)
#    C2  C3  C4
# 0   1   1   1
# 1   1   1   1
# 2   1   1   1

# reset_index デフォルトでは、元の index をカラムとして残す
df5.reset_index()
#    index  C2  C3  C4
# 0      1   1   1   1
# 1      2   1   1   1
# 2      3   1   1   1

集約関数

Series, DataFrame は 合計を取得する sum, 平均を計算する mean などの一連の集約関数に対応するメソッドを持つ。一覧は こちら

df6 = pd.DataFrame({'C1': ['A', 'B', 'C'],
                    'C2': [1, 2, 3],
                    'C3': [4, 5, 6]})
df6
#   C1  C2  C3
# 0  A   1   4
# 1  B   2   5
# 2  C   3   6

集約系の関数は既定では列方向に対して適用される。基本的には 数値型のみが集約対象になる。が、一部の関数では数値以外の型に対してもがんばってくれるものもある。

# mean は数値型のみ集約
df6.mean()
# C2    2
# C3    5
# dtype: float64

# sum は 文字列も集約
df6.sum()
# C1    ABC
# C2      6
# C3     15
# dtype: object

# 数値以外が不要な場合は numeric_only=True
df6.sum(numeric_only=True)
# C2     6
# C3    15
# dtype: int64

# 行方向へ適用したい場合は axis = 1
# このとき、数値型以外は除外して関数適用される
df6.sum(axis=1)
# 0    5
# 1    7
# 2    9
# dtype: int64

# 明示的に含めようとするとエラー
df6.sum(axis=1, numeric_only=False)
# TypeError: cannot concatenate 'str' and 'long' objects

複数列のデータをまとめて関数適用したい、という場合もたまーにある。pandas には直接対応するメソッドはないが、以下のようにすればできる。

# values.flatten() で 2列分の値を ひとつの numpy.array として取得
df6[['C2', 'C3']].values.flatten()
# array([1, 4, 2, 5, 3, 6], dtype=int64)

# 集約関数適用
np.mean(df6[['C2', 'C3']].values.flatten())
# 3.5

補足 numpy.array.flatten() は1次元化した array のコピーを返す。

統計関数

また、分散を計算する var, 標準偏差を計算する std などの統計関数に対応するメソッドもある。一覧は上と同じく こちら。関数の適用方向など、挙動は集約関数と一緒。

1点 覚えておくべき箇所は、pandas では分散 / 標準偏差について不偏推定量の計算がデフォルトになっている。これは numpy の挙動 ( 標本統計量を返す ) とは異なる。この挙動は pandas, numpy ともに ddof オプションで制御できる。

  • pandas : 不偏推定量の計算 ( ddof=True ) がデフォルト。
  • numpy : 標本統計量の計算 ( ddof=False ) がデフォルト。

補足 ddof = Delta Degrees of Freedom

df7 = pd.DataFrame({'C1': [1, 2, 3, 4],
                    'C2': [4, 5, 6, 7],
                    'C3': [2, 3, 3, 2]})
#    C1  C2  C3
# 0   1   4   2
# 1   2   5   3
# 2   3   6   3
# 3   4   7   2

# 不偏分散
df7.var()
# C1    1.666667
# C2    1.666667
# C3    0.333333
# dtype: float64

# 標本分散
df7.var(ddof=False)
# C1    1.25
# C2    1.25
# C3    0.25
# dtype: float64

# 標本分散 (numpy)
np.var(df7)
# C1    1.25
# C2    1.25
# C3    0.25
# dtype: float64

# 不偏標準偏差
df7.std()
# C1    1.290994
# C2    1.290994
# C3    0.577350
# dtype: float64

# 標本標準偏差
df7.std(ddof=False)
# C1    1.118034
# C2    1.118034
# C3    0.500000
# dtype: float64

# 標本標準偏差 (numpy)
np.std(df7)
# C1    1.118034
# C2    1.118034
# C3    0.500000
# dtype: float64

基本統計量の表示 ( describe )

最後。基本統計量をまとめて計算したい場合は DataFrame.describe()

df7.describe()
#              C1        C2       C3
# count  4.000000  4.000000  4.00000
# mean   2.500000  5.500000  2.50000
# std    1.290994  1.290994  0.57735
# min    1.000000  4.000000  2.00000
# 25%    1.750000  4.750000  2.00000
# 50%    2.500000  5.500000  2.50000
# 75%    3.250000  6.250000  3.00000
# max    4.000000  7.000000  3.00000

まとめ

pandas での算術演算、集約関数、統計関数の挙動をまとめた。ポイントは、

  • DataFrame への演算は適用される方向に注意。演算方向を指定する場合、列方向なら axis=0, 行方向は axis=1
  • 演算したい要素に NaN が含まれる場合、必要に応じて fill_value
  • 演算したい要素同士のラベルは一致させること。

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

pandas でメモリに乗らない 大容量ファイルを上手に扱う

概要

分析のためにデータ集めしていると、たまに マジか!? と思うサイズの CSV に出くわすことがある。なぜこんなに育つまで放っておいたのか、、、? このエントリでは普通には開けないサイズの CSVpandas を使ってうまいこと処理する方法をまとめたい。

サンプルデータ

たまには実データ使おう、ということで WorldBankから GDPデータを落とす。以下のページ右上の "DOWNLOAD DATA" ボタンで CSV を選択し、ローカルに zip を保存する。解凍した "ny.gdp.mktp.cd_Indicator_en_csv_v2.csv" ファイルをサンプルとして使う。

http://data.worldbank.org/indicator/NY.GDP.MKTP.CD?page=1

補足 pandasRemote Data Access で WorldBank のデータは直接 落っことせるが、今回は ローカルに保存した csv を読み取りたいという設定で。

chunksize を使って ファイルを分割して読み込む

まず、pandas で普通に CSV を読む場合は以下のように pd.read_csv を使う。サンプルデータではヘッダが 3行目から始まるため、冒頭の 2行を skiprows オプションで読み飛ばす。

import pandas as pd

fname = 'ny.gdp.mktp.cd_Indicator_en_csv_v2.csv'
df = pd.read_csv(fname, skiprows=[0, 1])
df.shape
# (258, 59)

df.head()
#   Country Name Country Code     Indicator Name  Indicator Code          1961
# 0        Aruba          ABW  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 1      Andorra          AND  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 2  Afghanistan          AFG  GDP (current US$)  NY.GDP.MKTP.CD  5.488889e+08
# 3       Angola          AGO  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 4      Albania          ALB  GDP (current US$)  NY.GDP.MKTP.CD           NaN
#
# [5 rows x 59 columns]

では読み取り対象ファイルが メモリに乗らないほど大きい場合はどうするか? read_csvchunksize オプションを指定することでファイルの中身を 指定した行数で分割して読み込むことができる。chunksize には 1回で読み取りたい行数を指定する。例えば 50 行ずつ読み取るなら、chunksize=50

reader = pd.read_csv(fname, skiprows=[0, 1], chunksize=50)

chunksize を指定したとき、返り値は DataFrame ではなく TextFileReader インスタンスとなる。

type(reader)
# pandas.io.parsers.TextFileReader

TextFileReaderfor でループさせると、ファイルの中身を指定した行数ごとに DataFrame として読み取る。TextFileReader は 現時点での読み取り位置 (ポインタ) を覚えており、ファイルの中身をすべて読み取るとループが終了する。

for r in reader:
    print(type(r), r.shape)
# (<class 'pandas.core.frame.DataFrame'>, (50, 59))
# (<class 'pandas.core.frame.DataFrame'>, (50, 59))
# (<class 'pandas.core.frame.DataFrame'>, (50, 59))
# (<class 'pandas.core.frame.DataFrame'>, (50, 59))
# (<class 'pandas.core.frame.DataFrame'>, (50, 59))
# (<class 'pandas.core.frame.DataFrame'>, (8, 59))

もしくは、TextFileReader.get_chunk で、現在の読み取り位置 (ポインタ) から N 行を読み取る。

# 上のループで全ての要素は読み取り済みになっているので、再度 reader を初期化
reader = pd.read_csv(fname, skiprows=[0, 1], chunksize=50)

# 先頭から 5行を読み込み
reader.get_chunk(5)
#   Country Name Country Code     Indicator Name  Indicator Code          1961
# 0        Aruba          ABW  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 1      Andorra          AND  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 2  Afghanistan          AFG  GDP (current US$)  NY.GDP.MKTP.CD  5.488889e+08
# 3       Angola          AGO  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 4      Albania          ALB  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 
# [5 rows x 59 columns]

# 次の4行 (6行目 - 9行目)を読み込み
reader.get_chunk(4)
#            Country Name Country Code     Indicator Name  Indicator Code  1961
# 0         Andean Region          ANR  GDP (current US$)  NY.GDP.MKTP.CD   NaN
# 1            Arab World          ARB  GDP (current US$)  NY.GDP.MKTP.CD   NaN
# 2  United Arab Emirates          ARE  GDP (current US$)  NY.GDP.MKTP.CD   NaN
# 3             Argentina          ARG  GDP (current US$)  NY.GDP.MKTP.CD   NaN
# 
# [4 rows x 59 columns]

補足 chunksize オプションは、 read_csvread_table で利用できる 。

補足 pandasread_csv はかなり速いので、パフォーマンス面でも pandas を使うのはよいと思う。

A new high performance, memory-efficient file parser engine for pandas | Wes McKinney

python - Fastest way to parse large CSV files in Pandas - Stack Overflow

でも、分割された DataFrame を扱うのはめんどうなんだけど?

分析に必要なレコードは 全データのごく一部、なんてこともよくある。chunk させて読み込んだ 各グループに対して前処理をかけると、サイズが劇的に減ってメモリに乗る。そんなときは、前処理をかけた後、各 DataFrame を結合してひとつにしたい。

DataFrame の結合には pd.concatTextFileReader から読み込んだ 各 DataFrame はそれぞれが 0 から始まる index を持つ。pd.concat は既定では index を維持して DataFrame を結合するため、そのままでは index が重複してしまう。 重複を避けるためには、結合後に index を振りなおす = ignore_index=True を指定する必要がある。

よく使う表現は、

def preprocess(x):
    # 不要な行, 列のフィルタなど、データサイズを削減する処理
    return x

reader = pd.read_csv(fname, skiprows=[0, 1], chunksize=50)
df = pd.concat((preprocess(r) for r in reader), ignore_index=True)

df.shape
# (258, 59)

df.head()
#   Country Name Country Code     Indicator Name  Indicator Code          1961
# 0        Aruba          ABW  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 1      Andorra          AND  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 2  Afghanistan          AFG  GDP (current US$)  NY.GDP.MKTP.CD  5.488889e+08
# 3       Angola          AGO  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 4      Albania          ALB  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 
# [5 rows x 59 columns]

メモリ上の DataFrame のサイズを確認したい

DataFrame を適切な大きさに分割するために、DataFrame のメモリ上の占有サイズが知りたい。これは v0.15.0 以降であれば可能。

DataFrame 全体のメモリ上のサイズを表示するには DataFrame.info()。 表示された情報の最終行、 "memory size" をみる。ただし、この表示には object 型のカラムが占めているメモリは含まれないので注意。"+" がついているのは実際の占有領域が表示よりも大きいことを示す。

df.info()
# <class 'pandas.core.frame.DataFrame'>
# Int64Index: 258 entries, 0 to 257
# Data columns (total 59 columns):
# Country Name      258 non-null object
# Country Code      258 non-null object
# Indicator Name    258 non-null object
# Indicator Code    258 non-null object
# 1961              127 non-null float64
# ...
# 2012              222 non-null float64
# 2013              216 non-null float64
# 2014              0 non-null float64
# Unnamed: 58       0 non-null float64
# dtypes: float64(55), object(4)
# memory usage: 116.9+ KB

ちゃんと表示する場合は DataFrame.memory_usage(index=True)index=Trueindex の占有メモリも表示に含めるオプション。表示はカラム別になるため、全体のサイズを見たい場合は sum をとる。

df.memory_usage(index=True)
# Index             2064
# Country Name      1032
# Country Code      1032
# Indicator Name    1032
# Indicator Code    1032
# 1961              2064
# ...
# 2013              2064
# 2014              2064
# Unnamed: 58       2064
# Length: 60, dtype: int64

df.memory_usage(index=True).sum()
# 119712

DataFrame を複製せずに処理する

DataFrameメソッドを呼び出した場合、一般には DataFrame をコピーしてから処理を行って返す (非破壊的な処理を行う)。大容量の DataFrame ではこのコピーによって 実メモリのサイズを超え MemoryError になる / もしくはコピーに非常に時間がかかることがある。

例えば、欠損値の補完を fillna で行う処理をみてみる。fillna の返り値では NaN が 0 でパディングされているが、元データは変更されていない。fillna では、コピーした DataFrame に対して処理を行っていることが "1961" カラムの値を比較するとわかる。

df.fillna(0).head()
#   Country Name Country Code     Indicator Name  Indicator Code          1961
# 0        Aruba          ABW  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 1      Andorra          AND  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 2  Afghanistan          AFG  GDP (current US$)  NY.GDP.MKTP.CD  5.488889e+08
# 3       Angola          AGO  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 4      Albania          ALB  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00

# dfはそのまま
df.head()
#   Country Name Country Code     Indicator Name  Indicator Code          1961
# 0        Aruba          ABW  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 1      Andorra          AND  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 2  Afghanistan          AFG  GDP (current US$)  NY.GDP.MKTP.CD  5.488889e+08
# 3       Angola          AGO  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 4      Albania          ALB  GDP (current US$)  NY.GDP.MKTP.CD           NaN
# 
# [5 rows x 59 columns]

このコピー処理を避けたい場合は inplace=True を指定する。こいつが指定されると、各メソッドDataFrame のコピーを行わず、呼び出し元の DataFrame を直接処理する (破壊的な処理を行う)。また、inplace=True が指定されたメソッドは返り値をもたない ( None を返す ) ため、代入を行ってはならない。

# 返り値はないため、代入してはダメ
df.fillna(0, inplace=True)

# df 自体が変更された
df.head()
#   Country Name Country Code     Indicator Name  Indicator Code          1961
# 0        Aruba          ABW  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 1      Andorra          AND  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 2  Afghanistan          AFG  GDP (current US$)  NY.GDP.MKTP.CD  5.488889e+08
# 3       Angola          AGO  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 4      Albania          ALB  GDP (current US$)  NY.GDP.MKTP.CD  0.000000e+00
# 
# [5 rows x 59 columns]

やりたいメソッドinplace オプションを持つかどうかは API ガイド で確認。

eval による処理の最適化

numexpr のインストール

pd.eval を使うには numexpr パッケージが必要なので、入っていなければインストールする。

pip install numexpr

eval のメリット

pd.eval では文字列表現された式を受け取って評価できる。pd.eval を使うと以下 2つのメリットがある。

  • 大容量の DataFrame を効率的に処理できる
  • 数値演算を一括して処理できる

補足 numexpr のソースは読んだことがないので詳細不明だが、 pandas では連続するオペレータは逐次処理されていく。例えば "A + B + C" であれば まず "A + B" の結果から DataFrame を作って、さらに "+ C" して DataFrame を作る。DataFrame 生成にはそれなりにコストがかかるので この辺の内部処理が最適化されるのかな、と思っている。

補足 逆に、小さい処理では eval から numexpr を呼び出すコストの方が メリットよりも大きくなるため、pd.eval を使う場合は通常処理とのパフォーマンス比較を行ってからにすべき。今回のサンプルくらいのデータサイズであれば eval よりも 通常の演算のほうが速い。

eval でサポートされる式表現は、公式ドキュメントにあるとおり、

  • 数値演算オペレータ。ただし、シフト演算 <<>> を除く。
  • 比較演算オペレータ。連結も可能 ( 2 < df < df2 とか)
  • 論理演算オペレータ ( not も含む)。
  • listtupleリテラル表現 ( [1, 2](1, 2) )
  • プロパティアクセス ( df.a )
  • __getitem__ でのアクセス ( df[0] )

eval の利用

例えばサンプルデータの "2011" 〜 "2013" カラムの値を足し合わせたいときはこう書ける。

pd.eval("df['2011'] + df['2012'] + df['2013']")
# 0     2.584464e+09
# 1     0.000000e+00
# 2     5.910162e+10
# 3     3.411516e+11
# 4     3.813926e+10
# ...
# 256    6.218183e+10
# 257    3.623061e+10
# Length: 258, dtype: float64

また、pd.eval ではなく DataFrame.eval として呼んだ場合、式表現中はデータのカラム名を変数名としてもつ名前空間で評価される ( DataFrame.query と同じ )。DataFrame.query については以下の記事参照。

まとめ

pandas で大容量ファイルを扱うときは、

  • chunksize で分割して読み込み
  • メソッド呼び出しは inplace=True
  • オペレータを使う処理は pd.eval

これさえ覚えておけば、大容量ファイルが送りつけられた場合でも安心。

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Python pandas データ選択処理をちょっと詳しく <後編>

概要

こちらの続き。これで pandas でのデータ選択についてはひとまず終わり。

Python pandas データ選択処理をちょっと詳しく <前編> - StatsFragments

Python pandas データ選択処理をちょっと詳しく <中編> - StatsFragments

サンプルデータの準備

データは 前編と同じものを使う。ただし変数名は変えた。

import pandas as pd

s1 = pd.Series([1, 2, 3], index = ['I1', 'I2', 'I3'])

df1 = pd.DataFrame({'C1': [11, 21, 31],
                    'C2': [12, 22, 32],
                    'C3': [13, 23, 33]},
                   index = ['I1', 'I2', 'I3'])

s1
# I1    1
# I2    2
# I3    3
# dtype: int64

df1
#     C1  C2  C3
# I1  11  12  13
# I2  21  22  23
# I3  31  32  33

where でのデータ選択

これまでみたとおり、Series から __getitem__ すると、条件に該当する要素のみが返ってくる。

s1[s1 > 2]
# I3    3
# dtype: int64

が、ときには元データと同じ長さのデータがほしい場合がある。Series.where を使うと 条件に該当しない ラベルは NaN でパディングし、元データと同じ長さの結果を返してくれる。

where の引数はデータと同じ長さの bool 型の numpy.array もしくは Series である必要がある。まあ Series への論理演算では長さは変わらないので、以下のような使い方なら特別 意識する必要はないかも。

s1.where(s1 > 2)
# I1   NaN
# I2   NaN
# I3     3
# dtype: float64

NaN 以外でパディングしたいんだよ、ってときは 第二引数に パディングに使う値を渡す。NaN でなく 0 でパディングしたければ、

s1.where(s1 > 2, 0)
# I1    0
# I2    0
# I3    3
# dtype: int64

また、第二引数にはデータと同じ長さの numpy.arraySeries も渡せる。このとき、パディングはそれぞれ対応する位置にある値で行われ、第一引数の条件に該当しないデータを 第二引数で置換するような動きになる。つまり if - else のような表現だと考えていい。

# 第一引数の条件に該当しない s1 の 1, 2番目の要素が array の 1, 2 番目の要素で置換される
s1.where(s1 > 2, np.array([4, 5, 6]))
# I1    4
# I2    5
# I3    3
# dtype: int64

# 置換用の Series を作る
s2 = pd.Series([4, 5, 6], index = ['I1', 'I2', 'I3'])
s2
# I1    4
# I2    5
# I3    6
# dtype: int64

# 第一引数の条件に該当しない s1 の 1, 2番目の要素が Series s2 の 1, 2 番目の要素で置換される
s1.where(s1 > 2, s2)
# I1    4
# I2    5
# I3    3
# dtype: int64

DataFrame でも同様。

df1.where(df1 > 22)
#     C1  C2  C3
# I1 NaN NaN NaN
# I2 NaN NaN  23
# I3  31  32  33

# 0 でパディング
df1.where(df1 > 22, 0)
#     C1  C2  C3
# I1   0   0   0
# I2   0   0  23
# I3  31  32  33

# 置換用の DataFrame を作る
df2 = pd.DataFrame({'C1': [44, 54, 64],
                    'C2': [45, 55, 65],
                    'C3': [46, 56, 66]},
                   index = ['I1', 'I2', 'I3'])
df2
#     C1  C2  C3
# I1  44  45  46
# I2  54  55  56
# I3  64  65  66

# df1 のうち、22以下の値を df2 の値で置換
df1.where(df1 > 22, df2)
#     C1  C2  C3
# I1  44  45  46
# I2  54  55  23
# I3  31  32  33

where がことさら便利なのは以下のようなケース。

  • DataFrame に新しいカラムを作りたい。 (あるいは既存の列の値を置き換えたい)
  • 新しいカラムは、"C2" 列の値が 30を超える場合は "C1" 列の値を使う。
  • それ以外は "C3" 列の値を使う。

これが一行でかける。

df1['C4'] = df1['C1'].where(df1['C2'] > 30, df1['C3'])

df1
#     C1  C2  C3  C4
# I1  11  12  13  13
# I2  21  22  23  23
# I3  31  32  33  31

mask でのデータ選択

where の逆の操作として mask がある。こちらは第一引数の条件に該当するセルを NaN でマスクする。ただし第二引数の指定はできないので 使いどころは限られる。

2015/05/06追記 v0.16.1以降で、DataFrame.maskwhere と同じ引数を処理できるようになった。

df1.mask(df1 > 22)
#     C1  C2  C3  C4
# I1  11  12  13  13
# I2  21  22 NaN NaN
# I3 NaN NaN NaN NaN

# v0.16.1以降
df1.mask(df1 > 22, 0)
#     C1  C2  C3
# I1  11  12  13
# I2  21  22   0
# I3   0   0   0

query でのデータ選択

最後にqueryquery で何か新しい処理ができるようになるわけではないが、__getitem__ と同じ操作がよりシンプル な表現で書ける。

numexpr のインストール

query を使うには numexpr パッケージが必要なので、入っていなければインストールする。

pip install numexpr

サンプルデータの準備

また、さきほどの例で列追加したのでサンプルデータを作り直す。

df1 = pd.DataFrame({'C1': [11, 21, 31],
                    'C2': [12, 22, 32],
                    'C3': [13, 23, 33]},
                   index = ['I1', 'I2', 'I3'])
df1
#     C1  C2  C3
# I1  11  12  13
# I2  21  22  23
# I3  31  32  33

query の利用

__getitem__ を利用したデータ選択では、論理演算の組み合わせで boolSeries を作ってやる必要がある。そのため、[] 内で元データへの参照 ( 下の例では df1 )が繰り返しでてくるし、複数条件の組み合わせの際は 演算順序の都合上 () がでてきたりと式が複雑になりがち。これはわかりにくい。

df1[df1['C1'] > 20]
#     C1  C2  C3
# I2  21  22  23
# I3  31  32  33

df1[df1['C2'] < 30]
#     C1  C2  C3
# I1  11  12  13
# I2  21  22  23

df1[(df1['C1'] > 20) & (df1['C2'] < 30)]
#     C1  C2  C3
# I2  21  22  23

同じ処理は query を使うとすっきり書ける。query の引数にはデータ選択に使う条件式を文字列で渡す。この式が評価される名前空間 = query 名前空間の中では、query を呼び出したデータの列名が あたかも変数のように参照できる。

df1.query('C1 > 20 & C2 < 30')
#     C1  C2  C3
# I2  21  22  23

ただし、query 名前空間の中で使える表現は限られるので注意。例えば以下のような メソッド呼び出しはできない。

# NG!
df1.query('C1.isin([11, 21])')
# NotImplementedError: 'Call' nodes are not implemented

同じ処理を行う場合は in 演算子を使う。

# in を使えば OK
df1.query('C1 in [11, 21]')
#     C1  C2  C3
# I1  11  12  13
# I2  21  22  23

また、numexpr で利用できる関数 の呼び出しは query 名前空間上では使えないっぽい。

df1.query('C1 > sqrt(400)')
# NotImplementedError: 'Call' nodes are not implemented

そのため、query に渡す式表現では論理表現 以外を含めないほうがよい。条件によって式表現を変えたい、なんて場合は 式表現を都度 文字列として連結 + 生成するか、ローカル変数に計算結果を入れて式表現に渡す。

ローカル変数を式表現中に含める際は、変数名を @ ではじめる。

x = 20

# NG!
df1.query('C1 > x')
# UndefinedVariableError: name 'x' is not defined

# OK!
df1.query('C1 > @x')
#     C1  C2  C3
# I2  21  22  23
# I3  31  32  33

query 名前空間上で index の値を参照する場合は、式表現中で index と指定する。

df1.query('index in ["I1", "I2"]')
#     C1  C2  C3
# I1  11  12  13
# I2  21  22  23

index が列名と重複した場合は 列名が優先。

df_idx = pd.DataFrame({'index': [1, 2, 3]}, index=[3, 2, 1])
df_idx
#    index
# 3      1
# 2      2
# 1      3

df_idx.query('index >= 2')
#    index
# 2      2
# 1      3

まとめ

  • where を使うと 元データと同じ形のデータが取得できる。また、if - else 的表現が一行で書ける。
  • 複数の条件式を組み合わせる場合は query を使うとシンプルに書ける。

全三回で pandas でのデータ選択処理をまとめた。公式ドキュメント、またAPIガイドには ここで記載しなかったメソッド / 例もあるので、より尖った使い方をしたい人は読んでおくといいと思う。

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Python pandas データ選択処理をちょっと詳しく <中編>

こちらの続き。

上の記事では bool でのデータ選択について 最後にしれっと書いて終わらせたのだが、一番よく使うところなので中編として補足。

まず __getitem__ix の記法では、次のような指定によって 行 / 列を選択することができた。

  • index, columns のラベルを直接指定しての選択
  • index, columns の番号(順序)を指定しての選択
  • index, columns に対応する bool のリストを指定しての選択

ここでは上記の選択方法をベースとして、ユースケースごとに IndexSeries のプロパティ / メソッドを使ってできるだけシンプルにデータ選択を行う方法をまとめる。

補足 一部の内容はこちらの記事ともかぶる。下の記事のほうが簡単な内容なので、必要な方はまずこちらを参照。

簡単なデータ操作を Python pandas で行う - StatsFragments

準備

今回のサンプルは DataFrame だけで。

import pandas as pd
import numpy as np

df = pd.DataFrame({'N1': [1, 2, 3, 4, 5, 6],
                   'N2': [10, 20, 30, 40, 50, 60],
                   'N3': [6, 5, 4, 3, 2, 1],
                   'F1': [1.1, 2.2, 3.3, 4.4, 5.5, 6.6],
                   'F2': [1.1, 2.2, 3.3, 4.4, 5.5, 6.6],
                   'S1': ['A', 'b', 'C', 'D', 'E', 'F'],
                   'S2': ['A', 'X', 'X', 'X', 'E', 'F'],
                   'D1': pd.date_range('2014-11-01', freq='D', periods=6)},
                  index=pd.date_range('2014-11-01', freq='M', periods=6),
                  columns=['N1', 'N2', 'N3', 'F1', 'F2', 'S1', 'S2', 'D1'])

df
#             N1  N2  N3   F1   F2 S1 S2         D1
# 2014-11-30   1  10   6  1.1  1.1  A  A 2014-11-01
# 2014-12-31   2  20   5  2.2  2.2  b  X 2014-11-02
# 2015-01-31   3  30   4  3.3  3.3  C  X 2014-11-03
# 2015-02-28   4  40   3  4.4  4.4  D  X 2014-11-04
# 2015-03-31   5  50   2  5.5  5.5  E  E 2014-11-05
# 2015-04-30   6  60   1  6.6  6.6  F  F 2014-11-06

補足 後の例のために index は月次の日付型にした。pd.date_range の使い方は以下の記事参照。

Python pandas で日時関連のデータ操作をカンタンに - StatsFragments

以下の例では __getitem__ を使ってシンプルに書けるところは __getitem__ で書く。もちろん ix, (ラベルや番号なら loc, iloc) を使っても書ける。

index, columns のラベルを特定の条件で選択

前編index, columns のラベルそのものを直接指定すればデータ選択できるのはわかった。が、ラベルが特定の条件を満たすとき (ラベルが特定の文字で始まるとか) だけ選択するにはどうすれば?というのがここでの話。

補足 内部実装の話になるが、 index, columns は どちらも pd.Index 型のクラスが使われている ( DatetimeIndexIndex のサブクラス)。index, columns とも裏側にあるオブジェクトは同一のため、このセクションで記載する方法は 行 / 列が入れ替わっても使える。

df.index
# <class 'pandas.tseries.index.DatetimeIndex'>
# [2014-11-30, ..., 2015-04-30]
# Length: 6, Freq: M, Timezone: None

df.columns
# Index([u'N1', u'N2', u'N3', u'F1', u'F2', u'S1', u'S2', u'D1'], dtype='object')

ラベルに関数適用して選択したい

pd.Index.map。たとえば 大文字の "N" から始まるラベル名のみ抽出したいなら

df.columns.map(lambda x: x.startswith('N'))
# array([ True,  True,  True, False, False, False, False, False], dtype=bool)

df.ix[:, df.columns.map(lambda x: x.startswith('N'))]
#             N1  N2  N3
# 2014-11-30   1  10   6
# 2014-12-31   2  20   5
# 2015-01-31   3  30   4
# 2015-02-28   4  40   3
# 2015-03-31   5  50   2
# 2015-04-30   6  60   1

ということで map を使えば index, columns のラベルに対してあらゆる関数を適用してデータ選択できる。

さらに、よく使うと思われるケースではより簡便な方法が用意されている。

2015/05/06追記 v0.16.1 で Index にも .str アクセサが追加され、文字列処理関数を直接呼び出せるようになった。そのため、上の例は df.ix[:, df.columns.str.startswith('N')] とも書ける。.str アクセサについては以降の記載を参照。

リストに含まれるラベルだけ選択したい

たとえば選択したいラベルのリストがあり、そこに含まれるものだけ選択したいなんてことがある。選択候補リストに余計なラベルが含まれていると、__getitem__ では KeyError になり、ix では NaN (値のない) 列ができてしまう。

df[['N1', 'N2', 'N4']]
# KeyError: "['N4'] not in index"

df.ix[:, ['N1', 'N2', 'N4']]
#             N1  N2  N4
# 2014-11-30   1  10 NaN
# 2014-12-31   2  20 NaN
# 2015-01-31   3  30 NaN
# 2015-02-28   4  40 NaN
# 2015-03-31   5  50 NaN
# 2015-04-30   6  60 NaN

いや NaN とかいいから 存在する列だけが欲しいんだけど、、、というときに pd.Index.isin

df.columns.isin(['N1', 'N2', 'N4'])
# array([ True,  True, False, False, False, False, False, False], dtype=bool)

df.ix[:, df.columns.isin(['N1', 'N2', 'N4'])]
#             N1  N2
# 2014-11-30   1  10
# 2014-12-31   2  20
# 2015-01-31   3  30
# 2015-02-28   4  40
# 2015-03-31   5  50
# 2015-04-30   6  60

ラベルをソートして選択したい

pd.Index.ordercolumns をアルファベット順に並べ替えて、前から3つを取得したければ、

df.columns.order()
# Index([u'D1', u'F1', u'F2', u'N1', u'N2', u'N3', u'S1', u'S2'], dtype='object')

df.columns.order()[:3]
# Index([u'D1', u'F1', u'F2'], dtype='object')

df[df.columns.order()[:3]]
#                    D1   F1   F2
# 2014-11-30 2014-11-01  1.1  1.1
# 2014-12-31 2014-11-02  2.2  2.2
# 2015-01-31 2014-11-03  3.3  3.3
# 2015-02-28 2014-11-04  4.4  4.4
# 2015-03-31 2014-11-05  5.5  5.5
# 2015-04-30 2014-11-06  6.6  6.6

特定の年, 月, etc... のデータだけ選択したい

DatetimeIndex へのプロパティアクセスを使う。使えるプロパティはこちら

index が 2015年の日付になっている行のみ抽出するときは、pd.DatetimeIndex.year で 年のみを含む numpy.array を作って論理演算する。

df.index.year
# array([2014, 2014, 2015, 2015, 2015, 2015], dtype=int32)

df.index.year == 2015
# array([False, False,  True,  True,  True,  True], dtype=bool)

df[df.index.year == 2015]
#             N1  N2  N3   F1   F2 S1 S2         D1
# 2015-01-31   3  30   4  3.3  3.3  C  X 2014-11-03
# 2015-02-28   4  40   3  4.4  4.4  D  X 2014-11-04
# 2015-03-31   5  50   2  5.5  5.5  E  E 2014-11-05
# 2015-04-30   6  60   1  6.6  6.6  F  F 2014-11-06

補足 前編でまとめた原則には書かなかったが、DatetimeIndex (と PeriodIndex という日時関連の別クラス ) を index に持つ Series, DataFrame では、 例外的に __getitem__ の引数として日時-like な文字列が使えたりもする。詳しくは こちら。そのため、同じ処理は以下のようにも書ける。

df['2015']
#             N1  N2  N3   F1   F2 S1 S2         D1
# 2015-01-31   3  30   4  3.3  3.3  C  X 2014-11-03
# 2015-02-28   4  40   3  4.4  4.4  D  X 2014-11-04
# 2015-03-31   5  50   2  5.5  5.5  E  E 2014-11-05
# 2015-04-30   6  60   1  6.6  6.6  F  F 2014-11-06

ラベルが重複したデータを削除したい

2015/05/06修正 v0.16向けの内容に変更

これは v0.16 以降であれば簡単。例示のため index に "A" が3つ重複したデータを作る。

df_dup = pd.DataFrame({'N1': [1, 2, 3, 4],
                       'N2': [6, 5, 4, 3],
                       'S1': ['A', 'B', 'C', 'D']},
                      index=['A', 'A', 'A', 'B'])
df_dup
#    N1  N2 S1
# A   1   6  A
# A   2   5  B
# A   3   4  C
# B   4   3  D

Index.duplicated()Index の値が重複しているかどうかがわかるため、これを使って列選択すればよい。重複している値が True となっているため ~ で論理否定をとって行選択すると、

df_dup.index.duplicated()
# array([False,  True,  True, False], dtype=bool)

df_dup[~df_dup.index.duplicated()] 
#    N1  N2 S1
# A   1   6  A
# B   4   3  D

このとき、各重複グループの一番最初のデータは削除されない。この挙動は take_last オプションで変更できる。

  • take_last=False (Default) : 重複しているグループの最初のデータを残す
  • take_last=True : 重複しているグループの最後のデータを残す。
df_dup[~df_dup.index.duplicated(take_last=True)]
#    N1  N2 S1
# A   3   4  C
# B   4   3  D

いやいや重複データは全削除したいんですけど?という場合は Dummy 列で groupby して filter

df_dup.groupby(["Dummy"]).filter(lambda x:x.shape[0] == 1)
#    N1  N2 S1 Dummy
# B   4   3  D     B

df_dup.groupby(["Dummy"]).filter(lambda x:x.shape[0] == 1)[['N1', 'N2', 'S1']]
#    N1  N2 S1
# B   4   3  D

補足 直感的でないな、と思った方が多いと思うが 開発者でもタスクとしては認識している ので、、、。

列, 行の値から特定の条件で選択

上のセクションでは index, columns 自体のラベルからデータ選択する方法を書いた。ここからは データの中身 (行 / 列 / もしくは各セルの値 ) をもとにデータ選択する方法を記載する。

補足 DataFrame では 実データは列持ち (各列が特定の型のデータを保持している) なので、ここからの方法では行/列の方向を意識する必要がある。

特定の型の列のみ取り出す

DataFrame.dtypes プロパティで各カラムの型が取得できるので、それらに対して論理演算をかける。

df.dtypes
# N1             int64
# N2             int64
# N3             int64
# F1           float64
# F2           float64
# S1            object
# S2            object
# D1    datetime64[ns]
# dtype: object

df.dtypes == np.float64
# N1    False
# N2    False
# N3    False
# F1     True
# F2     True
# S1    False
# S2    False
# D1    False
# dtype: bool

df.ix[:, df.dtypes == np.float64]
#              F1   F2
# 2014-11-30  1.1  1.1
# 2014-12-31  2.2  2.2
# 2015-01-31  3.3  3.3
# 2015-02-28  4.4  4.4
# 2015-03-31  5.5  5.5
# 2015-04-30  6.6  6.6

値が特定の条件を満たす行/列を選択したい

たいていは 普通の演算でいける。"N1" カラムの値が偶数の行だけ抽出するには、

df['N1'] % 2 == 0
# 2014-11-30    False
# 2014-12-31     True
# 2015-01-31    False
# 2015-02-28     True
# 2015-03-31    False
# 2015-04-30     True
# Freq: M, Name: N1, dtype: bool

df[df['N1'] % 2 == 0]
#             N1  N2  N3   F1   F2 S1 S2         D1
# 2014-12-31   2  20   5  2.2  2.2  b  X 2014-11-02
# 2015-02-28   4  40   3  4.4  4.4  D  X 2014-11-04
# 2015-04-30   6  60   1  6.6  6.6  F  F 2014-11-06

各列の合計値が 50 を超えるカラムを抽出するには、

df.sum()
# N1     21.0
# N2    210.0
# N3     21.0
# F1     23.1
# F2     23.1
# dtype: float64

indexer = df.sum() > 50
indexer
# N1    False
# N2     True
# N3    False
# F1    False
# F2    False
# dtype: bool

df[indexer.index[indexer]]
#             N2
# 2014-11-30  10
# 2014-12-31  20
# 2015-01-31  30
# 2015-02-28  40
# 2015-03-31  50
# 2015-04-30  60

各行 の値に関数適用して選択したいときは applyapply に渡す関数は 行 もしくは 列を Series として受け取って処理できるものでないとダメ。apply での関数の適用方向は axis オプションで決める。

  • axis=0 : 各列への関数適用
  • axis=1 : 各行への関数適用

"N1" カラムと "N2" カラムの積が 100 を超える行だけをフィルタする場合、

df.apply(lambda x: x['N1'] * x['N2'], axis=1)
# 2014-11-30     10
# 2014-12-31     40
# 2015-01-31     90
# 2015-02-28    160
# 2015-03-31    250
# 2015-04-30    360
# Freq: M, dtype: int64

df.apply(lambda x: x['N1'] * x['N2'], axis=1) > 100
# 2014-11-30    False
# 2014-12-31    False
# 2015-01-31    False
# 2015-02-28     True
# 2015-03-31     True
# 2015-04-30     True
# Freq: M, dtype: bool

df[df.apply(lambda x: x['N1'] * x['N2'], axis=1) > 100]
#             N1  N2  N3   F1   F2 S1 S2         D1
# 2015-02-28   4  40   3  4.4  4.4  D  X 2014-11-04
# 2015-03-31   5  50   2  5.5  5.5  E  E 2014-11-05
# 2015-04-30   6  60   1  6.6  6.6  F  F 2014-11-06

補足 上ではあえて apply を使ったが、各列同士は直接 要素の積をとれるため別に apply が必須ではない。

df[df['N1'] * df['N2'] > 100] 
#             N1  N2  N3   F1   F2 S1 S2         D1
# 2015-02-28   4  40   3  4.4  4.4  D  X 2014-11-04
# 2015-03-31   5  50   2  5.5  5.5  E  E 2014-11-05
# 2015-04-30   6  60   1  6.6  6.6  F  F 2014-11-06

リストに含まれる値だけ選択したい

index の例と同じ。Seriesisin メソッドを持っているので、"S1" カラムの値が "A" もしくは "D" の列を選択するときは、

df['S1'].isin(['A', 'D'])
# 2014-11-30     True
# 2014-12-31    False
# 2015-01-31    False
# 2015-02-28     True
# 2015-03-31    False
# 2015-04-30    False
# Freq: M, Name: S1, dtype: bool

df[df['S1'].isin(['A', 'D'])]
#             N1  N2  N3   F1   F2 S1 S2         D1
# 2014-11-30   1  10   6  1.1  1.1  A  A 2014-11-01
# 2015-02-28   4  40   3  4.4  4.4  D  X 2014-11-04

値をソートして選択したい

DataFrame.sort。ソート順序の変更など、オプションの詳細はこちら

"N2" カラムの値が大きいものを 上から順に 3行分 取得するには、ソートして 行番号でスライスすればよい。

df.sort('N2', ascending=False)[:3]
#             N1  N2  N3   F1   F2 S1 S2         D1
# 2015-04-30   6  60   1  6.6  6.6  F  F 2014-11-06
# 2015-03-31   5  50   2  5.5  5.5  E  E 2014-11-05
# 2015-02-28   4  40   3  4.4  4.4  D  X 2014-11-04

特定の年, 月, etc... のデータだけ選択したい

日時型のカラムに対しては、dt アクセサを利用して日時型の各プロパティにアクセスできる。使えるプロパティはこちら

"D1" カラムの日付が 2日, 3日, 5日の行だけ取得したければ、dt アクセサ + isin で、

df['D1']
# 2014-11-30   2014-11-01
# 2014-12-31   2014-11-02
# 2015-01-31   2014-11-03
# 2015-02-28   2014-11-04
# 2015-03-31   2014-11-05
# 2015-04-30   2014-11-06
# Freq: M, Name: D1, dtype: datetime64[ns]

df['D1'].dt.day
# 2014-11-30    1
# 2014-12-31    2
# 2015-01-31    3
# 2015-02-28    4
# 2015-03-31    5
# 2015-04-30    6
# Freq: M, dtype: int64

df['D1'].dt.day.isin([2, 3, 5])
# 2014-11-30    False
# 2014-12-31     True
# 2015-01-31     True
# 2015-02-28    False
# 2015-03-31     True
# 2015-04-30    False
# Freq: M, dtype: bool

df[df['D1'].dt.day.isin([2, 3, 5])]
#             N1  N2  N3   F1   F2 S1 S2         D1
# 2014-12-31   2  20   5  2.2  2.2  b  X 2014-11-02
# 2015-01-31   3  30   4  3.3  3.3  C  X 2014-11-03
# 2015-03-31   5  50   2  5.5  5.5  E  E 2014-11-05

補足 dt アクセサは集計でもかなり強力なのでオススメ。

Python pandas アクセサ / Grouperで少し高度なグルーピング/集計 - StatsFragments

Python 組み込みの文字列関数を使ってデータ選択したい

object型のカラムに対しては、str アクセサを利用して、Python 組み込みの 文字列関数を実行した結果を Series として取得できる。使えるメソッドこちら。したがって、文字列関数を実行するだけならわざわざ apply を使わなくても済む。

str.lower を使って値が小文字の列を選択してみる。

# "S1" カラムの値を小文字化
df['S1'].str.lower()
# 2014-11-30    a
# 2014-12-31    b
# 2015-01-31    c
# 2015-02-28    d
# 2015-03-31    e
# 2015-04-30    f
# Freq: M, Name: S1, dtype: object

df['S1'].str.lower() == df['S1']
# 2014-11-30    False
# 2014-12-31     True
# 2015-01-31    False
# 2015-02-28    False
# 2015-03-31    False
# 2015-04-30    False
# Freq: M, Name: S1, dtype: bool

df[df['S1'].str.lower() == df['S1']]
#             N1  N2  N3   F1   F2 S1 S2         D1
# 2014-12-31   2  20   5  2.2  2.2  b  X 2014-11-02

2015/05/06追記 v0.16以降で .str から呼び出せる関数が追加されている。追加関数には str.islower もあるため、上の例は以下のように書ける。

df[df['S1'].str.islower()] 
#             N1  N2  N3   F1   F2 S1 S2         D1
# 2014-12-31   2  20   5  2.2  2.2  b  X 2014-11-02

値が重複したデータを削除したい

DataFrame.drop_duplicates。重複のうち、最初のひとつ もしくは 最後のひとつ どちらかを残す挙動は Index のときと同様。

df_dup2 = pd.DataFrame({'N1': [1, 1, 3, 1],
                        'N2': [1, 1, 4, 4],
                        'S1': ['A', 'A', 'B', 'C']})

df_dup2
#    N1  N2 S1
# 0   1   1  A
# 1   1   1  A
# 2   3   4  B
# 3   1   4  C

# 完全に重複している行を、最初の一つを残して削除
df_dup2.drop_duplicates()
#    N1  N2 S1
# 0   1   1  A
# 2   3   4  B
# 3   1   4  C

# N1 カラムの値が重複している行を、最初の一つを残して削除
df_dup2.drop_duplicates(subset=['N1'])
#    N1  N2 S1
# 0   1   1  A
# 2   3   4  B

重複している値 全てを削除したければ、Index の場合と同じく groupby して filter

df_dup2.groupby(["S1"]).filter(lambda x:x.shape[0] == 1)
#    N1  N2 S1
# 2   3   4  B
# 3   1   4  C

欠測値 ( NaN ) のデータを削除したい

DataFrame.dropna。チェックするデータの方向 (行方向 or 列方向), 条件などのオプションの詳細はこちら

サンプルとして、いくつかの欠測値 ( NaN ) を含むデータを作成。

df_na = pd.DataFrame({'N1': [1, 2, np.nan, 4],
                      'N2': [6, 5, 4, np.nan],
                      'S1': [np.nan, 'B', 'C', 'D']})

df_na 
#    N1  N2   S1
# 0   1   6  NaN
# 1   2   5    B
# 2 NaN   4    C
# 3   4 NaN    D

# NaN がひとつでもある行を削除
df_na.dropna()
#    N1  N2 S1
# 1   2   5  B

# N2 カラムに NaN がある行を削除
df_na.dropna(subset=['N2'])
#    N1  N2   S1
# 0   1   6  NaN
# 1   2   5    B
# 2 NaN   4    C

# 0 or 1 行目に NaN がある列を削除
df_na.dropna(axis=1, subset=[0, 1])
#    N1  N2
# 0   1   6
# 1   2   5
# 2 NaN   4
# 3   4 NaN

まとめ

pandas で行 / 列からデータ選択する方法をざっとまとめた。少し複雑なデータ選択をするときにやるべき流れは、

  • 対象となる 行 / 列のデータと選択条件を確認する
  • API ガイドにやりたい処理に直接 対応するものがないか探す。
  • 直接 対応するものがなければ、全体をいくつかの単純な処理に分解して、最終的に__getitem__ix に渡せる形にできるか考える。
  • 自作関数を使ったほうが早そうだな、、、というときには Index.map or DataFrame.apply

次こそ wherequery

11/18 追記:

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Python pandas データ選択処理をちょっと詳しく <前編>

概要

書いていて長くなったため、まず前編として pandas で データを行 / 列から選択する方法を少し詳しく書く。特に、個人的にはけっこう重要だと思っている lociloc について 日本語で整理したものがなさそうなので。

サンプルデータの準備

import pandas as pd

s = pd.Series([1, 2, 3], index = ['I1', 'I2', 'I3'])

df = pd.DataFrame({'C1': [11, 21, 31],
                   'C2': [12, 22, 32],
                   'C3': [13, 23, 33]},
                  index = ['I1', 'I2', 'I3'])

s
# I1    1
# I2    2
# I3    3
# dtype: int64

df
#     C1  C2  C3
# I1  11  12  13
# I2  21  22  23
# I3  31  32  33

__getitem__ での選択

Series, もしくは DataFrame に対して、__getitem__ でアクセスした場合の挙動は以下のようになる。

  • Series : index からの選択
  • DataFrame : columns からの選択 (例外あり、下表 * の箇所)

なんかあいまいな言い方なのは 下表のとおりいくつかの形式で index, columns を指定できるから。使える形式は SeriesDataFrame で少し違うので注意。

Series DataFrame
index の名前 (もしくは名前のリスト) columns の名前 (もしくは名前のリスト)
index の順序 (番号) (もしくは番号のリスト) columns の順序 (番号)のリスト (番号のみはダメ) ただし、slice を渡した場合は index からの選択 *
indexと同じ長さの bool のリスト indexと同じ長さの bool のリスト *
- データと同じ次元の boolDataFrame

※ 上で "リスト" と書いている箇所は numpy.array, pd.Series, slice なんかのリスト - like でもよい。

11/15追記: 元データと同じ次元の DataFrame が渡せることの記載漏れ、またDataFrameslice を渡した場合の挙動が間違っていたので修正。

s[0]
# 1

s['I1']
# 1

df['C1']
# I1    11
# I2    21
# I3    31
# Name: C1, dtype: int64

# 番号を数値として渡すとNG!
df[1]
# KeyError: 1

# 番号のリストならOK (columns からの選択)
df[[1]] 
#     C2
# I1  12
# I2  22
# I3  32

# 番号のスライスもOK (index からの選択)
df[1:2]
#     C1  C2  C3
# I2  21  22  23

# NG!
df['I1']
# KeyError: 'I1'

s[[True, False, True]]
# I1    1
# I3    3
# dtype: int64

df[[True, False, True]]
#     C1  C2  C3
# I1  11  12  13
# I3  31  32  33


# bool の DataFrame を作る
df > 21
#        C1     C2     C3
# I1  False  False  False
# I2  False   True   True
# I3   True   True   True

# bool の DataFrame で選択
df[df > 21] 
#     C1  C2  C3
# I1 NaN NaN NaN
# I2 NaN  22  23
# I3  31  32  33

引数による 返り値の違い

引数を値だけ (ラベル, 数値)で渡すと次元が縮約され、Series では単一の値, DataFrame では Series が返ってくる。元のデータと同じ型の返り値がほしい場合は 引数をリスト型にして渡せばいい。

# 返り値は 値
s['I1']
# 1

# 返り値は Series
s[['I1']]
# I1    1
# dtype: int64

# 返り値は Series
df['C1']
# I1    11
# I2    21
# I3    31
# Name: C1, dtype: int64

# 返り値は DataFrame
df[['C1']]
#     C1
# I1  11
# I2  21
# I3  31

index, columns を元にした選択 ( ix, loc, iloc )

ix プロパティを使うと DataFrameindex, columns 両方を指定してデータ選択を行うことができる ( Series の挙動は __getitem__ と同じ)。

引数として使える形式は __getitem__ と同じだが、ix では DataFrame も以下すべての形式を使うことができる。

  • 名前 (もしくは名前のリスト)
  • 順序 (番号) (もしくは番号のリスト)
  • index, もしくは columns と同じ長さの bool のリスト

ixメソッドではなくプロパティなので、呼び出しは以下のようになる。

  • Series.ix[?] : ? にはindex を特定できるものを指定
  • DataFrame.ix[?, ?] : ? にはそれぞれ index, columns の順に特定できるものを指定
# 名前による指定
s.ix['I2']
# 2

df.ix['I2', 'C2']
# 22

# 順序による指定
s.ix[1]
# 2

df.ix[1, 1]
# 22

# 名前のリストによる指定
s.ix[['I1', 'I3']]
# I1    1
# I3    3
# dtype: int64

df.ix[['I1', 'I3'], ['C1', 'C3']]
#     C1  C3
# I1  11  13
# I3  31  33

# bool のリストによる指定
s.ix[[True, False, True]]
# I1    1
# I3    3
# dtype: int64

df.ix[[True, False, True], [True, False, True]]
#     C1  C3
# I1  11  13
# I3  31  33

# 第一引数, 第二引数で別々の形式を使うこともできる
df.ix[1:, "C1"]
# I2    21
# I3    31
# Name: C1, dtype: int64

DataFrame.ix の補足

DataFrameで第二引数を省略した場合は index への操作になる。

df.ix[1]
# C1    21
# C2    22
# C3    23
# Name: I2, dtype: int64

DataFramecolumns に対して操作したい場合、以下のように第一引数を空にするとエラーになる ( R に慣れているとやりがち、、 )。第一引数には : を渡す必要がある (もしくは ixを使わず 直接 __getitem__ する )。

df.ix[, 'C3']
# SyntaxError: invalid syntax

df.ix[:, 'C3']
I1    13
I2    23
I3    33
Name: C3, dtype: int64

引数による 返り値の違い

引数の型による返り値の違いは __getitem__ の動きと同じ。Series については挙動もまったく同じなので、ここでは DataFrame の場合だけ例示。

# 返り値は 値
df.ix[1, 1]
# 22

# 返り値は Series
df.ix[[1], 1]
# I2    22
# Name: C2, dtype: int64

# 返り値は DataFrame
df.ix[[1], [1]]
#     C2
# I2  22

ということで、だいたいの場合は ix を使えばよしなにやってくれる。

とはいえ

ラベル名 もしくは 番号 どちらかだけを指定してデータ選択したい場合もある。例えば、

  • index, columnsint 型である
  • index, columns に重複がある
df2 = pd.DataFrame({1: [11, 21, 31],
                    2: [12, 22, 32],
                    3: [13, 23, 33]},
                   index = [2, 2, 2])
df2
#     1   2   3
# 2  11  12  13
# 2  21  22  23
# 2  31  32  33

内部的には ix は ラベルを優先して処理を行うため、上記のデータについては以下のような動作をする。

  • index2 を指定すると、3行目ではなく indexのラベルが 2 の行を選択
  • columns[1, 2] を指定すると、2, 3列目ではなく columns のラベルが 1, 2 の列を選択
df2.ix[2, [1, 2]]
    1   2
2  11  12
2  21  22
2  31  32

つまり 上記のようなデータ、(特にデータによって index, columns の値が変わるとか、、) では ix を使うと意図しない挙動をする可能性がある。明示的に index, columns を番号で指定したい!というときには iloc を使う。

df2.iloc[2, [1, 2]]
2    32
3    33
Name: 2, dtype: int64

# 3列目は存在しないので NG! 
df2.iloc[2, 3]
# IndexError: index out of bounds

同様に、明示的にラベルのみで選択したい場合は loc

df2.loc[2, [1, 2]]
    1   2
2  11  12
2  21  22
2  31  32

# ラベルが 1 の index は存在しないので NG! 
df.loc[1, 2]
# KeyError: 'the label [1] is not in the [index]'

ix, iloc, loc については文法 / 挙動は基本的に一緒で、使える引数の形式のみが異なる。整理すると、

使える引数の形式 ix iloc loc
名前 (もしくは名前のリスト) -
順序 (番号) (もしくは番号のリスト) -
index, もしくは columns と同じ長さの bool のリスト

一般に、pandasスクリプトを書くような場合には index, columns をどちらの形式で指定して選択するか決まっているはず。そんなときは loc , iloc を使っておいたほうが安全だと思う。

プロパティによるアクセス

Seriesindex, DataFramecolumns はプロパティとしてもアクセスできる。が、オブジェクト自体のメソッド/プロパティと衝突した場合は メソッド/プロパティが優先されるので使わないほうがよい。例えば 上の ix を列名に持つデータがあったとすると、

s.I1
# 1

df3 = pd.DataFrame({'C1': [11, 21, 31],
                    'C2': [12, 22, 32],
                    'ix': [13, 23, 33]},
                    index = ['I1', 'I2', 'I3'])
df3
#     C1  C2  ix
# I1  11  12  13
# I2  21  22  23
# I3  31  32  33

df3.C1
# I1    11
# I2    21
# I3    31
# Name: C1, dtype: int64

# NG!
df3.ix
<pandas.core.indexing._IXIndexer at 0x10bb31890>

bool のリストによってデータ選択ができるということは

pandas.Seriesnumpy.array への演算は原則 リスト内の各要素に対して適用され、結果は真偽値の numpy.array (もしくは Series ) になる。また、真偽値の numpy.array 同士で論理演算することもできる。

df.columns == 'C3'
# array([False, False,  True], dtype=bool)

df.columns.isin(['C1', 'C2'])
# array([ True,  True, False], dtype=bool)

(df.columns == 'C3') | (df.columns == 'C2')
# array([False,  True,  True], dtype=bool)

そのため、上記のような条件式をそのまま行列選択時の引数として使うことができる。

df.ix[df.index.isin(['I1', 'I2']), df.columns == 'C3']
#     C3
# I1  13
# I2  23

上の例ではあまりありがたみはないと思うが、外部関数で bool のリストを作ってしまえばどんなに複雑な行列選択でもできるのは便利。

まとめ

pandas で行 / 列からデータ選択するとき、

  • 手元で対話的にちょっと試す場合は ix が便利。
  • ある程度の期間使うようなスクリプトを書く場合は 少し面倒でも iloc, loc が安全。

後編はもう少し複雑なデータ選択( where, query ) あたりを予定。

11/15追記: 取消線した内容とは違うが続きを書いた。

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Python pandas で日時関連のデータ操作をカンタンに

概要

Python で日時/タイムスタンプ関連の操作をする場合は dateutilarrow を使っている人が多いと思うが、 pandas でもそういった処理がわかりやすく書けるよ、という話。

pandas の本領は多次元データの蓄積/変形/集約処理にあるが、日時操作に関連した強力なメソッド / ユーティリティもいくつか持っている。今回は それらを使って日時操作を簡単に行う方法を書いてく。ということで DataFrameSeries もでてこない pandas 記事のはじまり。

※ ここでいう "日時/タイムスタンプ関連の操作" は文字列パース、日時加算/減算、タイムゾーン設定、条件に合致する日時のリスト生成などを想定。時系列補間/リサンプリングなんかはまた膨大になるので別途。

インストール

以下サンプルには 0.15での追加機能も含まれるため、0.15 以降が必要。

pip install pandas

準備

import pandas as pd

初期化/文字列パース

pd.to_datetime が便利。返り値は Python 標準のdatetime.datetime クラスを継承したpd.Timestamp インスタンスになる。

dt = pd.to_datetime('2014-11-09 10:00')
dt
# Timestamp('2014-11-09 10:00:00')

type(dt)
# pandas.tslib.Timestamp

import datetime
isinstance(dt, datetime.datetime)
# True

pd.to_datetime は、まず pandas 独自の日時パース処理を行い、そこでパースできなければ dateutil.parser.parse を呼び出す。そのため結構無茶なフォーマットもパースできる。

pd.to_datetime('141109 1005')
# Timestamp('2014-11-09 10:05:00')

リスト-likeなものを渡すと DatetimeIndex ( pandas を 普段 使ってない方は 日時のリストみたいなものだと思ってください) が返ってくる。

pd.to_datetime(['2014-11-09 10:00', '2014-11-10 10:00'])
# <class 'pandas.tseries.index.DatetimeIndex'>
# [2014-11-09 10:00:00, 2014-11-10 10:00:00]
# Length: 2, Freq: None, Timezone: None

また、pd.to_datetime は文字列以外の 日付-like なものも処理できる。自分は numpy.datetime64datetime.datetimeに変換するのに使ったりもする。

pd.to_datetime(datetime.datetime(2014, 11, 9))
# Timestamp('2014-11-09 00:00:00')

import numpy as np
pd.to_datetime(np.datetime64('2014-11-09 10:00Z'))
# Timestamp('2014-11-09 10:00:00')

補足 Timestamp.to_datetime()Timestamp -> datetime.datetime へ変換できる

dt.to_datetime()
# datetime.datetime(2014, 11, 9, 10, 0)

日時の加算/減算

pd.Timestamp に対して datetime.timedelta, dateutil.relativedeltaを使って日時の加減算を行うこともできるが、

dt + datetime.timedelta(days=1)
# Timestamp('2014-11-10 10:00:00')

from dateutil.relativedelta import relativedelta
dt + relativedelta(days=1)
# Timestamp('2014-11-10 10:00:00')

一般的な時間単位は pandasoffsets として定義しているため、そちらを使ったほうが直感的だと思う。使えるクラスの一覧は こちら

dt
# Timestamp('2014-11-09 10:00:00')

import pandas.tseries.offsets as offsets
# 翌日
dt + offsets.Day()
# Timestamp('2014-11-10 10:00:00')

# 3日後
dt + offsets.Day(3)
# Timestamp('2014-11-12 10:00:00')

pd.offsets を使うメリットとして、例えば dateutil.relativedelta では daysday を間違えるとまったく違う意味になってしまう。が、pd.offsets ならより明瞭に書ける。

# relativedelta の場合 days なら 3日後
dt + relativedelta(days=3)
# Timestamp('2014-11-12 10:00:00')

# day なら月の 3日目
dt + relativedelta(day=3)
# Timestamp('2014-11-03 10:00:00')

# 月の3日目を取りたいならこっちのが明瞭
dt - offsets.MonthBegin() + offsets.Day(2)
# Timestamp('2014-11-03 10:00:00')

時刻部分を丸めたければ normalize=True

dt + offsets.MonthEnd(normalize=True)
# Timestamp('2014-11-30 00:00:00')

また、 pd.offsetsdatetime.datetime, np.datetime64 にも適用できる。

datetime.datetime(2014, 11, 9, 10, 00) + offsets.Week(weekday=2)
# Timestamp('2014-11-12 10:00:00')

np.datetime64 に対して適用する場合は 加算/減算オペレータではなく、pd.offsets インスタンス.apply メソッドを使う。.applyTimestamp / datetime にも使える。(というか加減算オペレータも裏側では .apply を使っている)。

offsets.Week(weekday=2).apply(np.datetime64('2014-11-09 10:00:00Z'))
# Timestamp('2014-11-12 10:00:00')

offsets.Week(weekday=2).apply(dt)
# Timestamp('2014-11-12 10:00:00')

ある日付が offsets に乗っているかどうか調べる場合は .onOffset。例えば ある日付が水曜日 ( weekday=2 ) かどうか調べたければ、

# 11/09は日曜日 ( weekday=6 )
dt
# Timestamp('2014-11-09 10:00:00')

pd.offsets.Week(weekday=2).onOffset(dt)
# False

pd.offsets.Week(weekday=2).onOffset(dt + offsets.Day(3))
# True

タイムゾーン

タイムゾーン関連の処理は tz_localizetz_convert 二つのメソッドで行う。引数は 文字列、もしくは pytz, dateutil.tz インスタンス (後述)。

上でつくった Timestamp について、現在の表示時刻 (10:00) をそのままにして タイムゾーンを付与する場合は tz_localize

dt
# Timestamp('2014-11-09 10:00:00')

dt.tz_localize('Asia/Tokyo')
# Timestamp('2014-11-09 10:00:00+0900', tz='Asia/Tokyo')

現在の表示時刻を世界標準時 (GMTで10:00) とみて タイムゾーン付与する場合は まず GMTtz_localize してから tz_convert

dt.tz_localize('GMT').tz_convert('Asia/Tokyo')
# Timestamp('2014-11-09 19:00:00+0900', tz='Asia/Tokyo')

一度 タイムゾーンを付与したあとは、tz_convertタイムゾーンを変更したり、

dttz = dt.tz_localize('Asia/Tokyo')
dttz
# Timestamp('2014-11-09 10:00:00+0900', tz='Asia/Tokyo')

dttz.tz_convert('US/Eastern')
# Timestamp('2014-11-08 20:00:00-0500', tz='US/Eastern')

タイムゾーンを削除したりできる。

dttz
# Timestamp('2014-11-09 10:00:00+0900', tz='Asia/Tokyo')

dttz.tz_localize(None)
# Timestamp('2014-11-09 10:00:00')

dttz.tz_convert(None)
# Timestamp('2014-11-09 01:00:00')

また、tz_localize, tz_convertpytz, dateutil.tz を区別なく扱える。標準の datetime.datetime では pytzdateutil.tzタイムゾーンの初期化方法が違ったりするのでこれはうれしい。

import pytz
ptz = pytz.timezone('Asia/Tokyo')

dt.tz_localize(ptz)
# Timestamp('2014-11-09 10:00:00+0900', tz='Asia/Tokyo')

from dateutil.tz import gettz
dtz = gettz('Asia/Tokyo')
dt.tz_localize(dtz)
# Timestamp('2014-11-09 10:00:00+0900', tz='dateutil//usr/share/zoneinfo/Asia/Tokyo')

条件に合致する日時のリスト生成

単純な条件での生成

pd.date_range で以下で指定する引数の条件に合致した日時リストを一括生成できる。渡せるのは 下の4つのうち 3つ。例えば start, periods, freq を渡せば end は自動的に計算される。

  • start : 開始時刻
  • end : 終端時刻
  • periods : 内部に含まれる要素の数
  • freq : 生成する要素ごとに変化させる時間単位/周期 ( 1時間ごと、1日ごとなど)。freq として指定できる文字列は こちら。また pd.offsets も使える (後述)。

返り値は上でも少しふれた DatetimeIndex になる。print された DatetimeIndex の読み方として、表示結果の 2行目が 生成された日時の始点 (11/01 10:00) と終端 (11/04 09:00)、3行目が生成されたリストの長さ (72コ)と周期 ( H = 1時間ごと) になる。

dtidx = pd.date_range('2014-11-01 10:00', periods=72, freq='H')
dtidx
# <class 'pandas.tseries.index.DatetimeIndex'>
# [2014-11-01 10:00:00, ..., 2014-11-04 09:00:00]
# Length: 72, Freq: H, Timezone: None

生成された DatetimeIndex から Timestamp を取得する場合、ふつうに要素選択するか、 .asobject.tolist() を使う。.asobject.tolist() の意味は、

  • asobject : DatetimeIndex 内の要素を Timestamp オブジェクトに明示的に変換 (メソッドでなくプロパティなので注意)
  • tolist() : DatetimeIndex 自身と同じ中身のリストを生成
dtidx[1]
# Timestamp('2014-11-01 11:00:00', offset='H')

dtidx.asobject.tolist()
# [Timestamp('2014-11-01 10:00:00', offset='H'),
#  Timestamp('2014-11-01 11:00:00', offset='H'),
#  ...
#  Timestamp('2014-11-04 08:00:00', offset='H'),
#  Timestamp('2014-11-04 09:00:00', offset='H')]

少し複雑 (n個おき) な条件での生成

また、freqoffsets を与えればもう少し複雑な条件でもリスト生成できる。ある日時から3時間ごとの Timestamp を生成したい場合は以下のように書ける。

pd.date_range('2014-11-01 10:00', periods=72, freq=offsets.Hour(3))
# <class 'pandas.tseries.index.DatetimeIndex'>
# [2014-11-01 10:00:00, ..., 2014-11-10 07:00:00]
# Length: 72, Freq: 3H, Timezone: None

さらに複雑な条件 (任意の条件) での生成

さらに複雑な条件を使いたい場合は、一度単純な条件でリスト生成し、必要な部分をスライシングして取得するのがよい。例えば 深夜 0時から5時間ごとに1時間目/4時間目の時刻をリストとして取得したい場合は以下のようにする。

これで dateutil.rrule でやるような複雑な処理も (大部分? すべて?) カバーできるはず。

dtidx = pd.date_range('2014-11-01 00:00', periods=20, freq='H')
selector = [False, True, False, False, True] * 4
dtidx[selector].asobject.tolist()
# [Timestamp('2014-11-01 01:00:00'),
#  Timestamp('2014-11-01 04:00:00'),
#  Timestamp('2014-11-01 06:00:00'),
#  Timestamp('2014-11-01 09:00:00'),
#  Timestamp('2014-11-01 11:00:00'),
#  Timestamp('2014-11-01 14:00:00'),
#  Timestamp('2014-11-01 16:00:00'),
#  Timestamp('2014-11-01 19:00:00')]

DatetimeIndex に対する一括処理

最後に、先のセクションで記載した 時刻 加減算, タイムゾーン処理の方法/メソッドDatetimeIndex に対しても利用できる。全体について 何かまとめて処理したい場合は DatetimeIndex の時点で処理しておくと楽。

dtidx
# <class 'pandas.tseries.index.DatetimeIndex'>
# [2014-11-01 00:00:00, ..., 2014-11-01 19:00:00]
# Length: 20, Freq: H, Timezone: None

# 1日後にずらす
dtidx + offsets.Day()
# <class 'pandas.tseries.index.DatetimeIndex'>
# [2014-11-02 00:00:00, ..., 2014-11-02 19:00:00]
# Length: 20, Freq: H, Timezone: None

# タイムゾーンを設定
dtidx.tz_localize('Asia/Tokyo')
# <class 'pandas.tseries.index.DatetimeIndex'>
# [2014-11-01 00:00:00+09:00, ..., 2014-11-01 19:00:00+09:00]
# Length: 20, Freq: H, Timezone: Asia/Tokyo

まとめ

pandasなら日時/タイムスタンプ関連の操作もカンタン。

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Python pandas アクセサ / Grouperで少し高度なグルーピング/集計

日本語の説明がなさそうなので。

概要

pandas では groupby メソッドを使って、指定したカラムの値でデータをグループ分けできる。ここでは少し凝った方法を説明。

dtアクセサ の追加、またグルーピング関連のバグ修正がいろいろ入っているので、0.15以降が必要。

※簡単な処理については下の記事でまとめ。

はじめに

例えばこんなデータがあったとして、

import pandas as pd
import datetime


df = pd.DataFrame({'dt1': [datetime.datetime(2014, 10, 1),
                           datetime.datetime(2014, 10, 2),
                           datetime.datetime(2014, 10, 3),
                           datetime.datetime(2014, 10, 4),
                           datetime.datetime(2014, 10, 5),
                           datetime.datetime(2014, 10, 6),
                           datetime.datetime(2014, 10, 7),
                           datetime.datetime(2014, 11, 1),
                           datetime.datetime(2014, 11, 2),
                           datetime.datetime(2014, 11, 3),
                           datetime.datetime(2014, 11, 4),
                           datetime.datetime(2014, 11, 5),
                           datetime.datetime(2014, 11, 6),
                           datetime.datetime(2014, 11, 7)],
                   'col1': 'a b'.split() * 7})

#   col1        dt1  num1
# 0    a 2014-10-01     3
# 1    b 2014-10-02     4
# 2    a 2014-10-03     2
# 3    b 2014-10-04     5
# 4    a 2014-10-05     1
# 5    b 2014-11-01     3
# 6    a 2014-11-02     2
# 7    b 2014-11-03     3
# 8    a 2014-11-04     5
# 9    b 2014-11-05     2

まずは普通の groupbyDataFramegroupby すると、結果は DataFrameGroupBy インスタンスとして返ってくる。このとき、groups プロパティに グループのラベル : グループに含まれる index からなる辞書が入っている。サンプルスクリプトでの結果は、この groups プロパティを使って表示することにする。

下の結果は、上の DataFrame をカラム "col1" の値でグループ分けした結果、グループ "a" に 0, 2, 4, 6, 8行目, グループ "b" に 1, 3, 5, 7, 9行目が含まれていることを示す。

df.groupby('col1').groups

# {'a': [0L, 2L, 4L, 6L, 8L],
#  'b': [1L, 3L, 5L, 7L, 9L]}

groupby へのリスト渡し

groupbyには 対象の DataFrameと同じ長さのリスト, numpy.array, pandas.Series, pandas.Indexが渡せる。このとき、渡した リスト-likeなものをキーとしてグルーピングしてくれる。

grouper = ['g1', 'g2', 'g3', 'g4', 'g5'] * 2
df.groupby(grouper).groups

# {'g5': [4L, 9L],
#  'g4': [3L, 8L],
#  'g3': [2L, 7L],
#  'g2': [1L, 6L],
#  'g1': [0L, 5L]}

そのため、一度 外部の関数を通せばどんな柔軟な処理でも書ける。"num1" カラムが偶数か奇数かでグループ分けしたければ、

grouper = df['num1'] % 2
df.groupby(grouper).groups

# {0: [1L, 2L, 6L, 9L],
#  1: [0L, 3L, 4L, 5L, 7L, 8L]}

アクセサ

でも わざわざ関数書くのめんどくさい、、、。そんなときの アクセサ。例えばカラムが datetime64 型のとき、dt アクセサを利用して datetime64 の各プロパティにアクセスできる。

例えば "dt1" カラムの各日時から "月" を取得するには、

df['dt1'].dt.month

# 0    10
# 1    10
# 2    10
# 3    10
# 4    10
# 5    11
# 6    11
# 7    11
# 8    11
# 9    11
# dtype: int64

これを使えば月次のグルーピングも簡単。

df.groupby(df['dt1'].dt.month).groups

# {10: [0L, 1L, 2L, 3L, 4L],
#  11: [5L, 6L, 7L, 8L, 9L]}

複数要素、例えば年月の組み合わせでグループ分けしたければ、 dt.yeardt.month をリストで渡す。

df.groupby([df['dt1'].dt.year, df['dt1'].dt.month]).groups
# {(2014L, 11L): [5L, 6L, 7L, 8L, 9L],
#  (2014L, 10L): [0L, 1L, 2L, 3L, 4L]}

Grouperで周期的なグルーピング

数日おきなど、特定の周期/間隔でグループ分けしければ Grouperfreqキーワードの記法は Documentation 参照。

Grouperdatetime64 をグループ分けした場合、groups プロパティの読み方はちょっと難しくなる (辞書の値として、グループに含まれる indexではなくグループの切れ目になる index が入る)

df.groupby(pd.Grouper(key='dt1', freq='4d')).groups

# {Timestamp('2014-10-09 00:00:00', offset='4D'): 5,
#  Timestamp('2014-11-02 00:00:00', offset='4D'): 10,
#  Timestamp('2014-10-13 00:00:00', offset='4D'): 5,
#  Timestamp('2014-10-01 00:00:00', offset='4D'): 4,
#  Timestamp('2014-10-05 00:00:00', offset='4D'): 5,
#  Timestamp('2014-10-25 00:00:00', offset='4D'): 5,
#  Timestamp('2014-10-29 00:00:00', offset='4D'): 6,
#  Timestamp('2014-10-17 00:00:00', offset='4D'): 5,
#  Timestamp('2014-10-21 00:00:00', offset='4D'): 5}

読めないので同じ処理をした結果を普通に print。4日おきに グループ分けされていることがわかる。 対応する日付のない期間はパディングされる。

for name, group in df.groupby(pd.Grouper(key='dt1', freq='4d')):
    print(name)
    print(group)

# 2014-10-01 00:00:00
#   col1        dt1  num1
# 0    a 2014-10-01     3
# 1    b 2014-10-02     4
# 2    a 2014-10-03     2
# 3    b 2014-10-04     5

# 2014-10-05 00:00:00
#   col1        dt1  num1
# 4    a 2014-10-05     1

# 2014-10-09 00:00:00
# Empty DataFrame

# 2014-10-13 00:00:00
# Empty DataFrame

# 2014-10-17 00:00:00
# Empty DataFrame

# 2014-10-21 00:00:00
# Empty DataFrame

# 2014-10-25 00:00:00
# Empty DataFrame

# 2014-10-29 00:00:00
#   col1        dt1  num1
# 5    b 2014-11-01     3

# 2014-11-02 00:00:00
#   col1        dt1  num1
# 6    a 2014-11-02     2
# 7    b 2014-11-03     3
# 8    a 2014-11-04     5
# 9    b 2014-11-05     2

集計

上のようにグルーピングした後、集約関数、もしくは aggregate で普通に集約してもよいが、

df.groupby(df['dt1'].dt.month)['num1'].sum()

# 10    15
# 11    15
# Name: num1, dtype: int64

pandas.pivot_table にも リスト-like、 Grouper を渡して直接集約できる。例えば "dt1" の year を列, month を行として集計したければ、

pd.pivot_table(df, index=df['dt1'].dt.month,
               columns=df['dt1'].dt.year, values='num1', aggfunc=sum)

#     2014
# 10    15
# 11    15

"col1" を列, "dt1"の値を4日おきに行として集計したければ、

pd.pivot_table(df, index=pd.Grouper(key='dt1', freq='4d'),
               columns='col1', values='num1', aggfunc=sum)

# col1         a   b
# dt1               
# 2014-10-01   5   9
# 2014-10-05   1 NaN
# 2014-10-29 NaN   3
# 2014-11-02   7   5

補足 Grouper での集約には二つ既知の バグがあるので注意。

  1. グルーピング対象の列の値に NaTが入っているとき var, std, mean で集約できない
  2. first, last, nth で返ってくる行が 通常の groupby と異なる。

まとめ

  • groupby, pivot_table にはリスト-likeが渡せる。複雑な処理を書きたければ 外部の関数で配列作成してグルーピング/集計する。
  • アクセサ, Grouper 便利。

おまけ

groupbyには 関数、辞書も渡せる。

df.groupby(lambda x: x % 3).groups

# {0: [0L, 3L, 6L, 9L],
#  1: [1L, 4L, 7L],
# 2: [2L, 5L, 8L]}

辞書渡しの場合、存在しないキーはフィルタされる。

grouper = {1: 'a', 2: 'b', 3: 'a'}
df.groupby(by=grouper).groups

# {'a': [1L, 3L],
#  'b': [2L]}

[asin:4873116554:detail]

Python rpy2 で pandas の DataFrame を R の data.frame に変換する

pandasDataFrame を R へ渡す/また R から Python へデータを戻す方法について、本家のドキュメント が書きかけなのでよくわからない。ということで 以前 下の文書を書いたので訳してみる。

DOC: Complete R interface section by sinhrks · Pull Request #7309 · pydata/pandas · GitHub

rpy2 を使うと pandas (Python) <-> R 間のデータの相互変換を以下 2通りの方法で行うことができる。

  1. R の関数を Python に loadし、Python名前空間上で操作する

    • pandas で作ったデータを rpy2 形式に変換
    • R の 関数/処理を Python の関数として load
    • Python に読み込まれた R の関数を使って、rpy2 形式のデータを Python 上で操作する
  2. Python のデータを R に渡し、 R の名前空間上で操作する

    • pandas で作ったデータを rpy2 形式に変換 (ここは一緒)
    • rpy2 を使って、このデータを R の名前空間に転送
    • rpy2 で R へコマンドを送って、 R 上のデータ/関数を操作する
    • 結果を R から Python名前空間へ戻す

何をどちらに読み込ませるかという話なので、1, 2を組み合わせて処理することもできる。

共通

rpy2 のインストール

pip install rpy2

準備

import numpy as np
import pandas as pd
# 表示する行数を指定
pd.options.display.max_rows = 5

R のデータセットを Pandas に読み込む

pandas.rpy.common.load_data で、R のデータセットpandas.DataFrame に変換されて読み込まれる。

import pandas.rpy.common as com
infert = com.load_data('infert')

type(infert)
# pandas.core.frame.DataFrame

infert.head()
#   education  age  parity  induced  case  spontaneous  stratum  pooled.stratum
# 1    0-5yrs   26       6        1     1            2        1               3
# 2    0-5yrs   42       1        1     1            0        2               1
# 3    0-5yrs   39       6        2     1            0        3               4
# 4    0-5yrs   34       4        2     1            0        4               2
# 5   6-11yrs   35       3        1     1            1        5              32

pandas.DataFrame を R に渡せる形式 (rpy2) に変換する

pandas.DataFrame から R に渡せる形式 (rpy2.robjects.DataFrame) への変換は com.convert_to_r_dataframe で行う。

# サンプルの DataFrame 作成
df = pd.DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6], 'C':[7,8,9]},
                  index=["one", "two", "three"])
df
#        A  B  C
# one    1  4  7
# two    2  5  8
# three  3  6  9

r_dataframe = com.convert_to_r_dataframe(df)

type(r_dataframe)
# rpy2.robjects.vectors.DataFrame

# ipython でやる場合、print をかまさないと出力がうっとおしい
print(r_dataframe)
#       A B C
# one   1 4 7
# two   2 5 8
# three 3 6 9

# rpy2.objects.DataFrame に変換後も属性名の確認/操作はできる ( pandas でやっておけば必要ないが)
print(r_dataframe.rownames)
# [1] "one"   "two"   "three"

In [20]: print(r_dataframe.colnames)
[1] "A" "B" "C"

補足 R へ matrix で渡したい場合は com.convert_to_r_matrix

r_matrix = com.convert_to_r_matrix(df)

type(r_matrix)
# rpy2.robjects.vectors.Matrix

print(r_matrix)
#       A B C
# one   1 4 7
# two   2 5 8
# three 3 6 9

rpy2 形式のデータ を pandas.DataFrame に変換する

上の逆変換は com.convert_robj。rpy2 オブジェクト (rpy2.robjects.DataFrame) を pandas.DataFrame に戻す。

com.convert_robj(r_dataframe)
#        A  B  C
# one    1  4  7
# two    2  5  8
# three  3  6  9

com.convert_robj(r_matrix)
#        A  B  C
# one    1  4  7
# two    2  5  8
# three  3  6  9

1. R の関数を Python に loadし、Python名前空間上で操作する

rpy2.robjects.r.__getitem__ で、R の名前空間のオブジェクトを Python名前空間へ load できる。ここでは R の sum 関数をPython 上に読み出して使う。読み込んだ関数へ渡せるのは rpy2 のオブジェクトのみ。

# 処理するデータ
print(r_dataframe)
#       A B C
# one   1 4 7
# two   2 5 8
# three 3 6 9

import rpy2.robjects as robjects

# R 上の sum 関数を rsum として Python の名前空間に load
rsum = robjects.r['sum']
type(rsum)
# rpy2.robjects.functions.SignatureTranslatedFunction

# r_dataframe に対して関数適用 (Python の関数として使える)
rsum_result = rsum(r_dataframe)

# R の関数なので、結果は vector になる
type(rsum_result)
# rpy2.robjects.vectors.IntVector

# 要素を取り出す場合はスライス
rsum_result[0]
# 45

2. Python のデータを R に渡し、 R の名前空間上で操作する

rpy2 のオブジェクトを R の名前空間へ渡す場合は robjects.r.assign。 R で 実行する処理(コマンド)を R に渡す (Rに何か処理をさせる) 場合は robjects.r

# Python 上の r_dataframe が R 上で rdf という名前で転送される
# セミコロンは ipython での出力省略のため
robjects.r.assign('rdf', r_dataframe);

# R 上で str(rdf) コマンドを実行
robjects.r('str(rdf)');
# 'data.frame':    3 obs. of  3 variables:
#  $ A:Class 'AsIs'  int [1:3] 1 2 3
#  $ B:Class 'AsIs'  int [1:3] 4 5 6
#  $ C:Class 'AsIs'  int [1:3] 7 8 9

処理サンプル

これまでの処理の組み合わせで以下のようなことができる。

線形回帰

# データの準備
iris = com.load_data('iris')

# setosa のデータをフィルタ 
setosa = iris[iris['Species'] == 'setosa']
setosa.head() 
#    Sepal.Length  Sepal.Width  Petal.Length  Petal.Width Species
# 1           5.1          3.5           1.4          0.2  setosa
# 2           4.9          3.0           1.4          0.2  setosa
# 3           4.7          3.2           1.3          0.2  setosa
# 4           4.6          3.1           1.5          0.2  setosa
# 5           5.0          3.6           1.4          0.2  setosa

# (ほか、R でやるには面倒な処理があれば pandas で実行... )

# rpy2 オブジェクトに変換
r_setosa = com.convert_to_r_dataframe(setosa)

# R の名前空間に送る
robjects.r.assign('setosa', r_setosa);

# R の lm 関数を実行し、結果の summary を表示する
robjects.r('result <- lm(Sepal.Length~Sepal.Width, data=setosa)');
print(robjects.r('summary(result)'))
# Call:
# lm(formula = Sepal.Length ~ Sepal.Width, data = setosa)
# 
# Residuals:
#      Min       1Q   Median       3Q      Max 
# -0.52476 -0.16286  0.02166  0.13833  0.44428 
# 
# Coefficients:
#             Estimate Std. Error t value Pr(>|t|)    
# (Intercept)   2.6390     0.3100   8.513 3.74e-11 ***
# Sepal.Width   0.6905     0.0899   7.681 6.71e-10 ***
# ---
# Signif. codes:  0 ‘***’ 0.001 ‘**’ 0.01 ‘*’ 0.05 ‘.’ 0.1 ‘ ’ 1
# 
# Residual standard error: 0.2385 on 48 degrees of freedom
# Multiple R-squared:  0.5514, Adjusted R-squared:  0.542 
# F-statistic: 58.99 on 1 and 48 DF,  p-value: 6.71e-10

# R 上の result オブジェクトを Python の名前空間に戻す
result = robjects.r['result']
print(result.names)
#  [1] "coefficients"  "residuals"     "effects"       "rank"         
#  [5] "fitted.values" "assign"        "qr"            "df.residual"  
#  [9] "xlevels"       "call"          "terms"         "model"        

# 結果は名前付きリストになっている。各要素には .rx でアクセスできる
print(result.rx('coefficients'))
# $coefficients
# (Intercept) Sepal.Width 
#   2.6390012   0.6904897 

# 回帰式の切片, 係数を取得
intercept, coef1 = result.rx('coefficients')[0]
intercept
# 2.6390012498579694

coef1
# 0.6904897170776046

時系列処理

時系列の場合に気をつけるのは、

  • convert_to_r_dataframeSeries に対応していないので、rpy2 で直接 ベクトル (ここでは rpy2.FloatVector ) を作って渡す。詳細は rpy2 documentation: Vectors and arrays
  • ts オブジェクトへの変換は R で明示的に行う
  • R から pandas.DataFrame へ結果を戻した際に、日時の index を再度 付与する
# 2013年1月〜 月次 4年分のランダムデータを作成
idx = pd.date_range(start='2013-01-01', freq='M', periods=48)
vts = pd.Series(np.random.randn(48), index=idx).cumsum()
vts
# 2013-01-31    0.801791
# ...
# 2016-12-31   -3.391142
# Freq: M, Length: 48

# R へ渡す rpy2 オブジェクトを準備
r_values = robjects.FloatVector(vts.values)

# R の名前空間へ転送
robjects.r.assign('values', r_values);

# R の ts 関数で timeseries に変換
robjects.r('vts <- ts(values, start=c(2013, 1, 1), frequency=12)');

print(robjects.r['vts'])
#              Jan         Feb         Mar         Apr         May         Jun
# 2013  0.80179068 -0.04157987 -0.15779190 -0.02982779 -2.03214239 -0.09868078
# 2014  5.97378179  6.27023875  4.85958760  6.50728371  5.14595583  5.29780411
# 2015  1.04548632  0.60093762  0.13941486  0.56116450 -0.20040731  1.19696178
# 2016 -0.09101317 -0.79038658 -0.13305769  0.61016756 -0.13059757 -1.28190161
#              Jul         Aug         Sep         Oct         Nov         Dec
# 2013  2.84555901  3.96259305  4.45565104  2.86998914  4.52347928  4.38237841
# 2014  5.16001952  3.44611678  3.49705824  2.37352719  0.75428874  1.62569642
# 2015 -0.03488274  0.13323226 -0.78262492 -0.75325348 -0.65414439  0.40700944
# 2016 -2.31702656 -1.78120320 -1.92904062 -0.83488094 -2.31609640 -3.39114197

# R の stl 関数を実行し、 時系列をトレンド/季節性/残差に分解
robjects.r('result <- stl(vts, s.window=12)');

# 結果を Python の名前空間に戻す
result = robjects.r['result']
print(result.names)
# [1] "time.series" "weights"     "call"        "win"         "deg"        
# [6] "jump"        "inner"       "outer"      

# 結果の時系列を取得し、pandas.DataFrame へ変換 (index は数値型になってしまう)
result_ts = result.rx('time.series')[0]
converted = com.convert_robj(result_ts)
converted.head()
#              seasonal     trend  remainder
# 2013.000000  0.716947 -1.123112   1.207956
# 2013.083333  0.264772 -0.603006   0.296655
# 2013.166667 -0.165811 -0.082900   0.090919
# 2013.250000  0.528043  0.437206  -0.995077
# 2013.333333 -0.721440  0.938796  -2.249498

# index を再設定
converted.index = idx
converted.head()
#             seasonal     trend  remainder
# 2013-01-31  0.716947 -1.123112   1.207956
# 2013-02-28  0.264772 -0.603006   0.296655
# 2013-03-31 -0.165811 -0.082900   0.090919
# 2013-04-30  0.528043  0.437206  -0.995077
# 2013-05-31 -0.721440  0.938796  -2.249498

結果をプロットしてみる。

import matplotlib.pyplot as plt
fig, axes = plt.subplots(4, 1)

axes[0].set_ylabel('Original');
ax = vts.plot(ax=axes[0]);

axes[1].set_ylabel('Trend');
ax = converted['trend'].plot(ax=axes[1]);

axes[2].set_ylabel('Seasonal');
ax = converted['seasonal'].plot(ax=axes[2]);

axes[3].set_ylabel('Residuals');
converted['remainder'].plot(ax=axes[3])
plt.show()

f:id:sinhrks:20141013120538p:plain

補足

とはいえ、いちいち pandas -> rpy2 形式へ変換するのは面倒なので、robjects.r で直接 pandas.DataFrame を受け渡しできるとうれしい。やり方は、

ENH: automatic rpy2 instance conversion by sinhrks · Pull Request #7385 · pydata/pandas · GitHub

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理