StatsFragments

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

Theano で Deep Learning <2> : 多層パーセプトロン

Python Theano を使って Deep Learning の理論とアルゴリズムを学ぶ会、第二回。

目次

DeepLearning 0.1 より、

第一回 MNIST データをロジスティック回帰で判別する
第二回 多層パーセプトロン (今回)
第三回 畳み込みニューラルネットワーク
第四回 Denoising オートエンコーダ
第五回 多層 Denoising オートエンコーダ
第六回の準備1 networkx でマルコフ確率場 / 確率伝搬法を実装する -
第六回の準備2 ホップフィールドネットワーク -
第六回 制約付きボルツマンマシン
Deep Belief Networks
Hybrid Monte-Carlo Sampling
Recurrent Neural Networks with Word Embeddings
LSTM Networks for Sentiment Analysis
Modeling and generating sequences of polyphonic music with the RNN-RBM

多層パーセプトロンとは

ロジスティック回帰では判別関数が直線のため、入力データが線形分離できない場合は判別率が落ちていた。

多層パーセプトロンでは、入力を非線形変換/次元変換することによってデータを分離しやすくし、そのような場合の判別率を上げようとするもの。

モデル

多層パーセプトロンを DeepLearning 0.1 Multilayer Perceptron の式をベースに図示するとこんな感じになる。

f:id:sinhrks:20141214220250p:plain

  • { W^{(1)} } : 入力層 - 隠れ層の間で適用される係数行列。次元は 入力データの説明変数の数 { D } x 隠れ層のユニット数 { D_h }
  • { b^{(1)} } : 入力層 - 隠れ層の間で適用される重みベクトル。次元は 隠れ層のユニット数 { D_h }
  • { s } : 隠れ層の活性化関数。シグモイド関数もしくは { tanh } (ハイパボリックタンジェント) をよく使う。
  • { W^{(2)} } : 隠れ層 - 出力層の間で適用される係数行列 = ロジスティック回帰の係数行列。次元は 隠れ層のユニット数 x 出力データのクラス数
  • { b^{(2)} } : 隠れ層 - 出力層の間で適用される重みベクトル = ロジスティック回帰の重みベクトル。次元は 出力データのクラス数
  • { G } : 出力層の活性化関数。2クラスの場合は シグモイド、多クラスの場合はソフトマックス関数 (ロジスティック回帰と同じ)。

つまり 3層パーセプトロンでは、

  1. 入力層 - 隠れ層: { D } 次元の入力を { W^{(1)} }, { b^{(1)} } によって { D_h } 次元へと写像し、
  2. 隠れ層 - 出力層: { D_h } 次元へと写像された入力を { W^{(2)} }, { b^{(2)} } によってロジスティック回帰で学習 & クラス判別する

また、多層パーセプトロンの各層のユニット数 = その層に渡ってくるデータの次元 と考えればよい。

補足 PRML ではバイアス項をダミーのユニットを作って扱っているため、ユニット数 / 係数行列の次元など定義が異なる。

Pattern Recognition and Machine Learning (Information Science and Statistics)

Pattern Recognition and Machine Learning (Information Science and Statistics)

ロジスティック回帰から多層パーセプトロンへ

まず、隠れ層をあらわす HiddenLayer クラスを定義する。

係数行列の初期値の決め方

前回の LogisticRegression クラスと同じく、HiddenLayer クラスは 係数行列 W ( 上式 { W^{(1)} } ) , バイアス b ( 上式 { b^{(1)} } ) を Theano の共有変数として宣言する。

係数行列 W の初期値は以下の範囲の一様乱数からとると (特にネットワークの層が深い場合に) 学習の進みがよいらしい

  • 活性化関数が tanh : f:id:sinhrks:20141129233659p:plain

  • 活性化関数が sigmoid : f:id:sinhrks:20141129233721p:plain

{ fan_{in} }, { fan_{out} } は、それぞれ係数行列の前層 / 後続層のユニット数

そのため、HiddenLayer クラスでは 指定範囲の一様乱数を生成する numpy.random.uniform を使って係数行列 W の初期値を設定している。宣言中で使われている以下の変数は HiddenLayer.__init__ の引数として渡ってきているもの。

  • rng : 乱数のシード
  • n_in : 入力層のユニット数
  • n_out : 隠れ層のユニット数
    W_values = numpy.asarray(
        rng.uniform(
            low=-numpy.sqrt(6. / (n_in + n_out)),
            high=numpy.sqrt(6. / (n_in + n_out)),
            size=(n_in, n_out)
        ),
        dtype=theano.config.floatX
    )
    if activation == theano.tensor.nnet.sigmoid:
        W_values *= 4

    W = theano.shared(value=W_values, name='W', borrow=True)

例えば、前層のユニット数が 3, 後続層のユニット数が 4 で 活性化関数が tanh の場合、係数行列の初期値は以下のようになる。

import numpy

n_in = 3
n_out = 4
numpy.random.uniform(
    low=-numpy.sqrt(6. / (n_in + n_out)),
    high=numpy.sqrt(6. / (n_in + n_out)),
    size=(n_in, n_out)
)
# [[ 0.01572058 -0.46004291 -0.23671422 -0.54857948]
#  [ 0.081257    0.87090393 -0.72051252 -0.38176917]
#  [-0.09591302  0.04587085 -0.42426934 -0.35021306]]

また、バイアス b の初期値は 0ベクトルでよいようだ。

    b_values = numpy.zeros((n_out,), dtype=theano.config.floatX)
    b = theano.shared(value=b_values, name='b', borrow=True)

続けて、隠れ層からの出力を計算する式を HiddenLayer.output プロパティとして定義している。宣言中で使われている以下の変数は HiddenLayer.__init__ の引数として渡ってきているもの。

  • input : 入力データをあらわすシンボル ( TensorVariable 型)。
  • activation : 活性化関数。デフォルトは theano.tensor.tanh ( np.tanhテンソル版 )。

また、HiddenLayer はこの時点では実際のデータを受け取っておらず、Theano のシンボルから式を作っているだけ。ここも考え方は 前回同様。

    lin_output = T.dot(input, self.W) + self.b
    self.output = (
        lin_output if activation is None
        else activation(lin_output)
    )

