この記事は Zenn に投稿したものの再掲です。初出: 固定ホライズンのラベルは、なぜ筋が悪いのか——トリプルバリア法という選択肢
以前、検証が嘘をつくパターンについて連載を書きました。あの連載は「検証をどう壊さないか」がテーマでしたが、今回は一歩手前の話です。そもそも、何を予測させるか——ラベル設計について書きます。
ラベルは、検証がどれだけ正しくても、設計そのものが悪ければ意味のあるモデルにはなりません。そして、この設計は驚くほど雑になりがちです。
一番よくある設計:固定ホライズンのリターン
もっとも素朴なラベルは、こういう形です。
# 「この先20本後の価格が今より上がっているか」を予測する
df["label"] = (df["close"].shift(-20) > df["close"]).astype(int)
シンプルで理解しやすい。でも、これには構造的な弱点が2つあります。
弱点1:利益と損失の非対称を無視する
固定ホライズンのラベルは「20本後にプラスか」しか見ていません。その途中で、含み損が実際の損切り幅を超えて死んでいても、20本後にたまたま戻っていれば「勝ち」としてラベル付けされます。逆に、序盤で大きく利確ラインに到達していても、20本後に反落していれば「負け」になる。
これは、実際のトレードのルール(利確・損切り)と、ラベルの定義が一致していません。 モデルは、あなたが実際には取らない挙動(「途中で何があっても20本後まで持ち続ける」)を学習することになります。
弱点2:ホライズンをどう決めても恣意的
「20本」という数字に、根拠はあるでしょうか。多くの場合、なんとなく決めた値です。銘柄やボラティリティが変われば、最適なホライズンも変わるはずなのに、固定値は市場の状態を無視します。ボラティリティが高い日に20本進む値幅と、静かな日に20本進む値幅は、まったく違う意味を持ちます。
代替案:トリプルバリア法
この2つの弱点に対する、比較的よく使われる解法がトリプルバリア法です。López de Pradoが体系化した手法として知られています。
発想はシンプルです。各エントリー候補点に対して、3本のバリアを引きます。
- 上側バリア(利確ライン)
- 下側バリア(損切りライン)
- 時間バリア(保有期限)
そして、どのバリアに最初に触れたかでラベルを決めます。
import pandas as pd
import numpy as np
def triple_barrier_label(
prices: pd.Series,
entry_idx: int,
upper_pct: float,
lower_pct: float,
max_holding: int,
):
"""
prices: 価格系列
entry_idx: エントリー候補のインデックス位置
upper_pct, lower_pct: エントリー価格からの上下バリア(%)
max_holding: 時間バリア(最大保有本数)
"""
entry_price = prices.iloc[entry_idx]
upper = entry_price * (1 + upper_pct)
lower = entry_price * (1 - lower_pct)
window = prices.iloc[entry_idx + 1 : entry_idx + 1 + max_holding]
for i, price in enumerate(window):
if price >= upper:
return 1, i + 1 # 利確バリアに先に到達
if price <= lower:
return -1, i + 1 # 損切りバリアに先に到達
# 時間切れ:最終価格の方向で判定するか、0(判定不能)とする
if len(window) == 0:
return None, None
final_return = (window.iloc[-1] - entry_price) / entry_price
return (1 if final_return > 0 else -1), len(window)
これで得られるラベルは、実際のトレードルール(利確幅・損切り幅・保有期限)と定義が一致します。「20本後にプラスかどうか」ではなく、「利確ラインと損切りラインのどちらに先に触れたか」を直接ラベル化しているので、モデルが学習する対象と、実運用でモデルが使われる文脈がズレません。
バリア幅をどう決めるか:ボラティリティに連動させる
固定パーセンテージのバリアにも、固定ホライズンと同じ弱点が残ります。「上下2%」を全銘柄・全期間で固定すると、ボラティリティの高低を無視することになります。
改善策は、バリア幅をその時点のボラティリティに連動させることです。
def dynamic_barriers(prices: pd.Series, entry_idx: int, vol_window: int = 20, barrier_mult: float = 2.0):
"""直近のボラティリティに応じてバリア幅を決める"""
returns = prices.pct_change()
local_vol = returns.iloc[max(0, entry_idx - vol_window):entry_idx].std()
barrier_pct = local_vol * barrier_mult
return barrier_pct, barrier_pct # 上下対称にする場合。非対称にしても良い
ボラティリティが高い局面ではバリアを広く、静かな局面では狭く取ることで、「どんな相場状況でも、同じような"意味のある値幅"を捉える」ラベルになります。
不均衡への対処
トリプルバリア法を含め、ラベルは往々にして不均衡になります。「時間切れ(0付近)」が多く、明確な利確・損切りに到達するケースが少ない、という分布になりがちです。
対処の選択肢はいくつかあります。
1. 時間切れを除外する
# 時間バリアで決着したサンプルを学習対象から外す
labeled = df[df["barrier_type"] != "time"]
もっともシンプルな方法です。ただしサンプル数が大きく減るので、データ量が少ない場合は注意が必要です。
2. サンプル重みで調整する
from sklearn.utils.class_weight import compute_class_weight
classes = np.unique(y_train)
weights = compute_class_weight("balanced", classes=classes, y=y_train)
class_weight = dict(zip(classes, weights))
# model.fit(..., class_weight=class_weight) のように渡す
除外せず、学習時に少数クラスの重みを上げる方法です。データを捨てないメリットがありますが、少数クラスへの過学習には注意してください。
3. メタラベリング(発生確率と方向を分離する)
これはLópez de Pradoが提案する、より発展的な手法です。「方向を当てるモデル」と「そのシグナルを取るべきかを判定するモデル」を分離します。
# 1段目:既存のルールベースやシンプルなモデルで方向シグナルを出す
primary_signal = simple_direction_model.predict(X)
# 2段目:そのシグナルが「取るべきか」を別モデルで判定する
# ラベルは「1段目のシグナルが正しかったか(0/1)」
meta_label = (primary_signal == true_direction).astype(int)
meta_model.fit(X, meta_label)
1段目のモデルの精度がそこそこでも、2段目が「自信のあるシグナルだけ絞り込む」役割を果たすことで、全体の精度と取引頻度のバランスを取れます。不均衡データへの対処としてだけでなく、シグナルの取捨選択そのものを学習させる発想として有効です。
ラベル設計とリークの関係(前回までの連載との接続)
ここで一つ、以前の連載と接続しておきます。トリプルバリア法のラベルは、**前向きの窓(未来のmax_holding本を見る)**で作られます。つまり、隣接するエントリー候補のラベルは時間的に重なりやすい。これは連載で扱った「特徴量リーク」の口の一つ、ラベルの窓の重なりそのものです。
トリプルバリア法を導入したら、purgeとembargoは必須だと考えてください。ラベル設計を工夫しても、分割の設計を疎かにすると、また同じ罠に戻ります。
まとめ
- 固定ホライズンのラベルは、実トレードのルールと乖離しやすく、ホライズンの選び方も恣意的になりがち
- トリプルバリア法は、利確・損切り・時間切れの3バリアで、実際のトレードルールと整合したラベルを作れる
- バリア幅は固定値ではなく、ボラティリティに連動させると市場状況の違いを吸収できる
- ラベルの不均衡には、除外・サンプル重み・メタラベリングという選択肢がある
- トリプルバリア法のラベルは前向きの窓を持つため、purge/embargoは引き続き必須
ラベル設計は、モデルの精度以前に「何を学習させたいのか」を定義する工程です。ここが曖昧なままハイパーパラメータをいくら調整しても、土台がズレたままです。
質問や「うちはこういうバリア設計にしている」という話があればコメントで。