🚁

Numeraiで学ぶ金融時系列モデル評価指標

2023/07/04に公開

雨にも負けず
風にも負けず
冬にも
夏の過熱相場にも負けぬ
ロバストな予測を持ち
強欲はなく
決して悲観せず
いつも静かに利益を重ねている
...
そう言うモデルを
私は作りたい

by ??? (20??年)

前書き

こんにちは。日本爆損防止委員会です(さっき考えた)。

さて、皆さんは今日も今日とて爆損を垂れ流していらっしゃると思います。その原因は様々あろうかと思いますが、そもそも「原因がわからない」という方がほとんどではないでしょうか。

爆損しているのに原因がわからない、というのは、言うまでもありませんが大変なストレスです。楽しい思いをしようと小さな島に遊びに行ったら殺人事件が起きて誰が犯人かわからないけど容疑者の人たちと一緒に暮らさないといけないイメージです(?)。

どうせ爆損するなら、「あーワイのモデルはこういう市況に弱いことが検証でもわかってて、今はその市況だから爆損なんやー」と原因が分かったほうが気が楽ですし、何よりモデルを止める・改良するなどのアクションに移せます

原因がわからないと「今日は爆損なんだし、明日は爆益なんじゃね?」などとNYのカップケーキよりも甘い考えに囚われ、アクションを起こせず爆損を積み重ねることになります。

ではどうしたら自分のモデルを正しく評価できるようになるでしょうか?

トレーディングは時系列データを取り扱いますから、時系列の評価指標を使って分析することで、様々な角度から自分のモデルを客観的に評価できます。

先日のマケデコのイベント「マーケットAI開発におけるプロジェクトマネジメントと現場のモデル活用スペシャル」において、主催のAlpaca北山さんが以下のようなことを言及されていました。

-「トレーディングにおける時系列の評価指標に関する実例や研究は、Numeraiのようなコミュニティが存在する実際のファンドが開示してくれているものを学ぶと良い

-「ミレ●アムやP●int72といったヘッジファンドは評価方法を開示していないため、探しても無駄」

そこで今回の企画は、現在イケイケのクオンツヘッジファンドであるNumeraiが使っている評価指標を(pythonコード付きで)網羅的に紹介することで、1人でも爆損に悩む方々の救いになることを目指したいと思います。

Numeraiとは

Numeraiは近年成長著しいアメリカのクオンツヘッジファンドです。予測リターンを自社のクオンツが計算するのではなく、世界中のデータサイエンティストからクラウドソースで収集し、アンサンブルしてポートフォリオを組んでいることに最大の特徴があります。

どうやって世界中のデータサイエンティストから予測値を集めるの?( ・∇・)

データサイエンティストって暇なの?( ・∇・)

という疑問は当然あろうかと思いますが、Numeraiは(現在dailyで)コンペを開催し、実際のライブテストでの結果が良ければNMRという暗号資産を(参加者のstake額に比例した形で)参加者に支払うことで、データサイエンティストに予測値を提出するインセンティブを与えています。

コンペの種類として、Numeraiが特徴量もターゲットも用意するTournamentと、Numeraiはターゲットのみ用意し参加者が独自のデータを使用するSignalsの2つがあります。

モデルの評価指標はなぜ大切なのか?

繰り返しになりますがNumerai自身はモデルを作らず、参加者から予測値だけを収集しているわけですから、アンサンブルして得られたトレーディングシグナル(Numeraiではメタモデルと呼んでいます)が一体全体どういう性質を持っているものなのか、収集時点では全くもって不明なわけです。

それでもNumerai自身は機関投資家として、年金機構や超富裕層のお金を運用しているわけですから、

「なんかわからんけどシグナルできたんで、みなさんのお金使ってハイレバ取引しまっす^^」

というわけにはいきません。

あらゆる角度から分析し、どういうリスクを取っているのか、ベンチマークと比較して何が特に優れているかなど明らかにし、投資家に納得してもらわなければお金を預けてもらえません

(予測値の)クラウドソーシング x 暗号資産(を使ったインセンティブ設計) x 機械学習(によるトレーディングシグナル)とそもそものコンセプトが色々新しいNumeraiですから、投資家を説得するハードルは他の機関投資家よりもはるかに高いでしょう。

これがまず、Numeraiがモデルの評価にガチにならないといけない理由の1つ目です。

