paint-brush
金融時系列における欠損データの扱い - 秘訣と落とし穴@hackerclrk2ky7l00003j6qwtumiz7c
12,024 測定値
12,024 測定値

金融時系列における欠損データの扱い - 秘訣と落とし穴

Vladimir Kirilin10m2024/04/03
Read on Terminal Reader

長すぎる; 読むには

私は金融時系列の欠損データを処理する方法に焦点を当てています。いくつかのサンプルデータを使用して、LOCF はドロップや補完に比べて通常は適切な方法であるが、欠点があること、つまりデータに望ましくない人工的なジャンプを作成する可能性があることを示します。ただし、補間などの代替手段には、特にライブ予測/予測のコンテキストでは独自の問題があります。
featured image - 金融時系列における欠損データの扱い - 秘訣と落とし穴
Vladimir Kirilin HackerNoon profile picture
0-item

あなたも私と同じような人であれば、データセット内の欠損データを少なくとも一度は扱ったことがあるでしょう。あるいは二度、あるいは何度も…


場合によっては、これらの厄介な NA を処理するには、それらをドロップする、つまり欠落データを含む行を削除するだけで済みます。ただし、これは必ずしも最適とは限りません。特に時系列データの場合、また金融データの場合はなおさらです。もちろん、この問題は十分に研究されているため、ドロップに代わる多くの方法があります。


そのうちのいくつか(以下にリスト)を検討し、長所と短所について説明します。


  1. ドロップ

  2. LOCF(最後の観測値の繰り越し)

  3. 平均値(または類似の)補完

  4. 補間


ネタバレ注意: 万能のアプローチはありません! LOCF は一般的に金融にとって良い選択ですが、欠点がないわけではありません。それを念頭に置いて、それらを紹介するために使用する方法とデータについて説明します。


注: 厳密に言えば、方法 2 ~ 4 はすべて、何らかの帰属の例です。


モデルデータ

まず、なぜ株価が下落することを気にするのか、いくつかの例から始めましょう。説明のために、株価がドリフトのないランダムウォークに従う(つまり、長期平均価格は一定であるはず)と仮定して、過度に単純化された日次株価データを生成しました。これは最も正確ではありませんが、それでも無害な仮定です。


 np.random.seed(10) # needed for reproducibility price_moves = 3*pd.Series(np.random.randn(100)) # generating random "steps" with 0 mean price_vec = 100 + price_moves.cumsum() # generating brownian motion price_vec.plot() 


不変価格シリーズ


まあ、その筋書きはかなり善意に満ちているように見えます。


ここで、日々の価格差の実験的平均を知りたいとします。

 price_vec.diff().mean() #sample mean >0.20030544816842052

生成系列とは異なり、明らかにゼロではありませんが、これは単なるサンプル ノイズです。ここまでは順調です。


データの削除

ここで、いくつかのデータ ポイントを削除して、このデータを少し変形してみましょう。

 price_vec_na_simple = price_vec.copy() price_vec_na_simple.iloc[90:95] = np.array([np.NaN, np.NaN, np.NaN, np.NaN, np.NaN]) # price_vec_na_simple.diff().mean() >0.1433356258183252


私たちはすぐにいくつかのことに気が付きました -

  1. diffベクトルには明らかにNAが含まれているにもかかわらず、平均値はどういうわけか非NAである。

  2. 平均値は以前得たものと異なります


さて、#1 は非常に簡単です - pd.meanデフォルトで NA を自動的に削除します。

しかし、2 番目についてはどうでしょうか? 何を計算しているのかを再考してみましょう。


少なくとも NA がない場合、平均価格差は単純に(price_vec[99]-price_vec[0])/99になるはずであることが簡単に示せます。実際、価格差を合計すると、すべての「中間」部分が相殺され、 (price_vec[1] - price_vec[0]) + (price_vec[2] - price_vec[1]) + ..となります。


ここで、欠損データが挿入された状態で、最初に差を取り、次にNAを削除すると、このキャンセルは解除されます。簡単な計算(price_vec[99] - price_vec[0] - price_vec[95] + price_vec[89])/93を計算していることがわかります。


これを示すために、 price_vec[95] - price_vec[94]price_vec[90] - price_vec[89]の2つの項が省略されていることに注目してください。これは、 (NA - any number) NAと評価されて削除されるためです。


これを検証してみましょう:

 (price_vec[99] - price_vec[0])/99 >0.20030544816842052 (price_vec[99] - price_vec[0] - price_vec[95] + price_vec[89])/93 >0.1433356258183252


