Diana Boat

ボートレース(競艇)全場無料予想サイト ダイアナボート(V4.27.90)

氷水に浮かべたそうめんと、薬味(みょうが・大葉)、つゆの器
Episode.4

「予想」を「異常検知」として定式化する ― DeepSAD

2024年3月、大穴を「異常」とみなす異常検知に賭けた。この仮説を完全否定するのに2年かかる。

2026年8月27日6

目次

前回のエピソード執筆中に、スポーツ大佐を思い出した。小田扉さんの漫画「団地ともお」の登場人物である。「団地ともお」には、他にも深く僕の記憶に残っているエピソードがある。夏になると思い出す、そうめん職人の話だ。※1

団地に住むハザマさんが、そうめん職人から究極のそうめんを手にいれる話で、そうめん職人は自分のそうめんを次のように表現する。

「...その時初めて、自分がそうめんを吸い込んでいたのではなく...自分が吸い込まれていた事に気付く...」

異常値の検出=大穴の検出、この悪魔的な妄想に僕は吸い込まれていく。

異常の定義

当時の学習モデルの精度を見てみよう。モデルの精度とは、特徴量を入力としてモデルが出した予想が、実際の結果とどれくらい一致したかを表す数値である。ただし「どれくらい一致したか」の測り方はひとつではない。どの指標を選ぶかで、モデルの良し悪しは変わる。

この時期は AUC を見ていたようだ。ボートレース住之江会場のモデルで異常検知の状況を見てみよう。

世代 CSV の日付 学習させたもの 大穴の定義 該当率 AUC
type0_raw 2024-03-14 91次元(畳まず6艇ぶん並べた + コース別着順分布) 12,000円超 13.81% 0.4837
type0 2024-03-28 20次元(内3艇/外3艇の平均に畳んだ 36,000円超 3.81% 0.5604

世代の type0_raw, type0 は学習したモデルにつけた名前で、おそらく初号機(=type0)で殆ど何も手を加えていない特徴量(raw features)を入力したものが type0_raw、type0 は特徴量をなんらか変更して学習したモデルのことだろう。

モデルはどちらも DeepSAD※2 だ。特徴量を変えて学習させるのはわかるが、大穴の定義(=異常なレースの定義)を払戻金額で定義し、かつそれを変えたりしている。当然、払戻金額で異常を定義すると、過去異常だったレースの割合は変わってくる。

住之江の場合、12,000円とすると過去 13.81% のレースが異常、36,000円とすると 3.81% が異常ということになる。つまり、異常検出のために異常の定義をするという危うい試行錯誤感が伺える。

しかも、type1 という世代も存在した。どうやらレース情報ではなく、オッズ情報だけを特徴量にしたものらしい。大穴の定義は 50,000円超で、該当率は 2.12%(こちらは住之江ではなく桐生のファイルが残っていた)。

市場のオッズだけで荒れを当てられるか試そうとして、特徴量を作ったところで止まったブランチのようだ。現在のモデルもオッズは特徴量としては使っていないが、誰もが1度は考えてしまう罠かもしれない。オッズをどう料理しても、市場には勝てないのだ。

コイン投げ

上述の表の AUC の話に戻ろう。コイン投げをモデル化したら AUC は 0.5 になる。つまり全くもってランダム。ほぼ何も当てていないことを意味している。

91次元版は 0.4837。コイン投げを下回っている。20次元に畳んだ版は 0.5604 とわずかに上へ出たが、どちらも 95% 信頼区間が 0.5 をまたぐ。統計的には、どちらもコイン投げと区別がつかない。

この数値は当時の僕にとって危機的な状況だったであろう。絶対上手くいくと妄想していたのが全否定された状況なのである。

さまざまなパターンでモデルの学習を試みているが、当時のスコアは残っていない。とはいえ、AUC を見ていなかったわけではない。コードは毎回きちんと計算して、print でログにも出している。保存していなかっただけだ。S3 に置いていたのは model / mean / std / threshold の4点で、スコアはそこに入っていない。そしてそのログは、ロググループごと消えていた。

なお、上の表の AUC は、当時のモデル本体が S3 に残っていたので、同じ学習データ・同じ分割で測り直したものである。当時保存された平均値と、こちらで再現した平均値が完全に一致したので、2024年のログに流れて消えたのと同じ数字が出ているはずだ。

score / auc_score を pickle で残すようになるのはモデル type3 からだ。これは、僕が学習時のモデルのハイパーパラメータの調整が重要であることに気づき始めたからである。初期モデルのハイパーパラメータについては、次のエピソードで話そうと思う。

24場の罠、再び

91次元版、type0_raw のコードを見てみよう。

    print ("Model Type0: pull target and training data.")
    # pull target and training data
    y = fe_df['target']
    tmp_df = fe_df.copy()
    X = tmp_df.drop(columns=['target','DateAndRaceId'], axis=1)

    print ("Model Type0: split training data.")
    # データをトレーニングセットとテストセットに分割
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=139)

    # 特徴量の標準化
    scaler = StandardScaler()
    # scaler = RobustScaler()
    X_train = scaler.fit_transform(X_train)
    X_test = scaler.transform(X_test)

    # 平均値と標準偏差をpredictionフェーズのために保存
    mean_training = scaler.mean_
    std_training = np.sqrt(scaler.var_)

    #model = DevNet(device='mps')
    #model = DeepSAD(device='mps')
    #model = DeepSAD(device='cpu',epochs=100, hidden_dims='80,40,10',rep_dim=128)

    if e_race_stadium == '01': # auc: 0.5430 2024-03-18
        model = DeepSAD(device='cpu',batch_size=64, epochs=110, hidden_dims='91,64,48',rep_dim=32, act='Sigmoid')
    elif e_race_stadium == '02':
        model = DeepSAD(device='cpu',batch_size=24, epochs=100, hidden_dims='91,64,32,48',rep_dim=96, act='Sigmoid')
    ...
    else:
        model = DeepSAD(device='cpu',batch_size=70, epochs=110, hidden_dims='91,64,32,48',rep_dim=96, act='Sigmoid')

    print ("Model Type0: Tranining Model...")
    model.fit(X_train, y=y_train.to_numpy()) # semi_y uses 1 for known anomalies, and 0 for unlabeled data
    scores = model.decision_function(X_test)
    # ROC曲線の計算
    fpr, tpr, thresholds = roc_curve(y_test, scores)

    # AUCの計算
    roc_auc = auc(fpr, tpr)

    # 最適なしきい値の選定
    optimal_idx = np.argmax(tpr - fpr)
    optimal_threshold = thresholds[optimal_idx]

