Featured image of post コードドクター【Strategy】潜伏性自己免疫疾患〜競馬予想エンジンの臓器分離術〜

コードドクター【Strategy】潜伏性自己免疫疾患〜競馬予想エンジンの臓器分離術〜

往診

僕の的中率は嘘をつかない——はずだった。

神崎遼一、34歳。前職は大手シンクタンクの統計アナリスト。回帰分析、ベイズ推定、主成分分析。10年間、数字だけが友人だった。2年前に「自分の手でものを作りたい」と一念発起してIT企業に転職し、独学で Perl を覚えた。

動機は不純だ。競馬予想の自動化。

5種類のアルゴリズムを組み上げた。過去走データ重視、馬場状態重視、オッズ変動追跡、血統指数、騎手相性。ハッシュのディスパッチテーブルで整理して、なかなかの的中率を叩き出していた。統計屋の面目躍如。会社の同僚にも自慢した。

しかし先月、6つ目のアルゴリズム——パドック映像スコア——を追加した瞬間、すべてが崩れた。

馬場分析の的中率が70%から23%に暴落。血統指数も奇妙な値を返すようになった。コード自体は整理してあるはずなのに、新しいロジックを追加しただけで、なぜ まったく関係ないロジック まで壊れるのか。

土曜の午後。僕は書斎のデスクで頭を抱えていた。壁には先月まで誇らしげだったオッズ変動のグラフ。デュアルモニタの左にスプレッドシート、右にターミナル。ターミナルには、何度実行しても「23%」と表示される残酷な数字。

インターホンが鳴った。

配達かと思ってドアを開けると、黒い鞄を持った無表情の男がいた。年齢は30代だろうか。白衣は着ていないが、どこか医者を思わせる佇まい。その横に、柔和な笑顔の女性。

「……どちら様ですか?」

横にいた方が一歩前に出た。

「大丈夫ですよ、ここはコード診療所です……あ、いえ、往診ですね。ナナコと申します。こちらがコードドクターです」

コード診療所? 往診? 僕は困惑したが、助手のナナコと名乗った女性の説明を聞くうちに、どうやら「コードの不具合を診る」専門家らしいと理解した。呼んだ覚えはないのだが——。

「あの、僕は依頼してないんですが……」

男が口を開いた。短く、抑揚のない声。

「コードが悲鳴を上げていた」

意味がわからない。しかし、僕はこの2週間、誰にも相談できずに一人で格闘していた。怪しい。常識的に考えれば怪しい。しかし、「コードが悲鳴を上げていた」という一言が、妙に引っかかった。この2週間、僕のコードは確かに悲鳴を上げていたからだ。藁にもすがる思いで二人を書斎に通した。

男——コードドクターと呼ばれた人物——は部屋に入るなり、壁に貼ったオッズ変動のグラフを一瞥した。……ように見えた。実際にはその視線はグラフの横のモニタ、ターミナルの画面に向いていたのかもしれない。判別がつかないほどの一瞬だった。

そして、僕のデスクの前に無言で座った。

「先生、まずは患者さん——あ、神崎さんのお話を聞いてからにしましょう」

ナナコがそう言ったが、ドクターは構わずモニタを見ていた。

「あの……僕のコードのバグを直してくれるんですか?」

「コードを見せろ」

本当に一言だった。

触診

僕はおそるおそる HorsePredictor.pm を開いた。5種類の予想アルゴリズムをハッシュのディスパッチテーブルで管理したモジュールだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
my %strategies = (
    past_data => sub ($race_data) {
        my $score = 0;
        for my $result ($race_data->{past_results}->@*) {
            my $w = $weight_cache->{$result->{course}} //= 1.0;
            $score += $result->{finish} * $w;
        }
        push @recent_results, $score;
        return $score / scalar($race_data->{past_results}->@*);
    },

    track_condition => sub ($race_data) {
        my %track_weights = (good => 1.0, yielding => 0.8,
                             soft => 0.6, heavy => 0.4);
        my $base = $track_weights{ $race_data->{track} } // 0.5;
        $weight_cache->{track_adj} = $base;
        return $base * $race_data->{horse_rating};
    },

    # ... 他3つのアルゴリズム ...

    paddock_score => sub ($race_data) {
        my $visual = $race_data->{paddock_visual} // 50;
        # 自分用に初期化
        $weight_cache = { paddock => $visual / 100 };
        push @recent_results, $visual;
        return $visual / 100;
    },
);

ドクターがスクロールを止めた。一瞬、沈黙。

「……悪くない」

意外だった。てっきり酷評されると身構えていた。

ナナコも少し驚いたような顔をしていた——ように、僕には見えた。

ドクターがさらにスクロールを続けた。匿名サブルーチンの中身を一つずつ追っていく。指がマウスホイールの上で止まった。

表情が変わった。眉間にかすかな皺。

「……いや。これは 免疫疾患 だ」

「免疫……? テストは通ってるんですが」

ドクターがモニタの一点を指差した。ファイルの冒頭付近、モジュールスコープの変数宣言。

1
2
my $weight_cache = {};
my @recent_results = ();