MLP クラスの定義

続けて 多層パーセプトロン自体をあらわすクラス MLP を定義している。このクラスは 以下二つのインスタンスをプロパティとして持つ。

正則化 (Regularization)

MLP クラスでは 学習の際に 負の対数尤度だけでなく、以下 二つのノルムを正則化項として最小化するように設定している。一般には、これらを罰則 ( Penalty ) として最小化することで過学習をさけ、学習器の汎化性能 (未知のデータへのあてはまりの度合い) を上げることができる。

  • L1 ノルム : 係数行列 { W^{(1)} }, { W^{(2)} } の各要素の絶対値の和
  • L2 ノルム : 係数行列 { W^{(1)} }, { W^{(2)} } の各要素の二乗和
    # L1 ノルム
    self.L1 = (
        abs(self.hiddenLayer.W).sum()
        + abs(self.logRegressionLayer.W).sum()
    )

    # L2 ノルム
    self.L2_sqr = (
        (self.hiddenLayer.W ** 2).sum()
        + (self.logRegressionLayer.W ** 2).sum()
    )

負の対数尤度と判別誤差

他、モデル自体の負の対数尤度 ( negative_log_likelihood ) / 判別誤差 ( errors ) はモデルの出力に対して計算すればよいため、出力層である LogisticRegression での計算結果をそのまま使っている。

    self.negative_log_likelihood = (
        self.logRegressionLayer.negative_log_likelihood
    )

    self.errors = self.logRegressionLayer.errors

損失関数の定義

これら 負の対数尤度と正則化項を足し合わせて損失関数を計算する式 cost を作っている。L1_reg, L2_reg はそれぞれ L1ノルム, L2ノルムの正則化項の重み。既定値は L1_reg=0.00, L2_reg=0.0001 のため、サンプルをそのまま実行した場合は L2 ノルムのみが正則化項として考慮されることになる。

    cost = (
        classifier.negative_log_likelihood(y)
        + L1_reg * classifier.L1
        + L2_reg * classifier.L2_sqr
    )

勾配の計算

ここが今回のポイント。多層パーセプトロンの学習は 誤差逆伝播法 ( Back propagation) というアルゴリズムで行われ、一般には以下のような処理 (順逆モデリング) が必要になる。

  1. 学習器の出力値と、真の値との差異 = 出力誤差を計算
  2. 1 で求めた出力誤差を小さくする { W^{(2)} }, { b^{(2)} } の勾配を計算し、 { W^{(2)} }, { b^{(2)} } を更新
  3. 1 で求めた出力誤差に対して "隠れ層 - 出力層間の処理の逆演算"を行い、"出力誤差が隠れ層からでたと仮定した場合の出力値 = 隠れ層誤差" を求める
  4. 3 で求めた隠れ層誤差を小さくする { W^{(1)} }, { b^{(1)} } の勾配を計算し、 { W^{(1)} }, { b^{(1)} } を更新

、、、めんどくさい、、、。

が、Theano では式表現に対して偏微分を行い偏導関数を求めることができる。そのため、上で定義した損失関数 cost について、最適化したいパラメータ HiddenLayer.W, HiddenLayer.b, LogisticRegression.W, LogisticRegression.b それぞれで偏微分した偏導関数を求めておけば、学習の際はそれらを使ってパラメータごとに勾配計算 / 更新をかけていくことができる。

つまり、今後 パーセプトロンの層 / パラメータが増えた場合でも、モデル全体の損失関数を偏微分することによって各パラメータを個別に学習できることになる。これは強力だな、、、。

  • classifier.params : HiddenLayer.W, HiddenLayer.b, LogisticRegression.W, LogisticRegression.b からなるリスト
  • learning_rate : 学習率。デフォルトは 0.01
    gparams = [T.grad(cost, param) for param in classifier.params]

    updates = [
        (param, param - learning_rate * gparam)
        for param, gparam in zip(classifier.params, gparams)
    ]

続けて定義されている theano.function の読み方は 前回と同じ。以下 一連の処理を行う関数を作っている。

  • indexを引数として受け取り、
  • givens の指定により index の位置のデータを切り出して シンボル x, y に入れ、
  • x, youtputsの式 (負の対数尤度 + 正則化項) に与えて計算結果を得て、
  • updates で指定された更新式 ( 損失関数の偏導関数からの勾配計算 ) によって共有変数 ( 各係数行列、バイアス ) を更新する

全部まとめて

プログラムの量としては Theano のおかげで、え?こんだけでいいの?という感じだ。最後に置いてあるスクリプトを前回と同じディレクトリに入れて実行すればよい。

パラメータ調整のコツ

また、元文書では補足的に以下のパラメータ調整についてカッコ内のようなことが記載されている。自分でパラメータ調整したい方は読むといいですね。

  • 隠れ層の活性化関数 ( tanh がいい感じ )
  • 係数行列の初期値 (上の内容と同じ)
  • 学習率 ( { 10^{-1}, 10^{-2} ... } で試せ、時間で減衰させてみろ )
  • 隠れ層のユニット数 ( データによる。モデルが複雑な場合は増やせ )

隠れ層からの出力の可視化

最後。元文書にはない内容だが、今回のサンプルについて 入力が隠れ層においてどういった感じで分離されるのか、以下の方法で可視化してみた。

  • 学習データ ( train_set_x, train_set_y ) を対象
  • MNIST のラベル 0 〜 9 について、隣り合う 9組 ( 0と1, 1と2 ... ) を比較
  • 隠れ層からの出力データ 500 次元に主成分分析をかけて 2次元に写像
  • ランダムサンプリングした 300 レコードを描画

入力 - 隠れ層 間の変換によって各クラスが 主成分 ( = 分散大 ) の方向に離れていけば、分離超平面がみえるはずだ。

学習開始前 (隠れ層出力)

f:id:sinhrks:20141129231153p:plain

50 エポック学習後 (隠れ層出力)

曇りのない目で見てみると、なんか少し分離しやすそうになったかも?といえなくもない感じですね!

