違和感を統計で潰す:W杯2026シミュレーターを v2.0 から v2.6 まで磨いた話

に公開

はじめに

前回の記事で、W杯2026のトーナメント結果を 10 万回モンテカルロでシミュレートする Web アプリ (https://noahsark-wc2026.pages.dev/) を公開した。48 カ国の優勝確率、各ラウンドへの到達確率、FIFA 公式の 495 シナリオ対応 3 位抜けブラケットまで動く状態で出した。

そこから 1 日、v2.0 → v2.6 まで 6 段階の統計モデル改修を連続で入れた。この記事は「動いてから、違和感を潰していく」プロセスの記録。

得点分布、ポアソン混合モデル、λ damping、リジェクションサンプリングバイアス、延長戦の fatigue factor — シミュレーション系をやっている人には全部お馴染みの論点を、自分のプロダクトの上で全部踏んだ。

想定読者:

  • 統計モデリング / シミュレーションに関心があるエンジニア
  • 個人開発でプロダクトの品質を詰めたい人
  • ポアソン分布で得点を生成する系のシミュを書いたことがある人

扱う範囲:

  • ポアソン分布の尾の厚さと under-dispersion 問題
  • λ damping による「エンタメ調整」の設計判断
  • AET / PK のリジェクションサンプリングバイアス
  • 延長戦の fatigue factor

v2.0 の状態と、最初の違和感

公開直後、自分でシミュを回してみてすぐ気になったのが「点、入りすぎじゃないか」だった。

最初の目視観察:

  • フランス 4-2、イングランド 5-1、スペイン 3-3 …… 1 試合の総得点が 5-6 点の試合が頻発
  • 実際の W 杯は平均 2.5 点/試合、5 点以上の試合はレア

測定してみた(N=21600、GS 全試合)。

GS 平均得点/試合: 3.28   (実W杯 ~2.50、+31%超過)
6点以上の試合:    14.2%  (実W杯 ~4-5%、3倍)

3 割増し。体感通り。

原因

λ(期待得点)の計算が、基本レートに対して攻撃側と守備側の倍率をかけた結果、強豪 vs 弱小で λ が打ち上がりすぎていた。クランプはあったが緩かった。

// 旧
const atk = Math.max(1.0, Math.min(2.5, atkRaw));
const def = Math.max(0.6, Math.min(1.6, defRaw));
const BASE_GOALS_GS = 2.50;
const BASE_GOALS_KO = 2.69;

v2.2: クランプと base 調整

まずシンプルに、クランプを絞って BASE_GOALS を下げる。

// 新(v2.2)
const atk = Math.max(1.0, Math.min(2.2, atkRaw));
const def = Math.max(0.7, Math.min(1.4, defRaw));
const BASE_GOALS_GS = 2.10;
const BASE_GOALS_KO = 2.45;

結果:

GS 平均: 2.58  (実 2.50、+3%)
KO 平均: 2.75  (実 2.69、+2%)
6+ 試合: 6.5%  (実 4-5%、やや多め)

平均は合った。でも まだ 13 得点合計みたいな極端な試合が時々出る。次の問題へ。


ポアソンの尾、ではなく under-dispersion

λ 上限をさらに下げても、尾が消えない。λ キャップ 4.0 → 3.2 → 2.8 と段階的に試したが、分布の形がほぼ変わらない。

これは ポアソン分布の本質的な性質だった。独立な 2 サンプルがそれぞれ高い値を引く確率は、λ が多少下がっても 0 にならない。

そもそもサッカーの得点はポアソンなのか

Dixon-Coles (1997) や Karlis-Ntzoufras (2003) が指摘してきた事実:

実サッカーの得点は、ポアソン分布より under-dispersed(分散/平均 < 1)。

ポアソンは分散 = 平均。でもサッカーでは:

  • 0-0, 1-0, 0-1 の頻度が予測より高い
  • 大量得点試合の頻度が予測より低い
  • つまり「平均値周辺にもっと集中している」

負の二項分布?と思って考え直す

最初、「負の二項分布にすれば尾が薄くなるのでは」と考えた。でも負の二項は over-dispersed(分散 > 平均)だ。サッカーに必要なのは逆方向。

選択肢は 3 つ。

  1. Conway-Maxwell-Poisson:ν > 1 で under-dispersion。理論的に美しいが実装が重い
  2. 切断ポアソン:上限を設けて正規化。結局キャップ
  3. ポアソン混合:2 つの Poisson の加重平均で分散調整

v2.3: ポアソン混合モデル

実装コストとのバランスで 3 を選んだ

// v2.3 初版(後で修正される)
function footballRand(lambda) {
  // 30% の試合は「守備的な日」で λ × 0.5
  // 70% は通常
  const effectiveL = Math.random() < 0.30 ? lambda * 0.5 : lambda;
  return poissonRand(effectiveL);
}

解釈:30% の試合は雨だったり、両チームとも引き気味だったり、カウンター合戦にならないケース。残り 70% は通常のポアソン。

結果:

GS 平均:   2.31  (λ 実質 15% 減)
6+ 試合:   4.9%  (目標域に入った)
13+ 試合:  0%    (消えた)

尾が自然に薄くなった。統計分布の形として正しいアプローチで。


キャップは邪道だった

途中、別ルートも試した。「個々のスコアに上限をつける」実装。片チーム 5 点までキャップ。これで 13 点試合は消える。でも書いた瞬間に違和感があった。これは人為的な切り捨てで、統計分布として正しくない。

スペイン 7-1 コスタリカ(2022)のような試合は実際に起きる。モデル側が勝手に「起きない」とキャップする設計は、シミュレーターとして不誠実。このキャップ実装を一度捨てて、ポアソン混合に切り替えた。結果として分布は綺麗に、かつ統計的に健全に収まった。


λ damping:統計的忠実度とエンタメのトレードオフ

v2.3 で得点分布は落ち着いた。でも次の違和感が出てきた。

目視観察:

  • フランス 8-0 ブラジル(KO)
  • フランス 6-0 ブラジル(別シミュ)
  • ブラジルが軒並み 0-3, 0-4 で負ける

ブラジルが伝統強豪なのに、現代的なパフォーマンスとしては主観的に弱すぎに見えた。

原因:stratified atk/def とデータの一致

シミュレーターは 48 カ国それぞれに 対戦相手の強度別(強豪 / 弱小)に分離した atk/def ステータスを持っている。

BRA: { atk: { s: 1.4, w: 1.8 }, def: { s: 1.8, w: 0.4 } }
FRA: { atk: { s: 2.6, w: 3.4 }, def: { s: 0.4, w: 0.9 } }

s = vs 強豪、w = vs 弱小。ブラジルは近年「強豪相手だと守備が脆い(def.s = 1.8、失点多い)」データになっている。これは 2024-25 年のブラジルが実際に ARG に 1-4 で 2 回負けているなど、データ的には正確

でも体感的には「ブラジルが 0-8 で負ける」は違和感がある。強豪同士の試合は現実には引き締まる — 数値化できない「格の意識」みたいな要素がある。

どうするか

選択肢:

  1. データ側を歪める:ブラジルの def を手動補正 → データ駆動シミュの意味を失う
  2. レーティング重みを上げる:Elo を強く信用 → 他の問題が再発
  3. λ の差だけ縮める:合計 λ は維持、極端な格差だけ平均に引き寄せる

3 を選んだ。

// v2.3 追加(後に 15% に緩和される)
const avgL = (l1 + l2) / 2;
const dampening = 0.30;
l1 = l1 * (1 - dampening) + avgL * dampening;
l2 = l2 * (1 - dampening) + avgL * dampening;

合計期待得点は不変(平均に引き寄せるだけなので)。差だけが 30% 縮む。

効果

FRA vs BRA の λ:

Before: 2.59 - 0.72  (λ 比 3.6:1)
After:  2.31 - 1.00  (λ 比 2.3:1)

統計的に起こりうる大敗は残しつつ、「ブラジルが 0-8 で崩壊」は激減する。


コメントが嘘をついていた話

ここで一度、自分のコードを読み直してレビューする時間を取った。すると footballRand() のコメントと実装が矛盾していることに気づいた。

// v2.3 初版
// 期待値を保持しつつ尾を薄くする
function footballRand(lambda) {
  const effectiveL = Math.random() < 0.30 ? lambda * 0.5 : lambda;
  return poissonRand(effectiveL);
}

コメントは "preserving the mean" と書いてある。でも実装を展開すると:

E[\text{effectiveL}] = 0.30 \times 0.5\lambda + 0.70 \times \lambda = 0.85\lambda

15% 平均が下がっている。動作としては BASE_GOALS を上げて補正していたので観測値は正しい。でもコメントは嘘。

後日これを読む人が footballRand を理解するコストが高すぎる。実装を mean-preserving 版に書き直した。

// v2.4 版(mean-preserving)
function footballRand(lambda) {
  // 0.35 × 0.5λ + 0.65 × 1.269λ = 1.0 × λ (mean-preserving)
  const effectiveL = Math.random() < 0.35 ? lambda * 0.5 : lambda * 1.269;
  return poissonRand(effectiveL);
}

E[L] = λ になった。BASE_GOALS は 2.10 / 2.30 に再調整(mixture での減衰がなくなった分)。

damping 30% も強すぎた

同時にもう一つ、damping 30% が統計的に強すぎたことにも気づいた。2014 年のブラジル 1-7 ドイツのような大敗は現実に起きる。30% の引き寄せは、そういう統計的に正しい blowout を系統的に抑制している。

damping を 15% に下げて、コメントで設計意図を明示した。

// Entertainment-tuned damping: pulls extreme λ gaps slightly toward the mean.
// Real football statistically permits 0-5 blowouts (2014 BRA 1-7 GER exists),
// but naive stratified data overpredicts them. 15% damping softens these
// outliers without suppressing them entirely. Intentional deviation from
// pure statistical fidelity for UX. Set dampening = 0.0 to disable.
const dampening = 0.15;

「統計的に正しくない」ことを明示的に書く。これが地味に重要だった。将来この値に触る人が、なぜこの値なのかを理解できる。コメントに「entertainment-tuned」と書くような設計判断の透明化は、多分 damping の値そのものより価値がある。


AET で決まりすぎ問題

ここまでで得点分布は綺麗になった。次の違和感:延長戦まで行った試合が延長で決着しすぎる。実際の W 杯では、延長まで行ったら PK まで行く確率の方が高いはず。

測定:

AET 発生率:        15.3%  (実W杯 35-50%、低い)
AET のうち PK 行き: 40.7%  (実W杯 50-70%、低い)
AET 決着:          59.3%  (実W杯 30-50%、高い)

3 指標とも現実と逆方向。

原因:リジェクションサンプリングの bias

このシミュレーターは勝敗を事前決定している。スコアは「勝者が勝つような組み合わせ」を探しに行くリジェクションサンプリングで生成する設計。

// 旧実装(簡略化)
const MAX_ATTEMPTS = stage === 'ko' ? 4 : 10;
while (attempts < MAX_ATTEMPTS) {
  s1 = footballRand(l1);
  s2 = footballRand(l2);
  if (winnerScore > loserScore) break;
  attempts++;
}
// 規定時間でつかなければ AET
if (stage === 'ko' && stillTied) {
  let etAttempts = 0;
  while (etAttempts < 5) {
    et1 = footballRand(etL1);
    et2 = footballRand(etL2);
    if (winnerET > loserET) break;
    etAttempts++;
  }
  // それでもつかなければ PK
}

これが悪さをしている:

  • KO で 4 回リトライ → 規定時間で決着する確率が人為的に上がる → AET が発生しにくい
  • AET で 5 回リトライ → 延長で決着する確率が人為的に上がる → PK に行かない

v2.5: リトライ削減

// KO regulation: 4 → 2
const MAX_ATTEMPTS = stage === 'ko' ? 2 : 10;

// AET: 5 → 2 (実質1回リトライ)
let et1 = footballRand(etL1);
let et2 = footballRand(etL2);
if (winnerET <= loserET) {
  // retry once
  et1 = footballRand(etL1);
  et2 = footballRand(etL2);
}

結果:

AET 発生率:        33.1%  (実W杯 35-50%、やや下限だが OK)
AET のうち PK:     61.6%  (実W杯 50-70%、真ん中)
AET 決着:          38.4%  (実W杯 30-50%、真ん中)

3 指標がそれぞれ現実的な範囲に着地。

リジェクションバイアスの残存について

これは完全解決ではない。KO で 2 回リトライは残っていて、勝者側のスコア分布がわずかに右シフトしている可能性がある。完全解決するなら「勝者の λ を条件付きで上げて 1 回だけサンプル」という方式が筋。epsilon = |ratingP1 - 0.5| × EPSILON_SCALE を計算して、winnerL = l × (1+ε)loserL = l × (1-ε) の形にする。次サイクルで対応予定。


延長戦で点差が開きすぎ問題

v2.5 で AET / PK のバランスは取れた。でもまた次の違和感。オランダ 4-1 AET、スペイン 5-1 AET のような試合がちらほら出る。30 分の延長戦で 3 点差は不自然。

原因:時間縮小だけ、疲労が考慮されていない

旧実装:

const etL1 = l1 / 3;  // 30/90 = 1/3 の時間
const etL2 = l2 / 3;

時間は確かに 1/3。でも延長戦は 選手が疲弊している。歴史的データで、延長戦の単位時間得点率は 規定時間の 70-80%

v2.6: fatigue factor

// v2.6: λ × (30/90) × 0.75 = λ × 0.25
const etL1 = l1 * 0.25;
const etL2 = l2 * 0.25;

結果(AET で決着した試合の点差分布):

点差 比率 判定
1 点差 84.0% ✅ 延長戦の定番
2 点差 13.8% ✅ たまにはある
3 点差 1.9% ✅ レアケース
4+ 点差 0.3% ✅ ほぼなし

「オランダ 4-1 AET」は 0.3% の超レアケース扱いに。現実的な延長戦の感覚になった。


v2.6 最終スペック

改修累計の結果:

指標 実W杯 v2.0 v2.6
GS 平均得点 2.50 3.28 2.50
KO 平均得点 2.69 3.10 2.43
6+点試合 4-5% 14.2% 6.3%
13+点試合 ~0% 時々発生 0%
AET 発生率 35-50% 15.3% 33.1%
AET のうち PK 50-70% 40.7% 67.0%
AET 1点差決着 高頻度 - 84.0%

ほとんどの指標が歴史的 W 杯の範囲内に収まった。日本の優勝確率は v2.0 の 1.35% → v2.6 の 1.4% とほぼ変わらず。これは 勝敗と得点を分離した設計のおかげ。

勝敗とスコアの分離

シミュレーターの設計で一番効いた決断はこれだった。

  • 勝敗 は Elo / Opta レーティングで決まる
  • スコアの数値 は atk / def と λ 計算が決める
  • 両者は独立したパイプライン

この分離のおかげで、今回のチューニングが統計指標(優勝確率)を壊さずに済んだ。もしスコアと勝敗を同じロジックで決めていたら、チューニングするたびに全部の数字が動いて収拾がつかなくなっていた。

この設計判断は前回の記事の時点で入れていたもので、v2.0 を公開してから 6 段階チューニングをかけても優勝確率が維持されたのは、この前提があったから。


使った統計モデリング要素のまとめ

振り返って、今回の改修で使った概念を整理:

  1. 層別化 atk/def:対戦相手の強度別にパラメータを分ける。Elo 1700-1900 で smoothstep 補間
  2. ポアソン混合分布:35% が守備的試合(λ × 0.5)、65% が通常(λ × 1.269)、E[L] = λ 保存
  3. λ damping:極端な λ 差を 15% だけ平均に引き寄せる。意図的な統計的忠実度からの逸脱、コメントで明示
  4. リジェクションサンプリング削減:KO は 2 回、AET は 2 回まで。決定論的な勝敗への収束バイアスを抑制
  5. Fatigue factor:AET の λ に 0.75 の係数。時間縮小だけでなく選手疲労も考慮

残課題 と 次のサイクル

次サイクルの着手順:

  1. リジェクションサンプリングバイアスの完全解決:現在の「勝者が勝つまでリトライ」方式を、「勝者の λ を条件付きで上げて 1 回だけサンプル」方式に置換する。ε を rating gap から計算し、winnerL = l × (1+ε), loserL = l × (1-ε) の形にする。EPSILON_SCALE のキャリブレーションが必要
  2. PK の決定論性の緩和:現在 PK は rating gap で 0.72〜0.84 の範囲で勝率決定、事実上の決定論。±5% 程度の幅に抑えるのが統計とエンタメの妥協点
  3. Web Worker 化:10 万回の Monte Carlo で UI がブロックする問題。W 杯開幕でアクセス急増する前に着手予定

おわりに

シミュレーターは「動く」のと「リアルに感じる」のはまったく別の話だった。v2.0 は動いていたが、使ってみると違和感の塊だった。そこから v2.6 まで 6 段階の改修を経て、やっと「W 杯を見ている感覚」に近づいた。

統計的に正しいだけでもダメ、エンタメ振りすぎてもダメ、その間の妥協点を測定値と判断の両方で詰めていくプロセスだった。コメントに「entertainment-tuned」と書くような、設計判断の透明化が実は一番大事だったのかもしれない。

シミュレーター、試してみてください:
https://noahsark-wc2026.pages.dev/

次の記事では、未解決のリジェクションバイアスと PK 決定論を叩いた話を書く予定。


英語版

この記事の英語版を Medium に公開しました。内容は少し再構成してあり、「統計的忠実度と体感的リアルさ」の緊張関係という concept 寄りの切り口 で書いています:

When Your Model Is Right and the Answer Is Wrong: Lessons From a World Cup Simulator


続編

公開しました:独立ポアソンの壁を bivariate Poisson で破った話 — W杯2026シミュレーター v2.6 から v2.9.9 まで

前記事で予告した rejection sampling バイアスの完全解決、加えて参考文献に挙げていた Karlis-Ntzoufras (2003) の bivariate Poisson を実装するところまで行った記録。19 指標中 17 を実データ ±2pp 以内に収めた。


参考文献

  • Dixon, M. J., & Coles, S. G. (1997). Modelling association football scores and inefficiencies in the football betting market. Journal of the Royal Statistical Society: Series C.
  • Karlis, D., & Ntzoufras, I. (2003). Analysis of sports data by using bivariate Poisson models. Journal of the Royal Statistical Society: Series D.

Discussion