もう1つは、そもそも予測値を提出するコンペ参加者にしょーもない予測値を提出されては困るわけです。しょーもないものを何千と集めてもSP500などのベンチマークにも勝てません。理想的には、参加者自身が自身のモデル予測値を正しく検証し、出来上がった自信のある「強い」予測値をたくさん集め、トレーディングシグナルにしたいのです。

これを実現するためには、Numeraiから参加者へのフィードバックが欠かせません。もちろん、NMR貰える貰えないというライブテスト後のフィードバックはありますが、そもそもライブテスト以前に検証期間の予測値がどれくらい良さそうなのか、様々な評価指標を計算してあげて参加者にフォードバックしてあげれば、参加者のモデリングの助けになります。そのため、過去の検証期間の予測値を提出すると、様々な評価指標を一気に計算してくれる仕組みがNumeraiのwebsite上に整備されています。

Numeraiは参加者に報酬を払いますが、賭場と違って運営と参加者は「いいモデルを作ってほしい」「いいモデルを作りたい」と同じ方向を向いていますので、Numeraiは参加者がより良いモデリングができるよう、(リソースの範囲内で)ありとあらゆる応援をしてくれます。Numeraiがモデルの評価指標をあれこれ公開してくれるのもその一環です。次章では、TC (True Contribution)といった癖が強すぎるNumeraiコンペ独自のものは除き、株や仮想通貨のトレーディングなどでも汎用的に使えそうなものに絞って紹介していきます。

評価指標

評価指標をパフォーマンス系、リスク系、その他の3種類で見ていきます。この複数視点が大切です。一見ハイパフォーマンスに見えるモデルでも、大きく長く負ける時期が一部あるかもしれないですし、そんなとき人間はなかなかストレスに耐えられないです。

指標を計算するためのデータとしては、個別株 x era(取引時間粒度)単位のレコードが含まれるpandas dataframe (df)を想定します。

dfには、銘柄コード (ticker)、era(dailyなら日付)、target(予測対象のリターン)、そしてモデリングに使用した特徴量features (feature1, feature2, feature3, ...)及び、モデル予測値が含まれるpredictカラムがあるとします。

ChatGPTくんにモックを作ってもらいましたが、こんなイメージです(あくまでイメージで中身に意味はありません)。

パフォーマンス系

モデルのパフォーマンスを定量する評価指標です。

Corr

予測値とターゲットの順位相関係数です。予測値とターゲットが相関していれば、予測がうまくいっていそうですよね。

実装はたとえば以下のようにします。


def get_corr(df, target_name=target, pred_name='predict'):
    '''Takes df and calculates spearm correlation from pre-defined cols'''
    # method="first" breaks ties based on order in array
    return np.corrcoef(
        df[target_name],
        df[pred_name].rank(pct=True, method="first")
    )[0,1]

トレーディングの時系列の文脈では、全レコードに対し1つのcorrを計算するのではなく、取引時間粒度 (era)単位で計算するほうが良いです。Corrがマイナスになるような時期に何があったのか調べるきっかけになりますし、Corrをera単位で計算することでcorrの分布を見ることができるので、時期によるばらつき等様々なリスク指標もここから計算できます。

Corrに限った話ではありませんが、era単位で指標を計算するときはpandasのgroupbyを使って、apply関数でeraごとに計算を行います。


corrs = df.groupby('era').apply(lambda d : get_corr(d, target_name=target, pred_name='predict'))

Numerai Corr

UKIさんに触発されて日本株でLS (Long Short)戦略に取り組んでいる方も多いと思いますが、そもそもLSで取引するのは資金的にも毎回数銘柄が限界、という方も多いのではないでしょうか(はい私です)。

その場合、(基本的に)モデル予測値のTOP N銘柄を買い、BOTTOM N銘柄を売りという取引をするわけですので、実はbacktestでeraごとに全レコードでcorrを計算するのは、あまり現実に即しているとは言えません。

というのは、eraごとにたとえばTOPIX500なら500銘柄程度、Numerai Signalsなら5,000銘柄程度レコードがありますが、そのうち実際に取引できるのが上位と下位数%であるとしたら、別にその上位下位以外の部分で相関してようがしてまいがどうでもよいのですね。

ただ、取引対象の上位下位数%の部分はバッチリ相関していてほしいわけです。

これをどう評価指標で表現するかですが、1つの方法としては、取引対象のTop Bottom (TB) N銘柄だけeraごとに使ってcorrを計算することがまず考えられます。NumeraiだとTB = 200ですが、個人だと200銘柄一度に売買するのは厳しいかと思いますし、かといって数銘柄だとeraごとのサンプル数が激減してしまい、信頼できるcorrが得られない恐れがあります。

