Pandasのtime-aware rolling windowについてまとめてみた

#はじめに
こんにちは、(株)日立製作所 研究開発グループ サービスコンピューティング研究部のヤナです。
私は日々pandasと向き合っていますので、最近よく使うrolling windowについて紹介したいと思います。

pandasのrolling windowはスライディングウィンドウで計算を行いたい時に使うとても便利な手法です。
ただし、このrolling window手法には二つのwindowタイプがあるのが意外と知られていないのではないかと思います。
- fixed rolling window (固定的なwindowの長さ)
- time-aware rolling window (ある時点からのオフセットでwindowの長さが柔軟に変わる)

今回はtime-aware rolling windowを中心に、以下の時系列ダミーデータを使用して紹介します。


```python
display(tdf)
          series1 	
time 	
2019-11-25 	1.0 	
2019-11-26 	2.0 	
2019-11-27 	3.0 
2019-11-28 	4.0 	
2019-12-01 	7.0 	
2019-12-02 	8.0 	
2019-12-04 	10.0 	
2019-12-05 	9.0 	
2019-12-06 	8.0 	
2019-12-07 	7.0 	
2019-12-08 	6.0 	
2019-12-09 	5.0 	

```

#FixedとTime-aware Rolling Windowの違いの簡単な例

一先ず、二つのwindowタイプを比較してみましょう。
今回のデータはデイリーの時系列データで、よく見ると途中で抜けている日付があります。
Rolling windowでseries1の三日間分のwindowでsumをそれぞれ計算すると下記のようになります。

```python
sum_df=pd.DataFrame(index=tdf.index)
#sum with fixed rolling window of 3 (数値として入力）
sum_df['ser1_fixed']=tdf['series1'].rolling(window=3).sum()
#sum with offset rolling window of 3 days (時間情報がついているストリングとして入力）
sum_df['ser1_offset']=tdf['series1'].rolling(window='3D').sum()

 	  original 	ser1_fixed 	ser1_offset
time 			
2019-11-25 	1.0 	NaN 	1.0
2019-11-26 	2.0 	NaN 	3.0
2019-11-27 	3.0 	6.0 	6.0
2019-11-28 	4.0 	9.0 	9.0
2019-12-01 	7.0 	14.0 	7.0
2019-12-02 	8.0 	19.0 	15.0
2019-12-04 	10.0 	25.0 	18.0
2019-12-05 	9.0 	27.0 	19.0
2019-12-06 	8.0 	27.0 	27.0
2019-12-07 	7.0 	24.0 	24.0
2019-12-08 	6.0 	21.0 	21.0
2019-12-09 	5.0 	18.0 	18.0
```

`window`パラメータを指定することで、それぞれfixed(`window=3`)とtime-aware (`window='3D'`) rolling windowでの結果を得られます。ここで、`window='3D'`のDは時間情報のdaysを意味します。基本的には`pandas.Timedelta`同様のオプション `{‘ns’, ‘us’, ‘ms’, ‘s’, ‘m’, ‘h’, ‘D’}`が使えます。

二つの結果を比較するとすぐに気づく大きな違いが２つあります。
1. fixed rollingの場合は最初にNaNが二つ入る
2. 複数のタイムステップで値が違う

以下は、time-aware rolling windowのビヘイビアについて考察を行います。

#Time-aware Rolling Windowの長さの柔軟性

上記の結果で観測できるのが少しの入力の違いでデフォルトビヘイビアが大きく変わることです。
- fixed windowの場合は3という固定的なWindowの長さに対してsumを計算しますので、最初の２つのタイムステップでは結果が出力されません。また、時間情報を無視して結果を計算します。
- time-aware rolling windowの場合はwindowの長さを時間情報を使ったオフセットで決めますので、かならず3の長さでsumが計算されるわけではないです。日付が抜けている時間帯ではwindowの長さは短くなって、1個や2個の情報を使ってsumを計算します。