さて、どうすれば問題を解決できるかが明確になりました。まずNAを削除し、次にdiff必要があります。

 price_vec_na_simple.dropna().diff().mean() >0.21095999328376203


平均値はほぼ本来あるべき値に戻りました。平均値に含まれる項の数が 99 から 94 に減ったため、わずかな差異が生じています。


わかりました。平均値だけを気にするのであれば、 dropnaを使用するだけで問題ないようです (正しく行う限り)。結局のところ、 0.20.21の差は、明らかにノイズ許容範囲内です。まあ、そうではありません。理由を見てみましょう。


LOCF

LOCFとは何ですか?

LOCF は Last Observation Carried Forward の略です。その背後にある考え方は非常に単純です。一定間隔でデータを記録する場合、それが定期的かどうかは関係ありません。特定の間隔の観測値が欠落している場合は、変数に何も変更がないと仮定し、最後の欠落していない値に置き換えます (例: [3, 5, NA, 8] → [3, 5, 5, 8])。欠落した観測値がある間隔をなぜ気にするのか、つまり「ドロップ」方式のように単に削除しないのかと疑問に思う人もいるかもしれません。その答えは、上では触れなかった「ドロップ」の固有の欠陥にあります。


一度に複数の量、特に通常はそれほど急激に変化しない量、たとえば温度と湿度の 1 時間ごとの記録などを記録するとします。10:00、11:00、12:00 の両方の値があり、13:00 には湿度しかないします。その「行」を削除するだけですか。つまり、13:00 の測定値がないことにしますか。変数が 2 つだけであれば問題ありません。ただし、潜在的に価値のある情報 (13:00 の湿度) を削除したばかりです。しかし、そのような発生が多数ある場合や一度に多くの変数がある場合は、削除すると実質的にデータがまったくなくなる可能性があります。


非常に魅力的な代替案は、12:00 から 13:00 までの温度に変化がないと仮定することです。結局のところ、誰かが 12:30 に来て「現在の温度はどれくらいですか」と尋ねた場合、私たちは当然 12:00 の測定値で応答するでしょう (もちろん、新しい測定値をすぐに取得できない場合)。13:00 の値にも同じロジックを適用しないのはなぜでしょうか。


LOCF を (金融において) 使用する理由は何ですか?

まず、以前のデータで新しく見つけたアプローチをテストしてみましょう。

 price_vec_na_simple.ffill().diff().mean() # ffill performs LOCF by default >0.20030544816842052


以前の値を正確に回復したようです。さらに、価格差データについてさらに調査したい場合は、5 つのエントリが 0 になっているにもかかわらず、各日のエントリがあるため、より「整然と」見えます (なぜでしょうか。price_vec_na_simple.ffill price_vec_na_simple.ffill().diff().iloc[90:95]を実行して自分で確認してください)。


さらに、金融の世界では、欠損データと外れ値が同時に発生することがよくあります。そのことを説明しましょう。

 #inflate two observations, delete three next ones price_moves_na[90] += 20 price_moves_na[91] += 30 price_moves_na[92] -= 50 # to "deflate" the price shock back price_vec_na = (100 + price_moves_na.cumsum()) price_vec_na[92:95] = [np.NaN, np.NaN, np.NaN] price_vec_na.tail(20).plot() price_vec_na.diff().dropna().mean() >0.7093365245831178 

スパイク + 欠損データ


価格が急上昇した後、3 日間連続でデータが利用できないことがわかります。これは、聞こえるかもしれませんが、それほど「人為的な」例ではありません。少なくともこの特定の取引所では、急上昇後に取引が停止したと想像してください。その後、状況は少し落ち着き、価格は通常の状態に戻りました。おそらく、舞台裏で徐々に何かが起こっていて、急上昇と急上昇後の落ち着きの間の点が実際に「つながって」いたのでしょう。しかし、あなたはそれを知らず、それに関するデータもありません。


新しいデータがない場合、最も自然な仮定は何でしょうか? データ生成モデルは基本的に価格の変化に基づいていたことを思い出してください。したがって、新しいデータがない場合、価格はまったく変化していない可能性があります。これはまさに LOCF (Last Observation Carried Forward) が仮定していることです。

背景を説明する数学

好奇心旺盛な読者のための補足 - LOCFが株価データに特に適している理由に関するより根本的な見方は、それが通常マルチンゲールとしてモデル化されることです。大まかに言えば、マルチンゲールとは、明日の最良の推測が今日見たもの、つまりE[x_{t+1} | x_t] = x_t