そこでNumeraiは独自にNumerai_Corr(名前まんまや...)を提供しています。


def numerai_corr(preds, target):
    """
    https://github.com/numerai/example-scripts/blob/8f1abbc56c7a1a19b8c6e615a8bd0d9becb7b244/utils.py#L175C1-L185C52
    """
    # rank (keeping ties) then gaussianize predictions to standardize prediction distributions
    ranked_preds = (preds.rank(method="average").values - 0.5) / preds.count()
    gauss_ranked_preds = scipy.stats.norm.ppf(ranked_preds)

    # center targets around 0
    centered_target = target - target.mean()

    # raise both preds and target to the power of 1.5 to accentuate the tails
    preds_p15 = np.sign(gauss_ranked_preds) * np.abs(gauss_ranked_preds) ** 1.5
    target_p15 = np.sign(centered_target) * np.abs(centered_target) ** 1.5

    # finally return the Pearson correlation
    return np.corrcoef(preds_p15, target_p15)[0, 1]

(ピアソン)相関係数を計算する前に1.5乗することで、Eraごとにレコード数を減らすことなく、予測値やターゲットの分布の端(テール)の部分を強調して対応するようにしていますね。

現在はこのnumerai_corrがNumeraiコンペのスタンダードな評価指標になっています (CORR20V2)。

私はこれになってからNumeraiで勝てなくなりました。助けてください。

FNC (Feature Neutral Correlation)

FNCもCorrと同様に相関係数ですが、予測値をある特徴量セットによって直交化 (neutralize)した後、ターゲットとの相関を計算したものです。

直交化って何だお( ・∇・)?

と思われる方も多いかもしれませんが、ここではモデル予測値から特徴量の線形成分を控除する数学的手続きのことを指します。

どうしてせっかく学習した特徴量の(線形)成分を除いちゃうんだお?( ;∀;)

と思われる方も多いかもしれませんが、基本的に金融時系列の特徴量とターゲットリターンの相関自体が不安定であるため、そこに依存したモデルの予測値もすべからく不安定なものになります。

RSIとかなんちゃらクロスだけで勝ち続けられたら誰も苦労しないって話ですよ。

そこで、使用した特徴量(Numeraiの場合、420個の特徴量からなるV3 medium feature setを使っている)とターゲットの線形予測成分を除き、非線形の成分だけを残すことで、機械学習による特徴量群とターゲットの安定した関係を構築しようと言う試みがfeature neutralizationであり、FNCはその直交化後のモデル予測値とターゲットの相関係数です。


def calculate_fnc(sub, targets, features):
    """    

    https://docs.numer.ai/numerai-tournament/scoring/feature-neutral-correlation

    Args:
        sub (pd.Series)
        targets (pd.Series)
        features (pd.DataFrame)
    """
    
    # Normalize submission
    sub = (sub.rank(method="first").values - 0.5) / len(sub)

    # Neutralize submission to features
    f = features.values
    sub -= f.dot(np.linalg.pinv(f).dot(sub))
    sub /= sub.std()
    
    sub = pd.Series(np.squeeze(sub)) # Convert np.ndarray to pd.Series

    # FNC: Spearman rank-order correlation of neutralized submission to target
    fnc = np.corrcoef(sub.rank(pct=True, method="first"), targets)[0, 1]

    return fnc

モデルが表面上の特徴量とターゲットの相関だけ学習していたりすると、直交化した予測値とターゲットとの相関 (FNC)が極めて0に近くなって「あはは何も情報残ってないやんけ」ってなって面白いです(白目)

逆にうまくモデリングできれば、直交化は強力な武器になります。読まれてない方はぜひ↓のUKIさんの記事をご覧ください。

機械学習による株価予測 KaggleのJPXコンペを終えて

Sharpe

モデルの評価指標として最もよく使われるのがこのSharpe ratioでしょう。Numeraiでは先ほどCorrのセクションで計算したeraごとの相関係数 (corrs) を使って、以下のように計算しています。


sharpe = corrs.mean() / corrs.std(ddof=0)

相関係数のeraごとの平均値を標準偏差で割ったもので、リスク(標準偏差)調整済みパフォーマンスを表現します。

いろんな時間軸のロジックを持っている方は、annualizeといって年単位にsharpeを直すと比較しやすいです。