f:id:sinhrks:20141129231204p:plain

プログラム

プロット部分のみ gist に置いた ( 他は元文書のスクリプトと同じなので )。

Theano に関してのポイントは 1 点だけ。隠れ層からの出力を計算する HiddenLayer.outputTensorVariable 型のため、実際に計算するには一度 theano.function を通すこと。

    # 隠れ層の出力 z_data を計算
    apply_hidden = theano.function(inputs=[x_symbol], outputs=classifier.hiddenLayer.output)
    z_data = apply_hidden(x_data.get_value())
    labels = y_data.eval() 

Visualize Multilayer Perceptron Example in deeplearning.net · GitHub

11/30追記: 隠れ層での変換についてもう少しわかりやすい例をつくった。

12/07追記 続きはこちら。

深層学習 (機械学習プロフェッショナルシリーズ)

深層学習 (機械学習プロフェッショナルシリーズ)

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を使ったデータ処理

Theano で Deep Learning <1> : MNIST データをロジスティック回帰で判別する

概要

ここ数年 Deep Learning 勢の隆盛いちじるしい。自分が学生の頃は ニューラルネットワークオワコン扱いだったのに、、、どうしてこうなった?自分もちょっと触ってみようかな、と記事やらスライドやら読んでみても、活性化関数が〜 とか、 制約付き何とかマシンが〜(聞き取れず。何か中ボスっぽい名前)とか、何言っているのかよくわからん。

巷には 中身がわかっていなくてもある程度 使えるパッケージもいくつかあるようだが、せっかくなので少しは勉強したい。

Python 使って できんかな?と思って探してみると、すでに Theano というPython パッケージの開発チームが作った DeepLearning Documentation 0.1 という大部の聖典 (バイブル) があった。

当然だがこの文書では Theano の機能をいろいろ使っているため、ぱっと見では 何をやってんだかよくわからない。ということで、Theano の仕様を調べたり numpy で翻訳したりしながら読み解いてみることにした。

聖典の目次

かなり楽観的な見通しだが、 1章/週 のペースで進めても 3ヶ月近くかかるな、、。

第一回 MNIST データをロジスティック回帰で判別する (今回)
第二回 多層パーセプトロン
第三回 畳み込みニューラルネットワーク
第四回 Denoising オートエンコーダ
第五回 多層 Denoising オートエンコーダ
第六回の準備1 networkx でマルコフ確率場 / 確率伝搬法を実装する -
第六回の準備2 ホップフィールドネットワーク -
第六回 制約付きボルツマンマシン
Deep Belief Networks
Hybrid Monte-Carlo Sampling
Recurrent Neural Networks with Word Embeddings
LSTM Networks for Sentiment Analysis
Modeling and generating sequences of polyphonic music with the RNN-RBM

全体の流れ / スクリプトは元文書を参照。以下の各セクション名には対応するセクションへのリンクを貼っている。

Theano とは

こんなことができます。

  • シンボル(変数名) / 値 (スカラー, ベクトル, 行列) / 式を 数式表現そのもの (構文木) として扱える。
    • 式の定義 / 途中計算結果の再利用が楽
    • 機械学習で頻出する勾配計算を解析的に実行できる
  • 高速な計算実行

インストール

pip install theano

準備

import numpy
import theano
import theano tensor as T

モデル

ロジスティック回帰が 入力をどのようにクラス分類するか?という方法が書いてある。ロジスティック回帰 そのものについてはこちらでまとめた。

プログラム中で まず 目に付くのは以下のような theano.shared 表現。Theano では以下 二つのメモリ空間を明示的にわけて扱う

  • Python 空間 ( user space ) : Python スクリプト上で定義された普通の変数が存在する。
  • Theano 空間 : theano.shared によって定義された変数 (以降、共有変数) が存在する。物理的に Python 空間とは別に確保され、 Theano の式表現(後述)から参照できる。また、GPU へのデータ転送を高速にする。

theano.shared で宣言された変数は 実データとして numpy のデータをもつ。

# この表現で作られるデータは
W = theano.shared(
    value=numpy.zeros(
        (n_in, n_out),
        dtype=theano.config.floatX
    ),
    name='W',
    borrow=True
)

# numpy だとこちらと同じ
W = numpy.zeros((n_in, n_out), dtype=numpy.float64) 

theano.shared による共有変数の定義

上でも指定されている引数 borrowPython 空間上で定義されたデータの実体を 共有変数でも共有するかどうかを決める。

# Python 空間で変数定義
x = numpy.array([1, 1, 1])

# theano.shared で 共有変数化
xs = theano.shared(x)

# 返り値は int32 / vector の TensorSharedVariable 型
xs
# <TensorType(int32, vector)>
type(xs)
# theano.tensor.sharedvar.TensorSharedVariable

# 内部の値を得るためには get_value
xs.get_value()
# array([1, 1, 1])

# デフォルトでは 対象オブジェクトのコピーが 共有変数の実体になるため、
# Python 空間の変数を変更しても 共有変数の実体には影響しない
x[1] = 2
x
# array([1, 2, 1])

xs.get_value()
# array([1, 1, 1])

# 同じ実体を共有したい場合は borrow=True
xs = theano.shared(x, borrow=True)

xs.get_value()
# array([1, 2, 1])

# Python 空間での変更が 共有変数へも反映される
x[1] = 3
x
# array([1, 3, 1])

xs.get_value()
# array([1, 3, 1])

theano.tensor 上の関数の呼び出し

Theano では シンボル(変数名) / スカラー / ベクトル / 行列などはテンソル型として抽象化される。テンソル型に対する処理は theano.tensor 上で定義された関数を使って行う。

例えば、多項ロジスティック回帰では 入力があるクラスに分類される確率はソフトマックス関数を用いて書けたTheano にはソフトマックス関数を計算する theano.tensor.nnet.softmax があって、

d = theano.shared(value=numpy.matrix([[0.1, 0.2, 0.3],
                                      [0.3, 0.2, 0.1],
                                      [0.1, 0.2, 0.3]],
                                     dtype=theano.config.floatX),
                  name='d', borrow=True)
sm = T.nnet.softmax(d)