「ここだ。 共有汚染

僕には何のことかわからなかった。

ナナコが微笑んで口を開いた。

「一つの冷蔵庫をみんなで使っているような状態です。誰かが牛乳を入れ替えると、お隣のおかずの味まで変わってしまう——そんな感じですね」

冷蔵庫? 僕は自分のコードを見直した。$weight_cache はモジュールスコープの変数で、すべてのアルゴリズムから参照されている。そして paddock_score の中では——。

1
$weight_cache = { paddock => $visual / 100 };

リファレンスごと差し替えている。全部消える。他のアルゴリズムが書き込んだ track_adjbloodline も。

「でも、ディスパッチテーブルで整理したはずなんです。キーで分けてるし……」

ドクターは振り返りもせず、短く言った。

「表面だけだ。 中身は繋がっている

ナナコが続けた。

「ディスパッチテーブルは住所録みたいなものです。誰がどこに住んでいるかは整理されていますけど、壁がない——全員が一つの大部屋で暮らしているんですよ」

住所録はあるけど壁がない。その比喩が、統計屋の脳に一つの概念を呼び起こした。

「つまり……各変量の 独立性が確保されていない ってことですか?」

ナナコが一瞬目を見開いた。

「……はい。まさにそういうことです」

ドクターが微かに頷いた——気がした。

診断

ドクターがキーボードに手を伸ばした。ターミナルに短いコマンドを打ち込み、テスト結果を表示させた。

1
2
3
4
5
6
7
# paddock_score 実行前
track_condition: 80
jockey_compat:   0.958   (bloodline: 0.69 を参照)

# paddock_score 実行後
track_condition: 80      (自前で再計算するので復帰)
jockey_compat:   0.82    (bloodline キャッシュ消失 → 0)

「見ろ」

ドクターが指したのは jockey_compat の値だった。paddock_score を実行しただけで、騎手相性の計算結果が変わっている。$weight_cache のリファレンスが差し替えられ、bloodline のキャッシュが消失したからだ。

「自己免疫疾患だ。臓器を足すたびに、体が壊れる」

ナナコが補足した。

「表面的には健康体なんです。定期検診——つまりテスト——でも異常は見つかりにくい。でも体内では臓器同士が不正な血管で繋がっていて、新しい臓器を移植するたびに、免疫系が暴走するんです」

僕は、自分の的中率暴落の原因をようやく理解した。パドック映像スコアを追加したこと自体が悪いのではない。追加した瞬間に、共有変数を通じて 既存のすべてのアルゴリズムの計算結果が汚染された のだ。

「処方は」

ドクターが立ち上がった。

臓器分離

外科手術

ドクターが黙々とキーボードを叩き始めた。

最初に PredictionStrategy というファイルを作った。中身を見て拍子抜けした——定義しているのは「predict を持て」、それだけだ。

1
2
3
4
5
6
7
package PredictionStrategy;
use v5.36;
use Moo::Role;

requires 'predict';

1;

「予想する。それだけだ」

ナナコが僕に向き直った。

「それぞれのアルゴリズムが、同じ約束事—— predict メソッド——を持てばいいんです。中身は自由ですよ」

……待て。これ、僕が知っている概念だ。

「あ……これ、統計で言う 推定量のインターフェース と同じですね。推定方法は違っても、推定値を返すところは共通……」

言い終わる前にドクターは次のファイルを作っていた。最初の具象クラスとして Strategy::TrackCondition

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package Strategy::TrackCondition;
use v5.36;
use Moo;
with 'PredictionStrategy';

my %TRACK_WEIGHTS = (good => 1.0, yielding => 0.8,
                     soft => 0.6, heavy => 0.4);

sub predict ($self, $race_data) {
    my $base = $TRACK_WEIGHTS{ $race_data->{track} } // 0.5;
    return $base * $race_data->{horse_rating};
}

1;

「隔離完了」

僕は画面を凝視した。$weight_cache がどこにもない。@recent_results も。このクラスは外部の共有変数に一切依存していない。独立した臓器だ。

ナナコが微笑んだ。

「各アルゴリズムが自分専用の冷蔵庫を持つようになりました。もう隣の人のおかずの味は変わりませんよ」

冷蔵庫の比喩が、今度ははっきりと腹に落ちた。

「これなら僕にもできます」

患者が直々にStrategyクラスを書く

気がつくと、僕の手が動いていた。2つ目のアルゴリズム、Strategy::PastData を自分で書き始めていた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package Strategy::PastData;
use v5.36;
use Moo;
with 'PredictionStrategy';

has weight_cache => (is => 'rw', default => sub { {} });

sub predict ($self, $race_data) {
    my $score = 0;
    for my $result ($race_data->{past_results}->@*) {
        my $w = $self->weight_cache->{$result->{course}} //= 1.0;
        $score += $result->{finish} * $w;
    }
    return $score / scalar($race_data->{past_results}->@*);
}

1;

$weight_cache$self->weight_cache に変えた。インスタンス変数だ。他のクラスからは触れない。