この実装は、以下のquantstatsのsharpeの計算を見ていただけると良いかと思います。このライブラリはNumerai関係なく、returnのpandas seriesがあれば汎用的に使え、めちゃめちゃ色々な評価指標を勝手に計算し綺麗なレポートを作ってくれるので、システムトレーダーには必須だと思います。


def sharpe(returns, rf=0.0, periods=252, annualize=True, smart=False):
    """
    Calculates the sharpe ratio of access returns

    If rf is non-zero, you must specify periods.
    In this case, rf is assumed to be expressed in yearly (annualized) terms

    Args:
        * returns (Series, DataFrame): Input return series
        * rf (float): Risk-free rate expressed as a yearly (annualized) return
        * periods (int): Freq. of returns (252/365 for daily, 12 for monthly)
        * annualize: return annualize sharpe?
        * smart: return smart sharpe ratio
    """
    if rf != 0 and periods is None:
        raise Exception("Must provide periods if rf != 0")

    returns = _utils._prepare_returns(returns, rf, periods)
    divisor = returns.std(ddof=1)
    if smart:
        # penalize sharpe with auto correlation
        divisor = divisor * autocorr_penalty(returns)
    res = returns.mean() / divisor

    if annualize:
        return res * _np.sqrt(1 if periods is None else periods)

    return res

「sharpeが2を超えれば、マーケティング部門は必要ない」

というヘッジファンド業界の格言(?)があるらしいです。Sharpeが2を超えたら、も、もしかしたらリークかもしれないのでまず私にロジックを相談されると良いかと思いますはい。

リスク系

次はリスク系です。どれだけパフォーマンスが時期によってばらつくのか、最大どれだけの損失を覚悟しないといけないのか、などを教えてくれます。

Standard Deviation

Sharpeのところでも出てきましたが、どれだけ収益に変動があるかを計算しておくことで、余計にハラハラしないですむ(かもしれない)です。大きく勝って大きく負けるを繰り返すようなモデルだと、右肩上がりの損益曲線でも人間にはストレスですよね。

こちらもAnnualizeすると異なる時間軸のロジックを比較しやすいので、またまたquantstatsからコードを引用します。

quantstats


def volatility(returns, periods=252, annualize=True, prepare_returns=True):
    """Calculates the volatility of returns for a period"""
    if prepare_returns:
        returns = _utils._prepare_returns(returns)
    std = returns.std()
    if annualize:
        return std * _np.sqrt(periods)

    return std

Feature Exposure

FNCのセクションと大きく関連しますが、feature exposureはモデル予測値と特徴量の順位相関です。Numeraiでは特徴量全てとモデル予測値の順位相関係数を計算し、その平均値や最大値を指標として使います。

あんまり予測値と特定の特徴量の相関が高いと、せっかく機械学習しても情報としてはとある特徴量とほぼ一緒、ということですので、その特徴量の持つリスクをモデル予測値も受け継いでいることになります。偉い人に「それって機械学習、いる?」って言われて恥ずかしい思いをします。

Feature neutralization後、モデル予測値のfeature exposureを計算することで、本当に予測値が特定の特徴量に依存していないかどうか、確認することもできます。

実装例は↓


PREDICTION_NAME = 'predict'

def feature_exposures(df, feature_names=features):
    exposures = []
    for f in feature_names:
        fe = spearmanr(df[PREDICTION_NAME], df[f])[0]
        exposures.append(fe)
    return np.array(exposures)

def max_feature_exposure(df):
    return np.max(np.abs(feature_exposures(df)))

def feature_exposure(df):
    return np.sqrt(np.mean(np.square(feature_exposures(df))))

Max Drawdown

MDDと略されたりもしますが、検証期間で観測された最大の損失を表現します。Numeraiでは先ほどCorrのセクションで計算したeraごとの相関係数 (corrs) を使って、以下のように計算しています。

システムトレードでreturnsのpandas seriesがあれば、quantstatsでも計算してくれます。


def get_max_drawdown(corrs):
    """
    https://github.com/numerai/example-scripts/blob/8f1abbc56c7a1a19b8c6e615a8bd0d9becb7b244/utils.py#L278C18-L278C18
    """
    rolling_max = (
        (corrs + 1)
        .cumprod()
        .rolling(window=9000, min_periods=1)  # arbitrarily large
        .max()
    )
    daily_value = (corrs + 1).cumprod()
    max_drawdown = -((rolling_max - daily_value) / rolling_max).max()

    return max_drawdown