すなわち、time-aware rolling windowでは時間情報を使ったオフセットでwindowの長さを柔軟に決めます。そのため、windowの最低長さを決める必要がありますが、デフォルト設定では`min_periods=1`になっているため、全行に結果が出力されます。もし、windowの最低長さをある値以上にしたいのであれば、デフォルト設定を変える必要があります。例えば、必ずwindowの長さを3にしたい場合は以下のようにコードを書けばよいです。


```python
#changed minimum periods in window to 3
tdf.rolling(window='3D',min_periods=3).sum()


	      series1
time 	
2019-11-25 	NaN
2019-11-26 	NaN
2019-11-27 	6.0
2019-11-28 	9.0
2019-12-01 	NaN
2019-12-02 	NaN
2019-12-04 	NaN
2019-12-05 	NaN
2019-12-06 	27.0
2019-12-07 	24.0
2019-12-08 	21.0
2019-12-09 	18.0

```


`min_periods=3`に設定すると半分のタイムステップでは値がNaNに変わっています。
三日間の全日付が揃った時だけ、sumを計算できるため、途中の日付が抜けているところでは結果がNaNになってしまいます。


#Time-Aware Rolling Window専用のパラメータclosed
closedというパラメータはfixed windowでは使えない、time-aware rolling window専用のパラメータになっています。
Time windowのどのエンドポイントを入れるかを設定できます。
Referenceによると以下の四つのオプションがあります。

| オプション | 効果                          |
|:------------:|:-----------------------------:|
|        right|        右のエンドポイントを含む  |
|         left|         左のエンドポイントを含む |
|         both|       両方のエンドポイントを含む |
|      neither| どちらのエンドポイントも含まない  |

`closed='right'`はデフォルト設定です。
内容を確認するために、ダミーデータで計算してみて、ビヘイビアについて考察を行います。

```python
min_v=1
closed_df=pd.DataFrame(index=tdf.index)
closed_df['original']=tdf['series1']
closed_df['right']=tdf.rolling(window='3D',min_periods=min_v, closed='right').sum()['series1']
closed_df['left']=tdf.rolling(window='3D',min_periods=min_v, closed='left').sum()['series1']
closed_df['both']=tdf.rolling(window='3D',min_periods=min_v, closed='both').sum()['series1']
closed_df['neither']=tdf.rolling(window='3D',min_periods=min_v, closed='neither').sum()['series1']

 	     original 	right 	left 	both 	neither
time 					
2019-11-25 	1.0 	1.0 	NaN 	1.0 	NaN
2019-11-26 	2.0 	3.0 	1.0 	3.0 	1.0
2019-11-27 	3.0 	6.0 	3.0 	6.0 	3.0
2019-11-28 	4.0 	9.0 	6.0 	10.0 	5.0
2019-12-01 	7.0 	7.0 	4.0 	11.0 	NaN
2019-12-02 	8.0 	15.0 	7.0 	15.0 	7.0
2019-12-04 	10.0 	18.0 	15.0 	25.0 	8.0
2019-12-05 	9.0 	19.0 	18.0 	27.0 	10.0
2019-12-06 	8.0 	27.0 	19.0 	27.0 	19.0
2019-12-07 	7.0 	24.0 	27.0 	34.0 	17.0
2019-12-08 	6.0 	21.0 	24.0 	30.0 	15.0
2019-12-09 	5.0 	18.0 	21.0 	26.0 	13.0

```

上記の結果を図にします。
![closed_図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/543628/a7ea7e8a-ac6d-7757-ffc9-2b7c57c6e41d.png)

今回の例では三日間のtime windowを参考にしています。現時点tは右のエンドポイントで三日間のオフセットの先にある時点t-3は左のエンドポイントになります。これで`closed`パラメータでは現時点からみてどの時間帯を取り入れたいかのコントロールが可能になります。一方、fixed windowならwindowの柔軟性は低いので、自分で調整が可能で機能として導入されていません。