さて、実際のデータに戻りましょう。LOCF の結果を視覚的および数値的に調べてみましょう。

 price_vec_na.ffill().tail(20).plot() price_vec_na.ffill().diff().mean() >0.20030544816842052 

LOCF代入


すぐに、LOCF の長所と短所がわかります (文字通り)。まず、平均値は「予想」どおり、つまり変更されていない経験的値に戻ります。ただし、価格が「標準」から外れ、94 日目と 95 日目の間に価格が人為的に下落する、かなり醜い期間が発生します。

帰属についてはどうですか?

LOCF から得られる結果を (平均) 代入と比較してみましょう。これは、特に非時系列データの場合の NA 処理で非常に一般的な選択肢です。ただし、単純に行うと、金融データに使用すると多くの欠点があります。


  • 全サンプル平均だけを使用すると、明らかな先読みバイアスが発生します。つまり、将来のデータを使用して過去の値を代入することになります。

  • 何らかのルックバック平均やローリング平均を使用する方が確かに優れていますが、前に説明したマーチンゲール「ベースライン」ビューと矛盾が生じる場合があります。


これをもう少し詳しく見てみましょう。過去のデータに遡及補完法を適用します。

 price_vec_na_impute = price_vec_na.copy() price_vec_na_impute[price_vec_na_impute.isna()] = price_vec_na.iloc[:90].mean() price_vec_na_impute.diff().mean() >0.20030544816842052 

平均補完

LOCF と同様に、「正しい」価格変動の平均を回復します。ただし、91 日目と 92 日目の間に人為的な価格低下を導入します。これは、ある意味では以前のものよりもさらに悪いものです。結局のところ、前のものは状況が落ち着いたとき、またはその後に発生しましたが、今回はすべてがすぐに正常に戻ると想定しています。それとは別に、実際には、ルックバック ウィンドウのバランスをとって、a) 最近の傾向を捉えるだけでなく、b) 長期的な傾向も捉えることは、やや難しい場合があります (通常のバイアスと分散のトレードオフ)。


2番目の変数を追加する

ここで、より複雑なタスク、つまり、価格系列の 1 つまたは両方に欠損データがある場合に、経験的データから 2 つの資産の価格変動の相関関係を抽出するタスクを実行したいとします。もちろん、ドロップを使用することもできますが、次のようになります。


  • たとえ使用できたとしても、それは最適なのでしょうか?

  • 変数が多数ある場合はどうなるでしょうか。少なくとも 1 つの NA を含むすべての行を削除すると、データがまったくなくなる可能性があります。


相関を計算したい理由はたくさんあります。相関は、ほぼすべての多変数モデルにおける EDA の最初のステップであり、あらゆる種類のポートフォリオ構築でかなり広く使用されているなどです。したがって、この数値をできるだけ正確に測定することは非常に重要です。


例として、最初の変数に対して 0.4 の「組み込み」相関を持つ 2 番目の変数を生成してみましょう。そのためには、一種のガウス混合モデルを使用します。2 つの相関する株式が重要なリスク要因を共有しているものの、2 番目の株式は最初の株式にはない主要なリスク要因にさらされているという図を思い浮かべてください。Google と Facebook を例に考えてみましょう。最初の要因はテクノロジー セクターに関する一般的な感情であり、2 番目はライバルのソーシャル ネットワークとの競争である可能性があります。


 np.random.seed(2) # needed to ensure a fixed second series price_moves_2 = pd.Series(np.random.randn(100)) price_vec_2 = 50+(0.4*price_moves/3 + np.sqrt(1-0.4**2)*price_moves_2).cumsum() # all this math to ensure we get a 0.4 "theoretical" correlation with the first one


「ベースライン」の経験的相関、つまり NA とジャンプなしの相関を確認しましょう。

 pd.concat([price_vec, price_vec_2], axis = 1).diff().corr().iloc[0,1] >0.4866403018044526


これは「理論的な」相関関係にかなり近いものです。相関関係の経験的測定では、かなり大きなノイズが発生しやすいことはよく知られています。


外れ値のないNA

次のステップとして、NAがあるが外れ値がないケースを調べます。また、 diff前後でdropna場合に何が起こるかを比較します。

 pd.concat([price_vec_na_simple, price_vec_2], axis = 1).diff().corr().iloc[0,1] # implicit dropna after diff >0.5022675176281746 pd.concat([price_vec_na_simple, price_vec_2], axis = 1).dropna().diff().corr().iloc[0,1] >0.5287405341268966