ドクターが僕のコードを見た。一瞬手を止めて、画面を見つめた。

「……使える」

短い評価だった。でも、僕には十分だった。

残りのアルゴリズム——OddsMovementBloodlineJockeyCompat——も同じパターンで書き進めた。そして最後にドクターが作ったのが PredictionEngine。すべてを束ねるコンテキストだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package PredictionEngine;
use v5.36;
use Moo;

has strategies => (
    is      => 'ro',
    default => sub { {} },
);

sub add_strategy ($self, $name, $strategy) {
    die "$name does not implement PredictionStrategy"
        unless $strategy->does('PredictionStrategy');
    $self->strategies->{$name} = $strategy;
    return $self;
}

sub predict ($self, $race_data, $strategy_name) {
    my $strategy = $self->strategies->{$strategy_name}
        or die "Unknown strategy: $strategy_name";
    return $strategy->predict($race_data);
}

1;

add_strategy で登録して、predict で実行する——レースのたびに戦略を差し替えられるんですね」

僕が言うと、ナナコが嬉しそうに頷いた。

「その通りです。 Strategy パターン ——アルゴリズムの交換可能性。これが今回の処方箋です」

術後経過

テストを実行した。

1
2
3
ok 1 - track_condition is not affected by paddock_score
ok 2 - jockey_compat is not affected by paddock_score
ok 3 - bloodline is not affected by paddock_score

全アルゴリズムが独立して正しい結果を返した。馬場分析の的中率が70%に復帰。

僕は興奮していた。

「あの——今追加しようとしていた6つ目、パドック映像スコアのロジック。今すぐ試していいですか?」

「やってみろ」

僕は Strategy::PaddockScore を新規作成した。既存のファイルには一切触れない。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package Strategy::PaddockScore;
use v5.36;
use Moo;
with 'PredictionStrategy';

sub predict ($self, $race_data) {
    my $visual = $race_data->{paddock_visual} // 50;
    return $visual / 100;
}

1;

PredictionEngine に登録。テスト実行。

1
2
3
4
ok 1 - bloodline unchanged after adding paddock_score strategy
ok 2 - jockey_compat unchanged after adding paddock_score strategy
ok 3 - track_condition unchanged after adding paddock_score strategy
ok 4 - new paddock_score strategy works

「一つも壊れない……! 本当に独立してる……」

独立変数の重要性。統計学で嫌というほど学んだはずのことだった。多重共線性を排除し、各変量の独立性を確保する。基本中の基本だ。

なのに、自分のコードではそれを見落としていた。

ドクターが帰り支度を始めた。鞄を手に取り——そして、僕のデスクに置いてあったペンを一本、無造作に胸ポケットに差し込んだ。

一瞬、「あ、僕のペンを……」と思った。だが違った。よく見ると、僕が使っているのとは違うメーカーのペンだ。どうやら診察中にドクターが無意識に置いていたらしい。

ナナコが淡々と言った。

「すみません、先生よくペンを置き忘れるんです」

それだけのことだった。それだけのことなのに、あの無造作な手の動きに——なんだろう、不思議な温かみのようなものを感じたのは、きっと気のせいだ。

ドクターが玄関に向かった。振り返らずに、一言。

「感謝は、このコードに」

ナナコが深くお辞儀をして続いた。

「お大事にしてくださいね。次のアルゴリズムを追加するときも、もう怖くないはずです」

二人が去った後、僕はデスクに戻った。ターミナルには的中率のサマリが表示されている。6つのアルゴリズムが、互いに干渉することなく、それぞれの精度を保っている。

そして画面の隅に、小さく表示された文字。

1
All tests passed.

独立性の確保は、統計でも、コードでも、同じだった。

僕が一番基本的なことを忘れていたのだ。


処方箋まとめ

症状適用すべき経過観察
条件分岐(if-elsif)でアルゴリズムを切り替えている
ディスパッチテーブルで管理しているが共有状態がある
アルゴリズム追加時にメインモジュールの修正が必要
分岐が2〜3個で今後増える見込みがない
各アルゴリズムが完全に独立している(共有状態なし)

治療のステップ

  1. Strategy ロールの定義: Moo::Role で共通インターフェース(predict 等)を定義する
  2. 具象クラスの分離: 各アルゴリズムを独立したクラスに移植し、共有変数への依存を排除する
  3. Context クラスの作成: Strategy を登録・実行する PredictionEngine を構築する
  4. 独立性のテスト: 各 Strategy を個別にテストし、他の Strategy の実行結果に影響されないことを確認する
  5. 拡張テスト: 新しい Strategy を追加しても既存の結果が変わらないことを検証する

助手より

統計の知識をお持ちの神崎さんなら、きっとすぐに馴染めると思います。「各変量の独立性を確保する」——神崎さんご自身がおっしゃった言葉が、Strategy パターンの本質そのものです。新しいアルゴリズムを思いついたら、クラスを一つ作るだけ。もう既存のコードが壊れることはありません。先生はああいう方ですが、神崎さんのコードを「使える」と評価したのは、本当に珍しいことなんですよ。

——ナナコ

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。