各`closed`オプションを一度を言葉で表してみると以下のようになります（time windowの長さは3と考えた場合の例）：
- rightでは現時点を含めて3つの時点を使用します。
- leftでは現時点を含まず、過去の3つの時点を使用します。
- bothでは実質指定したwindowの長さより1つ多くの時点を取り入れて、現時点を含めて4つの時点を使用します。
- neitherでは実質指定したwindowの長さより1つ少ない時点を取り入れて、現時点を含まず2つの過去の時点を使用します。


上記の例では特に`closed=neither`の5行目で効果がわかると思います。実際にsumの結果がNaNになっておりますが、その原因を探ってみると、現時点tは2019年12月1日のため、オフセット1と2（つまり11月29と30日）のsumを計算したいのですが、ちょうどその二つの日付は抜けているため計算ができなくて結果がNaNになってしまいます。

最後に、`min_periods=3`の場合の結果を計算してみます。日付が抜けているrolling windowの行でかならず結果がNaNになるはずです。

```python
min_v=3
closed_df=pd.DataFrame(index=tdf.index)
closed_df['original']=tdf['series1']
closed_df['right']=tdf.rolling(window='3D',min_periods=min_v, closed='right').sum()['series1']
closed_df['left']=tdf.rolling(window='3D',min_periods=min_v, closed='left').sum()['series1']
closed_df['both']=tdf.rolling(window='3D',min_periods=min_v, closed='both').sum()['series1']
closed_df['neither']=tdf.rolling(window='3D',min_periods=min_v, closed='neither').sum()['series1']

 	     original 	right 	left 	both 	neither
time 					
2019-11-25 	1.0 	NaN 	NaN 	NaN 	NaN
2019-11-26 	2.0 	NaN 	NaN 	NaN 	NaN
2019-11-27 	3.0 	6.0 	NaN 	6.0 	NaN
2019-11-28 	4.0 	9.0 	6.0 	10.0 	NaN
2019-12-01 	7.0 	NaN 	NaN 	NaN 	NaN
2019-12-02 	8.0 	NaN 	NaN 	NaN 	NaN
2019-12-04 	10.0 	NaN 	NaN 	25.0 	NaN
2019-12-05 	9.0 	NaN 	NaN 	27.0 	NaN
2019-12-06 	8.0 	27.0 	NaN 	27.0 	NaN
2019-12-07 	7.0 	24.0 	27.0 	34.0 	NaN
2019-12-08 	6.0 	21.0 	24.0 	30.0 	NaN
2019-12-09 	5.0 	18.0 	21.0 	26.0 	NaN
```

上記の結果でわかるように、NaNの数が`closed`で選んだオプションによって変わります。
- right, left, bothの場合は条件がそろうタイミングが違うため、NaNのタイミングが変わります。また、抜けている日付に対してsumは計算されないため、全体のNaN数も変わります。
- neitherの場合は実際の計算に使用されているrolling windowは指定したwindowの長さより一タイムステップ短いため、条件揃うことはなく、かならず全行がNaNになってしまいます。


#まとめ

以上、まとめますとtime-aware rolling windowの決め方の流れは以下のようになっていると考えられます。
- windowの長さは`window`パラメータで設定→今回の例では三日間（`window='3D'`）
- windowはどこの時間的な範囲を反映させたいかを`closed`パラメータで設定→デフォルトは`closed='right'`
- windowの柔軟性が高いため、windowの最低長さを`min_periods`パラメータで設定→デフォルトは`min_periods=1`

#参考文献

- [pandas.TimedeltaのReference](https://pandas.pydata.org/pandas-docs/version/0.23.4/generated/pandas.Timedelta.html)
- [pandas.DataFrame.rollingのReference](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.rolling.html)
- [Pandas Computation Tools#Time-aware rolling](https://pandas.pydata.org/pandas-docs/stable/user_guide/computation.html#time-aware-rolling)

#Version
pandas `0.25.3`