どちらの結果も、前に得た「経験的」値と非常に近く、それほど離れていません。LOCF と補完も正常に実行されることを確認しましょう。

 pd.concat([price_vec_na_simple, price_vec_2], axis = 1).ffill().diff().corr().iloc[0,1] >0.5049380499525835 price_vec_na_simple_impute = price_vec_na_simple.copy() price_vec_na_simple_impute[price_vec_na_simple_impute.isna()] = price_vec_na_simple_impute.iloc[:90].mean() pd.concat([price_vec_na_simple_impute, price_vec_2], axis = 1).ffill().diff().corr().iloc[0,1] >0.4866728183859715


上記の 4 つの結果を比較すると、すべての方法が非常にうまく機能していることがわかります。おそらく、外れ値のケースでも同じことが期待できるのでしょうか?


外れ値のあるNA

覚えておいてください。一貫性を保つには、2 番目の価格シリーズを最初の価格シリーズが経験したのと同じ価格ショックにさらす必要がありますが、その後の NA は発生しません。上記の例に戻って、最初のリスク要因の急上昇を引き起こす大きなイベントを想像してください。その結果、最初の資産の取引が停止します。2 番目の資産ももちろん同じリスク要因を経験しますが、おそらくその程度は小さいため、停止は発生せず、したがって NA も発生しません。


 price_vec_na_2 = 50+(0.4*price_moves_na/3 + np.sqrt(1-0.4**2)*price_moves_2).cumsum()


もう一度、すべての方法のパフォーマンスを比較してみましょう。

 pd.concat([price_vec_na, price_vec_na_2], axis = 1).diff().corr().iloc[0,1] >0.6527112906179914 pd.concat([price_vec_na, price_vec_na_2], axis = 1).dropna().diff().corr().iloc[0,1] >0.7122391279139506


理論値と経験値の両方でかなりの違いがありますね!LOCF と代入はどうでしょうか?

 pd.concat([price_vec_na, price_vec_na_2], axis = 1).ffill().diff().corr().iloc[0,1] >0.33178239830519984 pd.concat([price_vec_na_impute, price_vec_na_2], axis = 1).dropna().diff().corr().iloc[0,1] >0.7280990594963112


これで、ようやく LOCF の価値がわかりました。他のすべての方法よりも明らかに優れています。


潜在的な落とし穴 + 補間

もちろん、これは 100% 堅牢ではありません。まず、LOCF を実行すると、欠損データが終了したときに大きな価格低下が発生します。これが 2 番目の価格ベクトルの外れ値と一致する場合、結果はかなり変わる可能性があります。(* 読者向けの演習 - price_vec_na_2[95]の価格変動の符号を反転し、結果にどのように影響するかを確認してください)。この価格低下を導入するだけで、たとえば価格ピークprice_vec_na[91]とその後の「通常の」値price_vec_na[95]の間を補間するのに比べて「クリーン」であるかどうかは、はっきりしていません。ただし、特に「ライブ」使用の場合、補間は実際には不可能です。結局のところ、今日が 93 日目である場合、95 日目の終わりに記録された将来の値を使用して補間するにはどうすればよいでしょうか。歴史的研究の場合、もちろんそれはオプションとして残っていますが、実際の予測にどのように解釈して使用するかは不明のままです。結論として、時間次元にわたる補間は可能ですが、やや疑問が残ります。


結論

私は、金融時系列の欠損データを処理するために LOCF が最も魅力的でシンプルなオプションであることが多い理由を紹介し、主張するために、小さなケース スタディを試みました。


まとめると、利点は次のとおりです。

  • マーチンゲール/「情報フロー」の観点から魅力的
  • 実装が非常に簡単
  • 最適化の必要がない(代入とは対照的に)
  • 外れ値を少なくとも適切に処理する


いくつかの欠点:

  • 欠落期間の終了時に大きなジャンプを引き起こす可能性がある
  • 複数の変数に使用すると微妙な関節の動きが見逃される可能性がある


プロップトレーディングショップのクオンツとして、私はほぼすべての研究でこれを効果的な基準として使用しています。もちろん、より微妙な測定が必要な状況もありますが、そのような状況はごくまれであり、通常、前述の他の 3 つの方法のいずれでも 100%「解決」されることはありません。