StatsFragments

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

pandas 日時まわりのリサンプリング/オフセット処理

こちらの続き。

今回のサンプルデータには自分の歩数のデータを使いたい。インスパイヤ元は以下のサイトだ。

d.hatena.ne.jp

データの読み込み

歩数データは iPhone の Health アプリから Export できる。形式は XML なので、そのままでは pandas で読み込めない。一度 XML から必要な属性を辞書のリストとして取り出した後、pandas に読み込ませる。

import pandas as pd
from xml.etree import ElementTree
tree = ElementTree.parse('export.xml')
root = tree.getroot()
# 属性の辞書のリストを作る
data = [e.attrib for e in root.findall('.//Record')]
data[0]
# {'endDate': '20150511220900+0900',
#  'recordCount': '104',
#  'source': u'xxx',
#  'startDate': '20150511210900+0900',
#  'type': 'HKQuantityTypeIdentifierStepCount',
#  'unit': 'count',
#  'value': '531'}

df = pd.DataFrame(data)
# 必要な列のみにフィルタ
df = df[['startDate', 'type', 'unit', 'value']]
df.head()
#              startDate                               type   unit value
# 0  20150511210900+0900  HKQuantityTypeIdentifierStepCount  count   531
# 1  20150512000900+0900  HKQuantityTypeIdentifierStepCount  count     6
# 2  20150512060900+0900  HKQuantityTypeIdentifierStepCount  count   629
# 3  20150512070900+0900  HKQuantityTypeIdentifierStepCount  count   312
# 4  20150512080900+0900  HKQuantityTypeIdentifierStepCount  count  1483

Series.value_counts() を使うと、ある列で出現する要素の個数を集計できる。"type" 列を集計すると、Export したデータには歩数以外のデータも含まれていることがわかる。歩数以外は不要なので削除する。

df['type'].value_counts()
# HKQuantityTypeIdentifierStepCount                 375
# HKQuantityTypeIdentifierDistanceWalkingRunning    374
# HKQuantityTypeIdentifierFlightsClimbed            216
# dtype: int64

# 歩数のみにフィルタ
df = df[df['type'] == 'HKQuantityTypeIdentifierStepCount']

また、XML で読み込んだデータはすべて文字列型になっているため、適宜型変換を行う。

# 歩数データは数値に
df['value'] = df['value'].astype(float)

# 日付は index として設定し、日付型 (DatetimeIndex) に変換
df = df.set_index('startDate')

# この時点で index は文字列型になっている
df.index 
# Index([u'20150511210900+0900', u'20150512000900+0900', u'20150512060900+0900',
#        u'20150512070900+0900', u'20150512080900+0900', u'20150512090900+0900',
#        ...
#        u'20150518165000+0900', u'20150518165900+0900', u'20150518171600+0900',
#        u'20150518181200+0900'],
#       dtype='object', name=u'startDate', length=965)

# to_datetime で日時型に変換 / タイムゾーンを表すオフセットを適宜設定
pd.to_datetime(df.index, utc=True).tz_convert('Asia/Tokyo')
# DatetimeIndex(['2015-05-11 21:09:00+09:00', '2015-05-12 00:09:00+09:00',
#                '2015-05-12 06:09:00+09:00', '2015-05-12 07:09:00+09:00',
#                ...
#                '2015-05-18 16:50:00+09:00', '2015-05-18 16:59:00+09:00',
#                '2015-05-18 17:16:00+09:00', '2015-05-18 18:12:00+09:00'],
#               dtype='datetime64[ns]', length=965, freq=None, tz='Asia/Tokyo')

# index を上書き
df.index = pd.to_datetime(df.index, utc=True).tz_convert('Asia/Tokyo')

# タイムゾーンは使わないので削除
df.index = df.index.tz_localize(None)

# 日付の昇順にソート
df = df.sort_index()

df.head()
#                                                   type   unit     value
# 2014-11-25 21:09:00  HKQuantityTypeIdentifierStepCount  count   1396.00
# 2014-11-26 21:09:00  HKQuantityTypeIdentifierStepCount  count   7020.37
# 2014-11-27 21:09:00  HKQuantityTypeIdentifierStepCount  count   6413.63
# 2014-11-28 21:09:00  HKQuantityTypeIdentifierStepCount  count   8396.00
# 2014-11-29 21:09:00  HKQuantityTypeIdentifierStepCount  count  12411.00

今の機種に買い替えたのが昨年11末なので、買い替え以降すべてのデータが入っているようだ。

部分文字列によるスライシング

pandas での一般的なデータ選択については以下の記事にまとめた。

加えて、データが 日付型の Index ( DatetimeIndex ) を持つとき、日付-like な文字列で __getitem__ すると、その期間にあてはまるデータを抽出してくれる。例えば 2015-05-17 日分のデータを抽出したければ以下のようにする。この方法を使うと 好きな期間のデータを簡単に確認することができる。詳細は公式ドキュメントを参照。