ライブでMax Drawdownを観測する前にモデルを止めたり改善を行いたいところですが、過去に様々な取り組みがあるようです。

Alpaca北山さんによるマケデコでのご回答

基本的には、使用している特徴量や予測値(SHAP値など)の分布が過去と大きく違っていないか、確認することで異常検知を試みるもののようです。

モデルできたら流しっぱなしではなく、監視が必要ってことですね。流しているモデルの監視についてのあたり、いわゆるMLOpsの話なんだろうと思いますが、トレーディングの文脈だと案外知見が出ていない気がするので、誰か共有してくれてもいいんですよ?

その他

パフォーマンス系ともリスク系とも言えるような言えないようなやつらです。

ReversalsとARLについては、Numeraiではモデル予測値というより特徴量ごとに計算して、特徴量選択の参考にするための数値として与えられています。

Reversals

検証期間において、その特徴量とターゲットとの相関のサイン (+ or -) が何度入れ替わったかカウントしたものです。低い値であれば、安定した特徴量であると言えます。

ARL (Average Run Length)

検証期間において、その特徴量がターゲットと高い相関を維持していた連続era数です。高い値であれば、安定した特徴量であると言えます。

Autocorr

eraごとの相関係数 (corrs) を用いて計算する、eraをずらしたときの自己相関係数です。高い値であれば、安定した特徴量、予測値であると言えます。

Churn

Autocorrと近い考えで、era単位でのcorrの自己相関ではあるのですが、こちらは個別銘柄粒度も入っており、どれくらい頻繁にポートフォリオの入れ替えが発生しているか定量しているものです。Numeraiでは、Signalsに最近 (2023/06/03)導入された評価指標で、Churnが高いと入れ替え(Turnover)が頻繁に起きており、不安定なポートフォリオであることを示唆しています。Numeraiからは、0.15以下が望ましいという指針が出ています。

実装は↓


def calculate_churn_stats(
    df,
    pred_col='predict',
    ticker_col='ticker',
    era_col='era'
    ):
    """
    https://forum.numer.ai/t/churn-new-signals-diagnostics-metric/6417
    """

    # rank and normalize per era
    df[f'{pred_col}_ranked'] = df.groupby(era_col)[pred_col].apply(lambda group: (group.rank() - 0.5) / len(group.dropna()))
    
    # fill na with 0.5
    df[f'{pred_col}_ranked_filled'] = df[f'{pred_col}_ranked'].fillna(0.5)
    
    # Sort the dataframe and set a multi-index with id_col and era_col
    df = df.sort_values([ticker_col, era_col], ascending=[True, False])
    df.set_index([ticker_col, era_col], inplace=True)
    
    # drop duplicates
    df = df.loc[~df.index.duplicated(keep='first')]

    # Unstack the dataframe to ensure every combination of id_col and era_col has a row
    df_unstacked = df.unstack(level=ticker_col)

    # Shift the pred_col within each id_col group
    shifted_df_unstacked = df_unstacked[f'{pred_col}_ranked_filled'].shift(-1)

    # Stack the dataframe back to a regular dataframe
    df_shifted = df_unstacked.stack(dropna=False)
    df_shifted[f'{pred_col}_ranked_filled_prev'] = shifted_df_unstacked.stack(dropna=False)

    # Calculate Spearman correlation
    churns = df_shifted.groupby(level=era_col).apply(lambda group: 1 - group[f'{pred_col}_ranked_filled'].corr(group[f'{pred_col}_ranked_filled_prev'], method='spearman'))
    
    # Calculate churn stats
    churn_stats_df = churns.agg(['mean', 'std', 'max']).rename(
        index={'mean': 'churn_mean', 'std': 'churn_std', 'max': 'churn_max'})

    return churn_stats_df

終わりに

Numeraiが公開しているモデル評価指標は一通りはさらったかと思います。数字感などは目安がありつつも、モデルを改良する中で使っていき、習うよりも慣れるのが良いかもしれません。

Bin⚫︎nceも⚫︎KXも日本からのアクセス制限が出てきてしまっていますが、Numeraiトークン (NMR)をstakeしなくてもNumerai自体はできますので、やったことない人はとりあえずNumerai TournamentかSignalsでモデルを作って提出してみて、diagnosticsを見てみるのがいいんじゃないかと思います。

Tournament...直近はやや難しい

Signals...Churnあんまりよくない...

それではHappy Modeling!

引用

Discussion