model.fit(X_train, y=y_train.to_numpy()) にコメントが付いている

# semi_y uses 1 for known anomalies, and 0 for unlabeled data

このコメントは「1 は荒れたと分かっているレース、0 はそれ以外のレース」という意味だ。つまり「荒れた」とだけ教えて、残りは「分からない」のまま渡していい。普通のレースがどういうものかを、こちらが定義しなくて済む。当時これは魅力的に見えたはずだ。

DeepOD の他のモデルの候補も見ている(コメントアウトで残存の DevNet) さらには、device='mps' のコメントが残っている = Mac のGPUでも試していた ことがわかる。学習速度の問題については、別のエピソードで取り上げたいと思う。

もう一つ、面白いコードが見える。場ごとに if/elif でハイパラを分岐しているではないか!?

01〜06 まで個別、07以降は全部 else で。なんと、このコードを覗くまで忘れていたが、ノーコードで24会場設定するのが嫌だったから手組みにしたのに、ハイパーパラメータの調整が場毎に必要になって、分岐して、おそらく7会場目(07は、愛知のボートレース蒲郡)で力尽きたようだ。

batch_size hidden_dims rep_dim
01 64 91,64,48 32
02 24 91,64,32,48 96
03 96 91,64,32,64 96
04 96 91,64,32,64 120
05 96 91,64,32,48 96
06 96 91,64,32,48 96
07〜24 70 91,64,32,48 96

先ほどのコードで、場01 の行にだけ # auc: 0.5430 2024-03-18 とメモが書いてあったのも、これで説明がつく。01 から順に手でコーディングしていたからだ。桐生は最初に手をつけた場で、そこにだけ数字を書き残している。住之江は else に落ちるので、メモを残していない。

他のコードにも 20次元に畳んだ特徴量を利用したのに hidden_dims='91,64,32,8' のままだったりと、そもそもいきなり24会場を相手にする事自体が無謀だったのかもしれない。DeepSAD のハイパーパラメータも複雑で、どうしたらいいかわからなかったというのが現実だろう。

異常検知 vs. 分類

状況をまとめよう。

過去レースのうち、異常レースを払戻金額のしきい値で定義し、さまざまなパターンで実験した。

異常検知は「異常は稀で、正常と質的に違う」が前提である。その異常の割合が 2〜14% で揺れているということは、前提そのものが成り立っていない。14% を「異常」と呼ぶのは、もう異常検知ではなく不均衡な二値分類になっていないか?

さらに、異常なレースだけを抽出したいのだが、AUC はコイン投げと区別がつかず、何も当たっていない。そしてそれは、「このレースは荒れる」と言うだけで、何号艇が来るかも言っていない。

DeepSAD を 2024年3月から回していたこの1ヶ月半、当たっているかを体系的に見ていなかった、さらにはハイパーパラメータを管理する仕組みもなかった。そして、この時期から LightGBM※3 を利用した分類モデルにも手を出し始める。

次のエピソードでは、今回語りきれなかったハイパーパラメータに関する話題を少し取り上げたい。

つづく。

団地ともおそうめん機械学習ML異常検知DeepSADLightGBM

関連情報

  1. 『団地ともお(6)』 小田扉本文のそうめん職人の話が入っている巻。
  2. Deep Semi-Supervised Anomaly Detection Lukas Ruff ほか本文で使った DeepSAD の原論文(ICLR 2020)。少数の「異常だと分かっているデータ」だけを教師に使う、半教師あり異常検知。
  3. LightGBM Microsoft本文で次に手を出した、決定木ベースの勾配ブースティングのライブラリ。異常検知ではなく分類として解く道具。

Amazonのアソシエイトとして、当サイトは適格販売により収入を得ています。