df['2015-05-17']
#                                                   type   unit     value
# 2015-05-17 08:09:00  HKQuantityTypeIdentifierStepCount  count   18.0000
# 2015-05-17 11:09:00  HKQuantityTypeIdentifierStepCount  count  180.0000
# 2015-05-17 12:09:00  HKQuantityTypeIdentifierStepCount  count  934.0000
# 2015-05-17 13:09:00  HKQuantityTypeIdentifierStepCount  count  469.0000
# ...                                                ...    ...       ...
# 2015-05-17 21:16:00  HKQuantityTypeIdentifierStepCount  count   88.5087
# 2015-05-17 21:17:00  HKQuantityTypeIdentifierStepCount  count   23.1214
# 2015-05-17 21:18:00  HKQuantityTypeIdentifierStepCount  count   99.3283
# 2015-05-17 21:19:00  HKQuantityTypeIdentifierStepCount  count   41.6284
# 
# [14 rows x 3 columns]

リサンプリング

データは日時で処理したいので、まずは 1日ごとの合計を算出したい。こういうときは DataFrame.resample。リサンプリングする期間や集約関数は引数として指定できる。

s = df.resample('D', how='sum')
#                    value
# 2014-11-25   1396.000000
# 2014-11-26   7020.370000
# 2014-11-27   6413.630000
# 2014-11-28   8396.000000
# ...                  ...
# 2015-05-15   7561.998000
# 2015-05-16  10615.000000
# 2015-05-17   9208.000000
# 2015-05-18   8085.994898
# 
# [175 rows x 1 columns]

日次に集約した結果をプロットしてみる。

s.plot()

f:id:sinhrks:20150518222638p:plain

あまり意識はしていないのだが そこそこ歩いているようだ。5月初旬はちょっと出かけていたため 歩数に異常値が出ている。

日付の標準化

日時データの適当な期間での集約は DataFrame.resample でできた。が、時には日時の補正のみを行い、集約はしたくない場合がある。

DatetimeIndex を日付ごとにまとめるのに一番簡単なのは .normalize

pd.Timestamp('2015-05-01 23:59').normalize()
# Timestamp('2015-05-01 00:00:00')
pd.Timestamp('2015-05-02 00:00').normalize()
# Timestamp('2015-05-02 00:00:00')

他の日時関連のメソッドと同じく、Timestamp, DatetimeIndex 両方で使える。

df.tail().index
# DatetimeIndex(['2015-05-18 18:55:00', '2015-05-18 18:56:00',
#                '2015-05-18 18:57:00', '2015-05-18 18:58:00',
#                '2015-05-18 18:59:00'],
#               dtype='datetime64[ns]', freq=None, tz=None)

df.tail().index.normalize()
# DatetimeIndex(['2015-05-18', '2015-05-18',
#                '2015-05-18', '2015-05-18',
#                '2015-05-18'],
#               dtype='datetime64[ns]', freq=None, tz=None)

当然、集約すれば 日付での .resample と同じ結果になる。

s = df.groupby(df.index.normalize())[['value']].sum()
s 
# 略

オフセット

また、より柔軟に日時補正を行うためにオフセットが提供されている ( 前回記事 )。オフセットを利用してデータを補正/集約する例を書きたい。

オフセットを使わずに .resample で月次平均をとると以下のような結果になる。

s.resample('M', how='mean')
#                    value
# 2014-11-30   7012.500000
# 2014-12-31   8435.741935
# 2015-01-31   9134.032258
# 2015-02-28   9323.821429
# 2015-03-31   9326.356129
# 2015-04-30   9938.533333
# 2015-05-31  10973.055439

オフセットを使って同じ処理をするには、日時を月末に補正するオフセット MonthEnd を使う。オフセットの一覧は 公式ドキュメント を参照。

m = offsets.MonthEnd()
m
# <MonthEnd>

オフセットにはいくつか共通のメソッドがあるため、順に記載する。

まず、ある日付が 当該のオフセット上に存在するか調べるには .onOffsetMonthEnd.onOffset の場合は、引数が月末の日付であるとき True になる。

m.onOffset(pd.Timestamp('2015-04-29'))
# False
m.onOffset(pd.Timestamp('2015-04-30'))
# True
m.onOffset(pd.Timestamp('2015-05-01'))
# False

オフセットを加減算することにより、日時を次の/前のオフセットへと移動できる。また、オフセットの加算と同一の処理として .apply がある。

# 日付を常に 次のオフセットへ移動させる。
pd.Timestamp('2015-04-29') + m
# Timestamp('2015-04-30 00:00:00')
pd.Timestamp('2015-04-30') + m
# Timestamp('2015-05-31 00:00:00')
pd.Timestamp('2015-05-01') + m
# Timestamp('2015-05-31 00:00:00')

# 日付を常に 前のオフセットへ移動させる。
pd.Timestamp('2015-04-29') - m
# Timestamp('2015-03-31 00:00:00')
pd.Timestamp('2015-04-30') - m
# Timestamp('2015-03-31 00:00:00')
pd.Timestamp('2015-05-01') - m
# Timestamp('2015-04-30 00:00:00')