# 返り値は TensorVariable 型
type(sm)
# <class 'theano.tensor.var.TensorVariable'>

# TensorVariable から結果を取り出す (関数を評価する) には eval()
sm.eval()
# [[ 0.30060961  0.33222499  0.3671654 ]
#  [ 0.3671654   0.33222499  0.30060961]
#  [ 0.30060961  0.33222499  0.3671654 ]]

ロジスティック回帰の予測値は上記の確率を最大にするラベルになる。値が最大となるラベルをとる処理は theano.tensor.argmax。これは numpy.argmaxテンソル版。以下の通り、theano.tensor の関数は通常 (共有変数化されていない) の numpy.array も受け取ることができる。その場合も返り値は TensorVariable 型になる。

# 3 番目の要素 ( index でみて 2 ) が最も大きい
am = T.argmax(numpy.array([1, 2, 3, 2, 1]))

type(am)
# theano.tensor.var.TensorVariable

am.eval()
# array(2L, dtype=int64)

損失関数の定義

これはそのまま。すべて theano.tensor の関数であることには注意。

LogisticRegression のクラス作成

LogisticRegression.errors で誤差 (ここでの誤差は真のクラスと 判別結果の差)を取っている。論理演算 != についても テンソル版である theano.tensor.neq が定義されているのでそちらを使う。

# 真のクラス
y = numpy.array([1, 0, 1, 1, 1])

# 予測値が2つ誤判別しているとする
y_pred = numpy.array([1, 1, 1, 0, 1])

# theano.tensor
T.mean(T.neq(y_pred, y)).eval()
# 0.4

# numpy
numpy.mean(y_pred != y)
# 0.4

モデルの学習

ロジスティック回帰の学習について。勾配 (Gradient) の計算は Theano だと theano.grad を使って簡単にできるようだ。ここからの一連の処理が今回のキモ。

Theano での勾配の計算 (微分)

Theano での勾配の計算については公式ドキュメントに 詳細 がある。Theano では、定義された式にあらわれるシンボル / 定数を構文木として保持している。そのため、勾配計算の際は 構文木解析によって導関数を求めて実行することができる。とりあえず { y = x^2 } を定義して微分してみると、

# シンボル x を宣言 (この時点で x に値はない) 
x = T.dscalar('x')

# y = x ** 2 という式を宣言
# シンボルに対して演算子から式を作っていけるところがカッコイイ
y = x ** 2

# y を x について微分 (まだ値はない)
gy = T.grad(y, x)

# gy の定義 (導関数) を確認
theano.pp(gy)
# '((fill((x ** TensorConstant{2}), TensorConstant{1.0}) * TensorConstant{2}) * (x ** (TensorConstant{2} - TensorConstant{1})))'