# 日付を常に 次のオフセットへ移動させる。
m.apply(pd.Timestamp('2015-04-29'))
# Timestamp('2015-04-30 00:00:00')
m.apply(pd.Timestamp('2015-04-30'))
# Timestamp('2015-05-31 00:00:00')
m.apply(pd.Timestamp('2015-05-01'))
# Timestamp('2015-05-31 00:00:00')

オフセットに乗っている日時は動かしたくなければ .rollforward / .rollback というメソッドを使う。

# 日付がオフセットに乗っていない場合 次のオフセットへ移動させる。
m.rollforward(pd.Timestamp('2015-04-29'))
# Timestamp('2015-04-30 00:00:00')
m.rollforward(pd.Timestamp('2015-04-30'))
# Timestamp('2015-04-30 00:00:00')
m.rollforward(pd.Timestamp('2015-05-01'))
# Timestamp('2015-05-31 00:00:00')

# 日付がオフセットに乗っていない場合 前のオフセットへ移動させる。
m.rollback(pd.Timestamp('2015-04-29'))
# Timestamp('2015-03-31 00:00:00')
m.rollback(pd.Timestamp('2015-04-30'))
# Timestamp('2015-04-30 00:00:00')
m.rollback(pd.Timestamp('2015-05-01'))
# Timestamp('2015-04-30 00:00:00')

よって、月次平均の算出をオフセットのメソッドを使って行う場合は以下のようになる。

# 例示のため一部データをスライス
s['2014-11']
#                value
# 2014-11-25   1396.00
# 2014-11-26   7020.37
# 2014-11-27   6413.63
# 2014-11-28   8396.00
# 2014-11-29  12411.00
# 2014-11-30   6438.00

# 日時を月末に補正
s['2014-11'].index.map(m.rollforward)
# array([Timestamp('2014-11-30 00:00:00'), Timestamp('2014-11-30 00:00:00'),
#        Timestamp('2014-11-30 00:00:00'), Timestamp('2014-11-30 00:00:00'),
#        Timestamp('2014-11-30 00:00:00'), Timestamp('2014-11-30 00:00:00')], dtype=object)

s.groupby(s.index.map(m.rollforward)).mean()
#                    value
# 2014-11-30   7012.500000
# 2014-12-31   8435.741935
# 2015-01-31   9134.032258
# 2015-02-28   9323.821429
# 2015-03-31   9326.356129
# 2015-04-30   9938.533333
# 2015-05-31  10973.055439

補足 オフセットは .resample の引数として渡すこともでき、同じ結果になる。

s.resample(m, how='mean')
# 略

オフセットを活用した集計

つづけて、カレンダー上の平日 / 休日での差異をみたい。アクセサ ( こちらの記事参照 ) を使って曜日で集計してから処理してもよいが、 BusinessDay オフセットを使えば簡単。BusinessDay.onOffset を使えば平日が True / 休日を False として集計できる。以下の結果をみると、休日の方が歩いている傾向があるようだ。

bday = offsets.BusinessDay()
s.groupby(bday.onOffset).mean()
#               value
# False  10860.216800
# True    8716.657583

BusinessDay オフセットは 土日のみを休日として扱うが、任意の祝日も含めて休日として扱いたい場合は CustomBusinessDay オフセットが使える。拙作のパッケージ で 日本の祝日のカレンダーを定義しているので、それを使って、

import japandas as jpd
calendar = jpd.JapaneseHolidayCalendar()
cday = pd.offsets.CDay(calendar=calendar)

s.groupby(cday.onOffset).mean()
#               value
# False  10799.033448
# True    8600.419640

これまでの内容を使って、月ごとに 平日 / 土日 / 祝日の歩数平均をクロス集計したい。クロス集計を行うには pd.pivot_table

avg = pd.pivot_table(s, index=s.index.map(m.rollforward),
                     columns=[s.index.map(bday.onOffset), s.index.map(cday.onOffset)],
                     values='value', aggfunc='mean')
avg
#                    False    True              
#                    False    False        True 
# 2014-11-30   9424.500000      NaN  5806.500000
# 2014-12-31  10382.275000  11704.0  7579.354545
# 2015-01-31   9534.193333  10162.5  8851.113000
# 2015-02-28  10943.125000   9446.0  8635.578947
# 2015-03-31  11148.877778      NaN  8580.779091
# 2015-04-30  11349.625000   7110.0  9535.666667
# 2015-05-31  12769.000000  11582.7  9572.544211

左から、

  • 土日: BusinessDay CustomBusinessDay ともに False
  • 祝日: BusinessDayTrue, CustomBusinessDayFalse
  • 平日: BusinessDay CustomBusinessDay ともに True

となる。ということで上の結果からも 5月の休日は結構歩いたなってことがわかった。

まとめ

日付を index として持つデータに対する リサンプリング/オフセット処理をまとめた。これらは以下のような使い分けをすればよいと思う。

  • 比較的単純な集約はリサンプリング
  • リサンプリングではできない より細かい補正やクロス集計をしたい場合はオフセット

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

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