TensorConstant{} 内の値をもつスカラー。また、fill は 第一引数を 第二引数で埋める、という意味になる。順番に数値を埋めてみると、、、おお、、、ちゃんと { y' = 2x } になっている。

# '((fill((x ** TensorConstant{2}), TensorConstant{1.0}) * TensorConstant{2}) * (x ** (TensorConstant{2} - TensorConstant{1})))'
# = '((fill((x ** 2), 1.0) * 2) * (x ** (2 - 1)))'
# = '(2 * x)'

続けて、式から関数を作成し、実際の値を渡して計算 (式の評価) を行う。え、式にそのまま引数渡せないの?と思うが、どうも function の実行時に式表現をコンパイルしているのでダメらしい。

# 式から関数を作る。function の第一引数は関数に与える引数, 第二引数に関数化する式
f = function([x], gy)

# x = 4 のときの x ** 2 の傾きを求める
f(4)
# array(8.0)

# x = 8 のときの x ** 2 の傾きを求める
f(8)
# array(16.0)

さて、元の文書にもどってロジスティック回帰を定義 / 学習を始めるところをみてみる。ここでのテンソル型の式から学習器を作っていく流れは格好いい。

    # ミニバッチ確率勾配降下法で指定する index (スカラー)を入れるシンボル
    index = T.lscalar() 
    # 入力データの行列を示すシンボル
    x = T.matrix('x') 
    # ラベルのベクトルを示すシンボル
    y = T.ivector('y')  

    # ロジスティック回帰の処理も theano.tensor の関数で定義されているため、
    # シンボル x が実データなしで渡せる
    classifier = LogisticRegression(input=x, n_in=28 * 28, n_out=10)

    # 引数 y もシンボル。返り値 cost では負の対数尤度を計算する式が返ってくる 
    cost = classifier.negative_log_likelihood(y)

    # w, b の勾配を計算する式
    g_W = T.grad(cost=cost, wrt=classifier.W)
    g_b = T.grad(cost=cost, wrt=classifier.b)

で、最後にこれらを theano.functionで関数化する。引数の意味は、

  • inputs : 関数への入力 (引数)。
  • outputs : 関数化される式。
  • updates : 共有変数を更新する式。
  • givens : 引数 ( inputs ) -> 共有変数へのマッピングを行う辞書。

つまり、

    # w, b の更新を定義する式
    updates = [(classifier.W, classifier.W - learning_rate * g_W),
               (classifier.b, classifier.b - learning_rate * g_b)]

    train_model = theano.function(
        inputs=[index],
        outputs=cost,
        updates=updates,
        givens={
            x: train_set_x[index * batch_size: (index + 1) * batch_size],
            y: train_set_y[index * batch_size: (index + 1) * batch_size]
        }
    )

上の定義は

  • indexを引数として受け取り、
  • givens の指定により index の位置のデータを切り出して シンボル x, y に入れ、
  • x, youtputsの式 (負の対数尤度) に与えて計算結果を得て、
  • updates で指定された更新式によって共有変数を更新する

という一連の処理を行う関数を作っている。うーん、これは訓練されていないと ちゃっと読めないな、、、。

そして、この時点においても train_model では ロジスティック回帰の学習を 関数として定義しただけで、実計算はまだ行われていない。実際の学習 ( 続く while ループ中 ) では この定義に対して index だけを渡してやれば、 必要な値が計算 / 関連する値が自動的に更新されていく。

全部まとめて

以降は特になし。MNISTデータをローカルに保存した後、まとめ にあるスクリプトをコピペして実行すればよい。

11/30追記: つづきはこちら。

深層学習 (機械学習プロフェッショナルシリーズ)

深層学習 (機械学習プロフェッショナルシリーズ)

ロジスティック回帰 (勾配降下法 / 確率的勾配降下法) を可視化する

いつの間にかシリーズ化して、今回はロジスティック回帰をやる。自分は行列計算ができないクラスタ所属なので、入力が3次元以上 / 出力が多クラスになるとちょっときつい。教科書を読んでいるときはなんかわかった感じになるんだが、式とか字面を追ってるだけだからな、、、やっぱり自分で手を動かさないとダメだ。

また、ちょっとした事情により今回は Python でやりたい。Python のわかりやすい実装ないんかな?と探していたら 以下の ipyton notebook を見つけた。

http://nbviewer.ipython.org/gist/mitmul/9283713

こちらのリンク先に2クラス/多クラスのロジスティック回帰 (確率的勾配降下法) のサンプルがある。ありがたいことです。理論的な説明も書いてあるので ロジスティック回帰って何?という方は上を読んでください (放り投げ)。

この記事ではロジスティック回帰で使われる勾配法について書きたい。

補足 この記事ではデータを pandas で扱う / matplotlib でアニメーションさせる都合上、元のプログラムとは処理/変数名などだいぶ変わっているため見比べる場合は注意。

補足 実際の分析で使いたい場合は scikit-learn の SGDClassifier

サンプルデータのロードと準備

iris で、"Species" を目的変数にする。

補足 rpy2 を設定している方は rpy2から、そうでない方は こちら から .csv でダウンロードして読み込み (もしくは read_csv のファイルパスとして直接 URL 指定しても読める)。

import numpy as np
import pandas as pd

import matplotlib.pyplot as plt
import matplotlib.animation as animation

# iris の読み込みはどちらかで

# rpy2 経由で R から iris をロード
# import pandas.rpy.common as com
# iris = com.load_data('iris')

# csv から読み込み
# http://aima.cs.berkeley.edu/data/iris.csv
names = ['Sepal.Length', 'Sepal.Width', 'Petal.Length', 'Petal.Width', 'Species']
iris = pd.read_csv('iris.csv', header=None, names=names)

np.random.seed(1)
# 描画領域のサイズ
FIGSIZE = (5, 3.5)

ロジスティック回帰 (2クラス)

まず iris データの一部を切り出して、説明変数 2次元 / 目的変数 2クラスのデータにする。

# 2 クラスにするため、setosa, versicolor のデータのみ抽出
data = iris[:100]

# 説明変数は 2つ = 2 次元
columns = ['Petal.Width', 'Petal.Length']

x = data[columns]        # データ (説明変数)
y = data['Species']      # ラベル (目的変数)

ロジスティック回帰での目的変数は 0, 1 のみからなるベクトルである必要があるので、以下のようにして変換。論理演算で boolpd.Series を作り、int 型に変換すればよい。

# ラベルを0, 1の列に変換
y = (y == 'setosa').astype(int)
y
# 1     1
# 2     1
# ...
# 99     0
# 100    0
# Name: Species, Length: 100, dtype: int64

プロットしてみる。線形分離できるのでサンプルとしては OK だろう。

def plot_x_by_y(x, y, colors, ax=None):
    if ax is None:
        # 描画領域を作成
        fig = plt.figure(figsize=FIGSIZE)

        # 描画領域に Axes を追加、マージン調整
        ax = fig.add_subplot(1, 1, 1)
        fig.subplots_adjust(bottom=0.15)

    x1 = x.columns[0]
    x2 = x.columns[1]
    for (species, group), c in zip(x.groupby(y), colors):
        ax = group.plot(kind='scatter', x=x1, y=x2,
                        color=c, ax=ax, figsize=FIGSIZE)
    return ax

plot_x_by_y(x, y, colors=['red', 'blue'])
plt.show()

f:id:sinhrks:20141124202817p:plain

最適化法

ロジスティック回帰における予測値 { \hat{y} } は、{ \hat{y} = \sigma (x w + b) } で計算される。それぞれの意味は、

  • { \hat{y} } : 真のラベル { y } の予測値ベクトル。次元は (入力データ数, 1)
  • { \sigma } : シグモイド関数。計算結果を (0, 1) 区間に写像する
  • { x } : 入力データ。次元は (入力データ数, 説明変数の数)
  • { w } : 係数ベクトル。次元は(クラス数 = 2)
  • { b } : バイアス (スカラー)

ロジスティック回帰では、予測値 { \hat{y} } と真のラベル { y } の差から計算される損失関数を最小化する回帰直線を求めることになる。この回帰直線を求める方法として勾配法が知られている。勾配法とは、損失関数から 勾配 (Gradient) を求めることによって損失関数をより小さくする { w }, { b }逐次更新していく手法 (詳細は上記リンク)。

ここでは以下 3 種類の勾配法を試す。上のリンクでやっているのは 3つ目の ミニバッチ確率的勾配降下法 / MSGD になる。

※ MSGD は SGD と特に区別されない場合も多い。

まず、共通で使う関数を定義する。

def p_y_given_x(x, w, b):
    # x, w, b から y の予測値 (yhat) を計算
    def sigmoid(a):
        return 1.0 / (1.0 + np.exp(-a))
    return sigmoid(np.dot(x, w) + b)

def grad(x, y, w, b):
    # 現予測値から勾配を計算
    error = y - p_y_given_x(x, w, b)
    w_grad = -np.mean(x.T * error, axis=1)
    b_grad = -np.mean(error)
    return w_grad, b_grad

勾配降下法 (Gradient Descent)

勾配法の中ではもっともシンプルな考え方で、全データ (x, y) を使って勾配を求める。実装は以下のようになる。

引数の意味は、

  • x, y, w, b : 上記数式に対応
  • eta : 学習率 (勾配をどの程度 w, b に反映させるか)
  • num : イテレーション回数

補足 num の既定値はそれぞれの手法がある程度収束する値にしている。ちゃんとやる場合は収束判定すべき。

def gd(x, y, w, b, eta=0.1, num=100):
    for i in range(1, num):
        # 入力をまとめて処理
        w_grad, b_grad = grad(x, y, w, b)
        w -= eta * w_grad
        b -= eta * b_grad
        e = np.mean(np.abs(y - p_y_given_x(x, w, b)))
        yield i, w, b, e

実行してみる。w, b の初期値は全て 0 のベクトル / スカラーにした。

# w, b の初期値を作成
w, b = np.zeros(2), 0
gen = gd(x, y, w, b)

# gen はジェネレータ
gen
# <generator object gd at 0x11108e5f0>

# 以降、gen.next() を呼ぶたびに 一回 勾配降下法を実行して更新した結果を返す。
# タプルの中身は (イテレーション回数, w, b, 誤差)
gen.next()
# (1, array([-0.027  , -0.06995]), 0.0, 0.47227246182037463)

gen.next()
# (2, array([-0.04810306, -0.12007078]), 0.0054926687253766763, 0.45337584157628485)

gen.next()
# (3, array([-0.06522081, -0.15679859]), 0.014689105435764377, 0.44035208019270661)

gen.next()
# (4, array([-0.07963026, -0.18443466]), 0.026392079742058178, 0.43101983106700503)

# ...

実行のたびに w, b が更新されるとともに、誤差の値が小さくなっていることがわかる。ここでの誤差 は 誤判別率 ではなく、真のクラスと クラス所属確率の差。

確率的勾配降下法 (Stochastic Gradient Descent / SGD)

勾配降下法では勾配の計算を全データを行列とみて行うため、データ量が増えると計算が厳しい。確率的勾配降下法では、入力をランダムに (確率的に) 並べ替えて、データ一行ずつから勾配を求めて係数/バイアスを更新する。一行ずつの処理になるため計算量の問題は解消する。実装は以下のようになる。

def sgd(x, y, w, b, eta=0.1, num=4):
    for i in range(1, num):
        for index in range(x.shape[0]):
            # 一行ずつ処理
            _x = x.iloc[[index], ]
            _y = y.iloc[[index], ]
            w_grad, b_grad = grad(_x, _y, w, b)
            w -= eta * w_grad
            b -= eta * b_grad
            e = np.mean(np.abs(y - p_y_given_x(x, w, b)))
            yield i, w, b, e

確率的に処理するため、データをランダムに並べ替える。ランダムに並べ替えられた結果の表示から、x, yindex が一致している = 対応関係が維持されていることを確認。

# index と同じ長さの配列を作成し、ランダムにシャッフル
indexer = np.arange(x.shape[0])
np.random.shuffle(indexer)

# x, y を シャッフルされた index の順序に並び替え
x = x.iloc[indexer, ]
y = y.iloc[indexer, ]

x.head()
#     Petal.Width  Petal.Length
# 81          1.1           3.8
# 85          1.5           4.5
# 34          0.2           1.4
# 82          1.0           3.7
# 94          1.0           3.3

y.head()
# 81    0
# 85    0
# 34    1
# 82    0
# 94    0
# Name: Species, dtype: int64

実行してみよう。

# w, b の初期値を作成
w, b = np.zeros(2), 0
gen = sgd(x, y, w, b)

# 以降、gen.next() を呼ぶたびに 一行分のデータで更新した結果を返す。
# タプルの中身は (イテレーション回数, w, b, 誤差)
gen.next()
# (1, array([-0.055, -0.19 ]), -0.050000000000000003, 0.43367318357380596)

gen.next()
# (1, array([-0.09571092, -0.31213277]), -0.07714061571720722, 0.4071586323043157)

gen.next()
# (1, array([-0.08310602, -0.22389845]), -0.014116100082250033, 0.42197958122210588)

gen.next によって 一行処理した結果が返ってくる。一行あたりの処理をみると損失は増加していることもある (が徐々に減っていく)。

補足 イテレーション回数は全データに対する回数のため、データが一回りするまで増えない。

ミニバッチ確率的勾配降下法 (Minibatch SGD / MSGD)

確率的勾配降下法について、一行ずつではなく 計算できる程度の固まり (Minibatch) 単位で処理していく方法。実装は以下のようになる。

※ 事前にデータをランダムに並べ替えるのは確率的勾配降下法と同様。

引数はこれまでから一つ増えて、

  • batch_size : Minibatch として切り出すデータ数 (行数)
def msgd(x, y, w, b, eta=0.1, num=25, batch_size=10):
    for i in range(1, num):
        for index in range(0, x.shape[0], batch_size):
            # index 番目のバッチを切り出して処理
            _x = x[index:index + batch_size]
            _y = y[index:index + batch_size]
            w_grad, b_grad = grad(_x, _y, w, b)
            w -= eta * w_grad
            b -= eta * b_grad
            e = np.mean(np.abs(y - p_y_given_x(x, w, b)))
            yield i, w, b, e

実行。

# w, b の初期値を作成
w, b = np.zeros(2), 0
gen = msgd(x, y, w, b)

# 読み方/補足は 確率的勾配降下法と同じ。
gen.next()
# (1, array([ 0.011 ,  0.0725]), 0.050000000000000003, 0.52633544830099133)

gen.next()
# (1, array([ 0.02251519,  0.13792904]), 0.096115503575706834, 0.54788728897754557)

可視化

以上 3 つの処理を見比べてわかるとおり、差異は勾配を計算するのに利用するデータの切り出し処理のみ。それぞれどんな動きをしているのか可視化してみる。

def plot_logreg(x, y, fitter, title):
    # 描画領域を作成
    fig = plt.figure(figsize=FIGSIZE)

    # 描画領域に Axes を追加、マージン調整
    ax = fig.add_subplot(1, 1, 1)
    fig.subplots_adjust(bottom=0.15)

    # 描画オブジェクト保存用
    objs = []

    # 回帰直線描画用の x 座標
    bx = np.arange(x.iloc[:, 0].min(), x.iloc[:, 0].max(), 0.1)

    # w, b の初期値を作成
    w, b = np.zeros(2), 0
    # 勾配法の関数からジェネレータを生成
    gen = fitter(x, y, w, b)
    # ジェネレータを実行し、勾配法 1ステップごとの結果を得る
    for i, w, b, e in gen:
        # 回帰直線の y 座標を計算
        by = -b/w[1] - w[0]/w[1]*bx
        # 回帰直線を生成
        l = ax.plot(bx, by, color='gray', linestyle='dashed')
        # 描画するテキストを生成
        wt = """Iteration = {0} times
w = [{1[0]:.2f}, {1[1]:.2f}]
b = {2:.2f}
error = {3:.3}""".format(i, w, b, e)
        # axes 上の相対座標 (0.1, 0.9) に text の上部を合わせて描画
        t = ax.text(0.1, 0.9, wt, va='top', transform=ax.transAxes)
        # 描画した line, text をアニメーション用の配列に入れる
        objs.append(tuple(l) + (t, ))

    # データ, 表題を描画
    ax = plot_x_by_y(x, y, colors=['red', 'blue'], ax=ax)
    ax.set_title(title)
    # アニメーション開始
    ani = animation.ArtistAnimation(fig, objs, interval=1, repeat=False)
    plt.show()

plot_logreg(x, y, gd, title='Gradient descent')
plot_logreg(x, y, sgd, title='Stochastic gradient descent')
plot_logreg(x, y, msgd, title='Minibatch SGD')

勾配降下法 (Gradient Descent)

勾配降下法では 全データから勾配をもとめるので、イテレーション時に損失が増えることはない。ただしデータ数が多くなると使えない。

f:id:sinhrks:20141124202840g:plain

確率的勾配降下法 (Stochastic Gradient Descent / SGD)

確率的勾配降下法では 一行ずつで勾配をもとめるので、処理される行によっては損失が増えることもある。が、全体を通じて徐々に損失は減っていく。

f:id:sinhrks:20141124202930g:plain

ミニバッチ確率的勾配降下法 (Minibatch SGD / MSGD)

ミニバッチ確率的勾配降下法では ある程度の固まりから勾配をもとめるので、確率的勾配降下法と比べると 更新処理ごとの損失のばらつきは小さい。

f:id:sinhrks:20141124203223g:plain

多項ロジスティック回帰 (多クラス)

続けて、目的変数のクラス数が 2より大きい場合 = 多項ロジスティック回帰を行う。データは同じく iris を使う。説明変数は 2次元のままとする。

data = iris

x = data[columns]
y = data['Species']

線形分離できないクラスもあるが、まあやってみよう。

f:id:sinhrks:20141124203533p:plain

多項ロジスティック回帰の場合は { y } の次元が変わる。伴って、予測値を求める式は { \hat{y} = \pi (x w + b) } となり関連する変数の次元も以下のように変わる。

  • { \hat{y} } : 真のラベル { y } の予測値ベクトル。次元は (入力データ数, クラス数)
  • { \pi } : ソフトマックス関数。{ x w + b } の計算結果が各クラスに所属する確率を計算する。詳細は上k(略)
  • { x } : 入力データ。次元は (入力データ数, 説明変数の数)
  • { w } : 係数行列。次元は (クラス数, 説明変数の数)
  • { b } : バイアスベクトル。次元は (クラス数, 1)

上記のとおり y を多項ロジスティック回帰の形式に変換する。

# ラベルを カテゴリに対応した 0, 1 からなる3列に変換
y = pd.DataFrame({'setosa': (y == 'setosa').astype(int),
                  'versicolor': (y == 'versicolor').astype(int),
                  'virginica': (y == 'virginica').astype(int)})

y.head()
#    setosa  versicolor  virginica
# 1       1           0          0
# 2       1           0          0
# 3       1           0          0
# 4       1           0          0
# 5       1           0          0

続けて、多項ロジスティック回帰で使う関数を定義する。

def p_y_given_x(x, w, b):

    def softmax(x):
        return (np.exp(x).T / np.sum(np.exp(x), axis=1)).T

    return softmax(np.dot(x, w.T) + b)

def grad(x, y, w, b):
    error = p_y_given_x(x, w, b) - y
    w_grad = np.zeros_like(w)
    b_grad = np.zeros_like(b)

    for j in range(w.shape[0]):
        w_grad[j] = (error.iloc[:, j] * x.T).mean(axis=1)
        b_grad[j] = error.iloc[:, j].mean()

    return w_grad, b_grad, np.mean(np.abs(error), axis=0)

ミニバッチ確率的勾配降下法 (Minibatch SGD / MSGD)

勾配法については考え方は一緒なので、MSGD のみ。ほかの手法の場合は ループ箇所とデータ切り出し処理を "2クラスのロジスティック回帰" の場合と同様に書き換えればよい。

def msgd2(x, y, w, b, eta=0.1, num=800, batch_size=10):
    for i in range(num):
        for index in range(0, x.shape[0], batch_size):
            # index 番目のバッチを切り出して処理
            _x = x[index:index + batch_size]
            _y = y[index:index + batch_size]
            w_grad, b_grad, error = grad(_x, _y, w, b)
            w -= eta * w_grad
            b -= eta * b_grad
            e = np.sum(np.mean(np.abs(y - p_y_given_x(x, w, b))))
            yield i, w, b, e

可視化

可視化部分も似たようなコードなので、全体は gist で。各クラスを分割する位置に少しずつ回帰直線が寄っていく。

f:id:sinhrks:20141128223434g:plain

補足 行列 w の出力がずれていて済まぬ、、済まぬ、、。 tex 記法がうまく動かなかったんだ、、。

まとめ

ロジスティック回帰、とくに勾配法についてまとめた。目的変数のクラス数によってデータの次元 / 予測値算出のロジックは若干異なる。が、おおまかな流れは一緒。

プログラム

これまでのシリーズ

11/28追記/修正: 不要な変数定義とその説明を削除。一部グラフのタイトルにスペルミスがあったため修正。

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 lifelines で生存分析

サイトのアクセス履歴をみていたら "Python", "生存分析", "Kaplan-Meier" なんかがちらほらあったので、知っている方法を書いてみる。

生存分析とは

いくつかのサンプルについて、何らかのイベントが起きるまでの時間とイベント発生率との関係をモデル化するもの。よく使われるのは

  • 何かの病気になってからの死亡率
  • 部品の故障率

など。

この説明がわかりやすい。

Rと生存時間分析(1)

Python でやる方法

どちらか。lifelines のほうが手間はすくない。

  1. lifelines パッケージを使う
  2. rpy2 でデータを R に渡して survival パッケージ を使う

1. lifelines パッケージを使う

これを使えば Pure-Python で生存分析できる。使えるのは、以下のノンパラ、セミパラ系の手法。Weibull などのパラメトリック系はない (Scipy 使えばできる)。

  • Kaplan-Meier
  • Nelson-Aalen
  • Cox 比例ハザード

インストール

pip install lifelines

ドキュメント

バージョン古いようなのでアップデートされてないかも。

Lifelines — lifelines 0.8.0.1 documentation

やり方

データは pandas の DataFrame で扱うのがよい。ここでは、lifelinesにもサンプルデータとして入っている lung (肺ガン) データを使う。

lungデータ

https://github.com/CamDavidsonPilon/lifelines/blob/master/datasets/lung.csv

データの意味はこちら。

R: NCCTG Lung Cancer Data

ここでは、性別でグループ分けしたデータについて、イベントの発生時間 ("time" カラムの値) とそのときの状態 ("death" カラムの値, Trueなら死亡, Falseなら censored = 観測打ち切り) で Kaplan-Meierモデルを作る。

これは lifelines.KaplanMeierFitter クラスでできる。引数の詳細はドキュメント参照。

Introduction to using lifelines

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

# データの読み込み
df = pd.read_csv("lung.csv")

# 元データには、死亡なら2, censored なら1が入っている
# lifelines に渡すため、死亡なら True, censored なら False となるよう変換する
df['death'] = df['status'] == 2
df

#      Unnamed: 0  inst  time  status  age  sex  ph.ecog  ph.karno  pat.karno  \
# 0             1     3   306       2   74    1        1        90        100   
# 1             2     3   455       2   68    1        0        90         90   
# ..          ...   ...   ...     ...  ...  ...      ...       ...        ...   
# 226         227     6   174       1   66    1        1        90        100   
# 227         228    22   177       1   58    2        1        80         90   
# 
#      meal.cal  wt.loss  death  
# 0        1175      NaN   True  
# 1        1225       15   True  
# ..        ...      ...    ...  
# 226      1075        1  False  
# 227      1060        0  False  
# 
# [228 rows x 12 columns]

from lifelines import KaplanMeierFitter

# グラフを描画する Axes
ax = None

# 性別でグループ分け
for name, group in df.groupby('sex'):
    kmf = KaplanMeierFitter()
    kmf.fit(group['time'], event_observed=group['death'],
            label = 'sex=' + str(name))

    # 描画する Axes を指定。None を渡すとエラーになるので場合分け
    if ax is None:
        ax = kmf.plot()
    else:
        ax = kmf.plot(ax=ax)

# Kaplan-Meier曲線を描画
import matplotlib.pyplot as plt
plt.show()

Kaplan-Meier曲線 (横軸が経過時間、縦軸が生存率 = イベントの未発生率) となる。性別 = 2 (女性) のほうが 同じ経過時間時点での生存率が高い傾向がある。

f:id:sinhrks:20141101215357p:plain

2. rpy2 でデータを R に渡して survival パッケージ を使う

pandas と rpy2 との連携はこちらを参照。

やり方

やりたいことにもよるが、結果を Python に戻してからの処理がちょっとめんどい。

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

# rpy2 使って survival::lung データセットを呼び出す
# 手元のデータを使うなら不要
import rpy2.robjects as robjects
import pandas.rpy.common as com
robjects.packages.importr('survival')
lung = com.load_data('lung')

# rpy2 形式に変換し、 Rへ渡す
lungr = com.convert_to_r_dataframe(lung)
robjects.r.assign('lungr', lungr);

# R で生存分析実施
robjects.r('fitted <- survfit(Surv(time, status) ~ sex, data = lungr)');

# 結果を Python に戻し、各プロパティを numpy.arrayとして取得
fitted = robjects.r['fitted']
time = np.array(fitted.rx2('time'))
surv = np.array(fitted.rx2('surv'))
lower = np.array(fitted.rx2('lower'))
upper = np.array(fitted.rx2('upper'))

# strata のキーを順序どおりに取得するには一手間必要
robjects.r('strata_keys <- names(fitted$strata)')
strata_keys = com.convert_robj(robjects.r['strata_keys'])
strata = com.convert_robj(fitted.rx2('strata'))
strata = reduce(lambda x, y: x + y, [ [k] * strata[k] for k in strata_keys])

# DataFrame作成
result = pd.DataFrame({'time': time, 'surv': surv,
    'lower': lower, 'upper': upper, 'strata': strata})
result

#         lower strata      surv  time     upper
# 0    0.954230  sex=1  0.978261    11  1.000000
# 1    0.943423  sex=1  0.971014    12  0.999412
# ..        ...    ...       ...   ...       ...
# 204  0.001582  sex=2  0.011111   821  0.078022
# 205       NaN  sex=2  0.000000   965       NaN


# プロット
import matplotlib.pyplot as plt

# グラフを描画する Axes
ax = None

# 生存曲線と 信頼区間を別々に描画する必要がある
# strata ごとに同じ色を使うために グループ名 : 色の辞書を作成
colors = {'sex=1': 'green', 'sex=2': 'blue'}
for name, group in result.groupby('strata'):
    # pandas は index を x軸として plot するので、
    # time の値を indexに設定
    group = group.set_index('time')
    color = colors[name]

    # pandas.Series.plot で生存曲線自体を描画
    ax = group['surv'].plot(ax=ax, color=colors[name])

    # matplotlib.Axes.fill_between で信頼区間を描画
    ax.fill_between(group.index, group['lower'], group['upper'],
        color=colors[name], alpha=0.3)
plt.show()

f:id:sinhrks:20141101215936p:plain