Lean Baseball

No Engineering, No Baseball.

野球をAIでいい感じにしようぜ — ファンも解説者もコーチも、すべての野球人にAI Agentを提供する野望のはじまり

野球解説AI AgentをWebのプロダクトとして完成させました!

本ブログは日本語版の解説記事です。

英語版は別途Mediumで「Bringing AI to Every Dugout — The Ambitious Quest to Build an AI Agent for All of Baseball」というタイトル*1で公開中です。

なお、スクリーンショットは海外版優先のため英語となっております。

日本語版のデモ動画はこちらをどうぞ。

youtu.be

日本語版は近日中にブログで公開する or Xでつぶやきます。

TL;DR

野球に関わるすべての人のバディとして、AI Agentが傍らにいる未来を実現したい。

以下はオリジナルコンテンツ(英語版と同等)をお楽しみください&最後に日本語版のみの結びを入れています。

野球をAIでいい感じにしようぜ — ファンも解説者もコーチも、すべての野球人にAI Agentを提供する野望のはじまり

デモ

youtu.be

筆者について

中川伸一(shinyorke)。本業は株式会社LayerX AI Workforce事業部のSRE。個人のライフワークとして野球データ分析に取り組んでおり、自称「野球AI Engineer」。

2012年からセイバーメトリクスを元にした野球データ分析を開始。2018年には、スポーツ科学とITで「全てのアスリートにイノベーションを届ける(INNOVATIONS FOR ALL ATHLETES)」ベンチャー企業「ネクストベース」に一人目のEngineer/CTOとして参画し、約1年半にわたりNPB/MLB向けのシステム開発に従事した。現在もライフワークとして野球データ基盤の構築・運用、成績予測といったチャレンジを継続中。本記事で紹介する「The Scouter 3」は、その集大成となるプロジェクトである。

Links: GitHub / X (Twitter) / Blog / LinkedIn

はじめに

MLB(メジャーリーグベースボール)の試合を観戦する際、「この投手と打者の過去の対戦成績はどうだったか」「もし大谷翔平と佐々木朗希が対戦したらどうなるか」といった疑問が浮かぶことがある。Baseball SavantやFangraphsといった既存の統計サイトには膨大なデータが公開されているが、セイバーメトリクスの知識がなければ数字の羅列から意味を読み取ることは難しい。

そこで、LLM(大規模言語モデル)をAI Agentとして活用し、Statcastのトラッキングデータを自然言語で解説するWebアプリケーション「The Scouter 3」を開発した。


本記事の概要と読みどころ

The Scouter 3は、選手検索・AIレポート生成・実績対戦の深掘り分析・仮想対戦予測の4つの機能を日英バイリンガルで提供するWebアプリケーションである。中でも仮想対戦予測は、対戦したことがない選手同士、あるいは「投手・大谷翔平 vs 打者・大谷翔平」のように実際の野球では絶対に実現し得ないカードをLLMでシミュレーションする、AI Agentだからこそ可能な機能である。

本記事は、以下の4章構成となっている。

対象読者 内容
1章 The Scouter 3 とは何か 野球ファン・野球関係者 AI Agentが提供する4つの分析体験の詳細。野球データに馴染みのある方であれば、技術的な知識がなくても楽しめる内容
2章 PoCからプロダクトへ プロダクトマネージャー・エンジニア Marimo PoCの限界、プロトタイプ駆動開発、Design Docによる設計管理など、プロダクト化の判断と過程
3章 技術スタック エンジニア Next.js 15 + FastAPI + Vertex AI + GCP Cloud Runのアーキテクチャ詳細、CI/CD、インフラ構成
4章 得られた知見 プロダクトマネージャー・エンジニア PoCからプロダクトへの移行で得た3つの実践的な教訓

筆者(shinyorke)は、このプロジェクトを「ちょっとした個人開発」ではなく、本気で野球の見方を変えるプロダクトとして開発している。現時点の対象ユーザーはセイバーメトリクスに馴染みのある中〜上級ファン・アナリストだが、その先には、中継で裏付けデータを即座に引き出したい解説者、次の打席の作戦を練るコーチなど、野球に関わるすべての人の傍らにAI Agentがいる未来を見据えている。6週間で27本のPRをマージし、約35,200行・202ファイルのコードベースを構築した。その規模感と開発プロセスの詳細は2章の末尾で紹介する。この野望の全体像は「おわりに」で改めて語る。


1. The Scouter 3 とは何か

1.1 プロジェクトの目的

The Scouter 3は、MLBのStatcast/Baseball Savantデータを活用したAI駆動の野球分析Webアプリケーションである。ユーザーに見えるサイト名が「The Scouter 3」。3はストライク、3アウトの野球メタファーである。

プロジェクト名は「Betts」。Mookie Betts選手に由来する。データの土台となるMLB統計データAPI基盤は「Zobrist」という名前で、こちらはBen Zobrist選手に由来する1。両者はいずれもMLBを代表するユーティリティ・プレーヤーであり、どこのポジションでも高水準のパフォーマンスを発揮する万能性を持つ。プロジェクト名にユーティリティ・プレーヤーの名前を冠するのは、「あらゆる分析シーンで使える万能な基盤」でありたいという筆者のリスペクトと願いの表れである。

対象ユーザーは、wOBA、xBA、BABIP、ISO、K%、BB%といったアドバンスド指標2を理解し、日常的にMLBデータを分析する中〜上級ファン・アナリストを想定している。

1.2 4つのページで構成される分析体験

The Scouter 3は4つのページで構成され、選手検索から詳細分析まで段階的に深掘りできる構造になっている。

ページ 概要
Top(選手検索) 日本語・英語での選手名検索 + サマリー表示
選手詳細 AIレポート + 統計テーブル + チャート可視化
AI対戦分析 実績対戦データの深掘り + AI対戦レポート
仮想対戦予測 未対戦カードのLLMシミュレーション

Topページ(選手検索)

日本語・英語での検索が可能

最初に目にするページである。日本語(漢字・ひらがな・カタカナ)でも英語でも選手名を入力して検索できる。たとえば「大谷翔平」と入力すれば、LLMが「Ohtani, Shohei」に対応づけ、基本成績のサマリーカードを表示する。

検索後のサマリーと導線

サマリーカードには打率・出塁率・長打率・OPS・本塁打数・三振数・四球数の7指標を掲載し、そこから詳細ページに遷移する導線を提供する。

選手詳細ページ

選手の1シーズンを俯瞰するページである。画面の上部にはAIレポートが表示される。5つのセクション(選手スタイル、強みと課題、対右傾向、対左傾向、総合評価)に分かれており、Server-Sent Events(SSE)によるストリーミング配信でセクション単位で逐次表示される。LLMの応答を待つ間もユーザーは最初のセクションから読み始めることができるため、体感的な待ち時間が短い。

統計・可視化は2つのタブで切り替える。

タブ 内容
統計 基本成績・SABR指標・球種別成績・打球傾向の4カテゴリ
可視化 Plotly.jsによるインタラクティブチャート

可視化タブでは、投手なら球種分布(円グラフ)・球速推移・投球位置・リリースポイント・打球位置の8種のチャートを4サブタブで表示する。

投手の表示例

打者ならBarrel分布・打球種別分布・打球位置の4種を2サブタブで表示する。いずれも投打左右(対右/対左/全体)のフィルタで絞り込みが可能である。

打者の表示例

AI対戦分析ページ

実績のある対戦カードを深掘りするページである。選手のトラッキングデータから対戦相手を抽出し、対戦打数・打率・本塁打・三振・四球・1打席あたりの投球数をテーブルで一覧表示する。ページネーションとソートに対応している。

特定の対戦相手を選択すると、以下の情報が表示される。

セクション 内容
対戦成績サマリー カウンティング成績 + 率成績の2テーブル
球種別成績 球種ごとの投球数・打率・K%など
トラッキング可視化 球速推移・投球位置・打球位置のチャート
球種別比較 打率 vs 三振率の比較チャート
AI対戦レポート 総合分析・球種別分析・推奨戦略の3セクション

AI対戦レポートは単なるデータの要約ではない。「この打者はスライダーの打率が低いため、カウントを稼ぐ場面ではスライダーを中心に組み立てるのが有効」といった具体的な戦略提案が含まれる点が特徴である。

仮想対戦予測ページ

仮想対戦モード(山本由伸VS大谷翔平)

The Scouter 3の最も独創的な機能であり、AI Agentだからこそ実現できる分析体験である。対戦したことがない選手同士、あるいは対戦しようがない選手同士の仮想対戦をLLMでシミュレーションする。

たとえば、以下のようなユースケースが考えられる。

ユースケース
リーグ違い・対戦機会の少ない組み合わせ NL所属の大谷翔平は、AL所属の村上宗隆や岡本和真と年に何回対戦するだろうか? インターリーグでしか実現しないカードを、データに基づいて予測する
同一チーム所属の選手同士 大谷翔平 vs 山本由伸。同じドジャースに所属する以上、公式戦で対戦することはない。しかし、両者のStatcastデータから「もし対戦したら」を予測できる
同一人物の投手 vs 打者 投手・大谷翔平は、打者・大谷翔平を抑えられるか? 二刀流選手ならではの究極の仮想対戦。投手としてのトラッキングデータと打者としてのトラッキングデータの両方をLLMに渡し、球種別の有利度や予想打率を算出する

特に「大谷 vs 大谷」の対戦は、実際の野球では絶対に実現し得ない。しかし、Statcastには投手・大谷翔平の全投球データと打者・大谷翔平の全打席データが別々に記録されている。

大谷翔平VS大谷翔平の仮想対戦

AI Agentはこの両方のデータを読み解き、「大谷のSweeper(被打率.180)に対して、打者・大谷のブレーキングボール打率は.260」といった具体的な根拠をもとに対戦を評論する。

人間の解説者では主観的な推測に留まりがちなこの種の問いに、データドリブンな分析を提供できる点が、AI Agentならではの価値である。

画面には以下の要素が表示される。

UI要素 説明
Tug-of-Warバー 投手(青)vs 打者(赤)の有利度を横棒グラフで表示。55%以上で投手有利、45%以下で打者有利、その間は五分
予測サマリー 予想打率・三振確率・四球確率・長打確率の4指標(予測幅付き)
球種別有利度バー 球種ごとの有利度。「Sweeper: 75%投手有利」「4-Seam: 55%打者有利」のような粒度
AI分析レポート 総合予測・球種別攻略可能性・推奨戦略・相手の脅威・予想シナリオの5セクション

モード選択機能も用意している。「真剣分析」モードでは客観的・プロフェッショナルな分析、「エンタメ」モードではドラマチックでファン向けの語り口に切り替わる。同じデータであっても、読み手の目的に合わせた表現を選べる設計である。

エンタメモードの場合

1.3 日英バイリンガル対応

The Scouter 3は日本語と英語の両方に対応している。ヘッダーのJA/ENボタンで言語を切り替えると、UIラベルだけでなく、AIレポートの生成言語も連動して切り替わる。108以上のi18nキーを独自実装の軽量i18nシステムで管理しており、外部ライブラリに依存しない構成としている。

日本語をデフォルト言語とした理由は、主要ターゲットが日本在住のMLBファン・アナリストであるためである。英語は補助言語として提供し、海外のユーザーや英語記事を書く際にも活用できるようにしている。


2. PoCからプロダクトへ — 開発の経緯

2.1 Marimoで始めたPoC

プロジェクトの出発点は、PythonのリアクティブノートブックフレームワークであるMarimoを使ったPoCであった。Google Cloud Vertex AI(Gemini 2.5 Flash)とPolars、Plotlyを組み合わせ、以下の機能を検証した。

  • 自然言語によるMLB統計データ検索
  • LLMによる日本語クエリの解析と選手名マッチング
  • 打者・投手の統計データの可視化
  • 選手レポートの自動生成(AIレポート)
  • 対戦傾向分析と対戦レポート生成
  • シーズン選択(2023-2025)

PoCはインタラクティブなウェブインターフェースとして動作し、開発者自身が分析に使う分には十分な機能を備えていた。Cloud Run上にOAuth2 Proxyを併設してデプロイし、betts-poc.shinyorke.infoのカスタムドメインで限定公開した。Terraformでインフラを管理し、本番環境の運用まで一通り経験できた。

しかし、PoCにはいくつかの課題があった。

2.2 PoCの限界と、プロダクト化の動機

PoCを運用する中で、以下の4つの課題が明確になった。

課題 詳細
UIの制約 Marimoはノートブック環境であり、モバイル対応が不十分。球場でスマホから確認するユースケースに対応できなかった。タブ切り替えやアコーディオンUIの実現も困難
パフォーマンス サーバーサイドレンダリング主体のため、クライアントサイドのフィルタリングやソートのたびにサーバーとの往復が発生
i18nの困難さ PoC段階では日本語UIのみ。Marimoのアーキテクチャ上、多言語対応の後付けは容易ではなかった
スケーラビリティ 1つのCloud Runサービスに全機能を詰め込んでおり、フロントエンドとバックエンドの独立スケーリングが不可能

これらの課題を踏まえ、Next.js + FastAPIでフロントエンドとバックエンドを分離した本格的なWebアプリケーションとしてフルリビルドすることを決定した。PoCで検証済みのLLMロジック(選手名マッチング、レポート生成、仮想対戦予測)は、Python側に再実装してFastAPIのバックエンドに移植する方針とした。

2.3 プロトタイプ駆動の開発プロセス

フルリビルドにあたり、最初にHTML/CSS/JSのプロトタイプ(prototype)を作成した。Tailwind CSS 3.x(CDN版)とPlotly.js 2.35をビルド不要で直接利用し、4ページすべてのUI/UXをブラウザ上で検証した。

この「prototype → frontend」の開発フローには明確なルールを設けた。

  • prototypeをUI/UXデザイン検証の「正」とする
  • デザイン変更はまずprototypeで検証し、確定後にfrontend(Next.js)へ実装する
  • prototypeのHTML構造・CSSクラス名・レスポンシブ設計をfrontend実装の仕様として扱う
  • カラーパレット(CSS変数)・ブレークポイントはfrontendのTailwind設定に反映する
  • チャート定義はfrontendのReactコンポーネントと1:1対応を維持する

prototypeにはPlaywrightによるE2Eテスト(121テスト)を整備しており、変更のたびにデグレがないことを自動検証する体制を構築した。こうすることで、「デザインの正」としてのprototypeの信頼性を維持しつつ、Next.jsへの移植における仕様のブレを防止した。

2.4 Design Docによる設計管理

本プロジェクトでは、各機能・各フェーズの設計判断をDesign Docとして文書化した。ファイル命名規則(ISSUE番号_YYYYMMDD_説明.md)を定め、GitHub Issueと紐づけて管理している。以下に主要なDesign Docを挙げる。

  • #11 LLM Backend API設計 — 8つのAPIエンドポイント(ヘルスチェック、選手リスト、日本語名検索、トラッキングデータ、選手サマリー、選手AIレポート、対戦AIレポート、仮想対戦予測)のスキーマ・データフロー・SSE設計・キャッシュ戦略を定義
  • #12 GCPインフラ設計 — VPC、Cloud Run 2サービス構成、IAM、Artifact Registry、Cloud NAT等のインフラ全体像を設計
  • #16 Backend実装計画 — FastAPIの4層アーキテクチャ(ルーティング・サービス・クライアント・モデル)と段階的実装フェーズを定義
  • #17 Frontend実装計画 — Next.js 15 App Routerのファイル構成、Server/Client Componentの使い分け、API Routeプロキシ設計を定義
  • #26 i18n実装 — LanguageProvider(React Context)による多言語切替の配線設計
  • #27 Terraformインフラ構築 — VPC/IAM/Artifact Registry/Workload Identity等の基盤リソース構築
  • #28 CI/CDパイプライン — GitHub ActionsによるGAR push + Terraform deployフロー
  • #29 Cloud Runデプロイ — OAuth2 Proxyサイドカー構成、Domain Mapping、本番運用構成

このように設計を文書化しておくことで、PoCの設計判断を振り返り、プロダクトとして何を変更し何を踏襲するかを明確にできた。特にPoCとの差分(たとえば、Zobrist APIの呼び出し元をFrontendからBackendに変更した判断など)は、Design Docの「RFPからの変更点」セクションで明示的に記録している。

2.5 開発規模

PoCからプロダクトへの再構築に要した期間は約6週間(2026年2月14日〜3月29日)。その間のリポジトリ活動を数字でまとめる。

指標 数値
コミット数 45
マージ済みPR数 27
管理ファイル数 202ファイル
コード総量 約35,200行

平均すると約1.5日に1本のPRをマージしたペースである。コンポーネント別の内訳は以下の通り。

コンポーネント 行数 備考
frontend/ ~17,300行 最大。lockfile含む
docs/ ~5,000行 RFP・Design Doc等
backend/ ~5,000行 Python + lockfile
prototype/ ~3,900行 HTML/CSS/JS
terraform/ ~960行 HCL
.github/ ~630行 CI/CDワークフロー
scripts/ ~46行 pre-commitフック

lockfileを除いた実質コード量は概算で2万行前後となる。ドキュメント(docs/)が5,000行を占めるのは、RFPと8本のDesign Docを丁寧に書いた結果である。

なお、実装にはClaude Codeを全面的に活用し、コードレビューにはOpenAI CodexおよびGitHub Copilotを併用した。AIコーディングツールを開発プロセスに組み込むことで、一人開発でも6週間という短期間でこの規模のプロダクトを形にできた。

6週間の開発は、生活の中に溶け込む形で進んだ。平日は仕事が終わった後、馴染みの居酒屋でお酒を飲みながらプロトタイプの設計と実装を行った。毎週末は地元のスターバックスで、シロップ少なめのアーモンドミルクのカフェモカを片手にフロントエンドとバックエンドの実装・テストに集中した。そしてWBCのアメリカ代表戦 — 日本時間では朝から昼にかけての放送である — を見ながらデバッグに勤しむ日もあった。野球を見ながら野球のプロダクトを作る、これ以上のドッグフーディングはない。


3. 技術スタック

ここからは、エンジニアリングの観点からThe Scouter 3の技術スタックを詳述する。

3.1 全体アーキテクチャ

2つのCloud Runサービスを同一VPC内にデプロイし、BackendへのアクセスをVPC内部に限定する構成である。Frontendは外部からのアクセスを許可し、OAuth2 Proxyで認証を行う。Backendはingress: internal-onlyでVPC内部からのアクセスのみ受け付ける。

3.2 Frontend — Next.js 15 + TypeScript

フレームワーク選定の背景 — PoCのMarimoからの移行先として、Next.js 15(App Router)を採用した。理由は、将来のServer Component化によるSSR/SEO最適化を見据えたこと、App RouterによるServer/Client Componentの柔軟な使い分け、そしてReactエコシステムの成熟度である。

主要なライブラリ構成は以下の通りである。

  • Next.js 15(App Router) + React 18 + TypeScript — ルーティング・データフェッチ・レンダリング
  • Tailwind CSS v4 + @tailwindcss/typography — スタイリング。v4ではCSS内の@themeディレクティブで設定し、tailwind.config.tsは不要
  • Plotly.js(react-plotly.js + plotly.js-basic-dist-min) — チャート描画。scatter/pieを含み本プロジェクトの要件を満たす基本ディストリビューション(約1MB)を使用。将来の3Dチャート等の要件に備え、フルバンドル(約3.5MB)への切り替えも考慮済み
  • react-markdown — AIレポートのMarkdownレンダリング
  • 独自i18n実装lib/i18n.ts + JSON辞書 + LanguageProvider) — 外部ライブラリ不使用。4ページ分のラベル(108キー以上)に対して外部ライブラリは過剰と判断

API Routeプロキシの設計 — FrontendからBackendへの通信は、Next.js API Route(/app/api/v1/[...path]/route.ts)を透過プロキシとして経由する単一パターンに統一した。ブラウザは/api/v1/*を叩き、API RouteがBackendのCloud RunサービスURLに転送する。現時点での中核部分を以下に示す。

// frontend/src/app/api/v1/[...path]/route.ts
export async function GET(
  request: Request,
  { params }: { params: Promise<{ path: string[] }> },
) {
  const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
  const { path } = await params;
  const url = new URL(request.url);
  const targetUrl = `${backendUrl}/api/v1/${path.join("/")}${url.search}`;

  const response = await fetch(targetUrl);
  return new Response(response.body, {
    status: response.status,
    headers: {
      "Content-Type":
        response.headers.get("Content-Type") ?? "application/json",
    },
  });
}

export async function POST(
  request: Request,
  { params }: { params: Promise<{ path: string[] }> },
) {
  const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
  const { path } = await params;
  const url = new URL(request.url);
  const targetUrl = `${backendUrl}/api/v1/${path.join("/")}${url.search}`;

  const body = await request.text();
  const response = await fetch(targetUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body,
  });

  const contentType =
    response.headers.get("Content-Type") ?? "application/json";
  return new Response(response.body, {
    status: response.status,
    headers: { "Content-Type": contentType },
  });
}

見ての通り、プロキシの中核はこれだけである。catch-allルート([...path])でパスを受け取り、BackendのURLに転送し、response.body(ReadableStream)をそのまま返す。SSEレスポンス(text/event-stream)もContent-Typeヘッダーごと透過するため、ストリーミング対応のための特別な処理は一切不要である。

この設計には以下の利点がある。

利点 説明
セキュリティ ブラウザからBackendの内部URLが見えない
CORS不要 同一オリジン通信のため、CORSの設定が不要
統一的なAPI呼び出し Client/Server Componentのどちらからでも同じ/api/v1/*パスで呼び出し可能

一方、トレードオフとして、全リクエストがAPI Routeを経由するためレイテンシが1ホップ分増加する点がある。また、Cloud RunのリクエストタイムアウトはSSEの長時間接続に影響し得る。現時点ではCloud Runのタイムアウトを60秒に設定しており、LLMのレポート生成がこの範囲に収まることを確認済みである。Vercel等のサーバーレス環境ではストリーミングのタイムアウト制約がより厳しいケースがあるが、Cloud Runではこの問題は発生していない。

Server ComponentとClient Componentの使い分け — 設計上はページコンポーネント(page.tsx)をServer Componentとし、初期データを取得してClient Componentにpropsとして渡す構成を想定した。ただし、i18n対応(LanguageProvider = React Context)の導入により、全ページが"use client"を宣言したClient Componentとして動作している。

Server Component化は将来の最適化として位置づけている。なお、Plotly.jsのチャートコンポーネントはモジュールimport時にwindowを参照するため、next/dynamic + ssr: falseでSSRを無効化している。

3.3 Backend — Python + FastAPI

フレームワーク選定の背景 — LLMとの連携にVertex AI(Google Cloud)のPython SDKを利用するため、PythonベースのWebフレームワークとしてFastAPIを採用した。PoCのMarimoアプリ内のLLMロジックをFastAPIに移植する形で再実装している。ただし、CLAUDE.mdの制約としてPoCコードの直接importは禁止しており、ロジックを理解した上での再実装(移植)が方針である。

主要なライブラリ構成は以下の通りである。

  • Python 3.14+ + FastAPI + Pydantic v2 — APIフレームワーク。CamelModel(alias_generator)でcamelCase↔snake_caseの自動変換を実現
  • google-cloud-aiplatform(Vertex AI / Gemini 2.5 Flash) — LLM推論
  • httpx — Zobrist APIへの非同期HTTPクライアント
  • google-cloud-storage — GCSキャッシュバケットへのアクセス
  • Polars — データ処理(PoCから踏襲)
  • パッケージマネージャ: uv — lockfile(uv.lock)による依存管理

4層アーキテクチャ — Backendは以下の4層で構成している。

  1. ルーティング層api/) — HTTPリクエスト/レスポンスの受け渡しのみを担当。ビジネスロジックは持たない
  2. サービス層services/) — ビジネスロジック。選手名マッチング、レポート生成、対戦予測を実装
  3. クライアント層clients/) — 外部サービスとの通信。Vertex AI、Zobrist API、GCS/Localキャッシュ
  4. モデル層models/) — Pydanticスキーマ定義。リクエスト・レスポンス・内部型

さらに、core/に横断的な基盤コード(LLMClient Protocol、SSEヘルパー)、config/に環境変数管理を配置している。os.getenvsettings.py内のみで使用し、他のモジュールからの直接使用を禁止するルールを設けている。これにより環境変数の管理ポイントが一箇所に集約される。

DI(依存性注入)パターン — FastAPIのlifespanイベントで共有リソースを初期化し、app.stateに格納する。実装は以下の4フェーズで段階的に進めた。

Phase 初期化するリソース 解禁されるエンドポイント
A Settings health
B + CacheBackend + ZobristClient players, tracking, summary
C + LLMClient search, report/player
D 全結合 report/matchup, predict/virtual

この順序には意図がある。Phase Aでヘルスチェックが通ればデプロイの正常性を確認でき、Phase Bで外部API連携(Zobrist API)を検証し、Phase Cで初めてLLMを導入する。依存関係の少ないものから順に積み上げることで、各フェーズの完了時点で動作するエンドポイントが増え、段階的にデプロイ・検証できる設計とした。

8つのAPIエンドポイント — Backendは8つのエンドポイントを提供しており、レスポンス形式で大きく2種類に分かれる。

カテゴリ エンドポイント数 レスポンス形式 役割
データ取得系 5 JSON ヘルスチェック、選手リスト取得、日本語名検索、トラッキングデータ取得、選手サマリー
AIレポート生成系 3 SSE 選手AIレポート、対戦分析AIレポート、仮想対戦予測

データ取得系はZobrist APIへのプロキシやLLMによる選手名マッチングなど、即時レスポンスを返すJSON APIである。一方、AIレポート生成系の3エンドポイントはSSE(Server-Sent Events)でセクション単位のストリーミング配信を行う。PoCでも同様の方式を採用しており、LLMの応答を待たずに先行セクションを表示するUXを実現する。

キャッシュ戦略 — ヘルスチェック以外の全エンドポイントにcacheパラメータを設けている。デフォルトはtrueでGCSバケットのキャッシュを参照し、falseの場合はキャッシュを無視して再処理する。LLM呼び出しのコスト削減とレスポンス速度の改善が目的である。キャッシュバケットは7日間のライフサイクルポリシーで自動削除される。

3.4 インフラストラクチャ — GCP + Terraform

Infrastructure as Code — すべてのインフラはTerraform(1.14+)で宣言的に管理している。フラット構成(モジュール分割なし)を採用し、サービス数が少ない段階ではシンプルさを優先した。Google Cloud Provider ~> 6.0を使用している。

主要なインフラ構成要素は以下の通りである。

カテゴリ コンポーネント 用途
コンピュート Cloud Run × 2 Frontend + Backend
ネットワーク VPC + Subnet 内部通信の隔離(10.0.0.0/24
ネットワーク Serverless VPC Access Connector Cloud Run ↔ VPC接続(10.8.0.0/28
ネットワーク Cloud Router + Cloud NAT Backend → 外部API通信用
認証 OAuth2 Proxy v7.6.0 エンドユーザーGoogle OAuth認証
認証 Workload Identity Federation GitHub Actions → GCPのkeyless認証
IAM Service Account × 4 frontend-sa / backend-sa / deploy-sa / admin-sa
AI/ML Vertex AI(Gemini 2.5 Flash) LLM推論
ストレージ GCS キャッシュストア(7日lifecycle)
シークレット Secret Manager APIキー・OAuthシークレット管理
コンテナ Artifact Registry Dockerイメージ保管(最新10保持)

VPCとネットワーク設計 — BackendからのEgress通信経路は宛先に応じて異なる。SubnetのPrivate Google Accessを有効化することで、この使い分けを実現している。

通信先 経路 備考
Vertex AI(*.googleapis.com Private Google Access Googleプライベートネットワーク内で完結
GCS キャッシュ Private Google Access 同上
Zobrist API Cloud NAT経由 外部REST API。パブリックインターネットを通過

OAuth2 Proxyサイドカー構成 — PoCと同じ方式をそのまま踏襲した。Cloud Run v2 APIのマルチコンテナ機能を利用し、以下の2コンテナ構成としている。

コンテナ ロール ポート CPU Memory
OAuth2 Proxy v7.6.0 Ingress(認証ゲートウェイ) 8080 0.5 256Mi
Next.js App サイドカー(アプリ本体) 3000 0.5 512Mi

OAuth2 ProxyはSecret Managerからクライアントシークレットを読み取り、Google OAuth 2.0で認証を行う。Terraformでの定義を抜粋する(シークレット関連の環境変数は省略)。

# terraform/cloud_run.tf(抜粋)
resource "google_cloud_run_v2_service" "frontend" {
  name     = "betts-frontend"
  location = var.gcp_region

  template {
    service_account = google_service_account.frontend.email
    timeout         = "60s"

    # OAuth2 Proxy container (ingress, port 8080)
    containers {
      name  = "oauth2-proxy"
      image = "${local.gar_url}/oauth2-proxy:v7.6.0-amd64"
      ports { container_port = 8080 }
      env { name = "OAUTH2_PROXY_UPSTREAMS"; value = "http://localhost:3000" }
      # ... Secret Manager参照によるOAuth設定(省略)
      resources { limits = { cpu = "0.5"; memory = "256Mi" } }
    }

    # Next.js container (sidecar, port 3000)
    containers {
      name  = "betts-frontend"
      image = "${local.gar_url}/betts-frontend:${var.frontend_image_tag}"
      env { name = "BACKEND_URL"; value = google_cloud_run_v2_service.backend.uri }
      resources { limits = { cpu = "0.5"; memory = "512Mi" } }
      depends_on = ["oauth2-proxy"]  # サイドカーはIngressコンテナの後に起動
    }

    vpc_access {
      connector = google_vpc_access_connector.connector.id
      egress    = "ALL_TRAFFIC"
    }
  }

  ingress = "INGRESS_TRAFFIC_ALL"
}

ポイントは、OAuth2 Proxyがportsブロックを持つIngressコンテナであり、Next.jsアプリはportsブロックを持たないサイドカーである点。OAuth2 ProxyのUPSTREAMShttp://localhost:3000に向けることで、同一インスタンス内のlocalhostでNext.jsに転送する。BACKEND_URLにはBackendのCloud Run URIが動的に注入され、VPC内部通信でBackendに到達する。

3.5 CI/CD — GitHub Actions + Terraform

ブランチ戦略feature/#123-xxx → main → prdの3層ブランチモデルを採用している。mainへのマージはSquash merge、main → prdへのマージはMerge commit(リリース単位でのリバートを可能にするため)と使い分けている。

CIパイプライン — コンポーネントごとにワークフローを分離している。

  • backend-ci.yml — ruff check → ruff format --check → ty check → pytest → Docker build → GAR push
  • frontend-ci.yml — ESLint → Next.js build → vitest → Docker build → GAR push
  • terraform-ci.yml — fmt → validate → plan(PRコメント出力)

CDパイプライン — prdブランチへのmerge commitをトリガーとする。CDパイプラインの動作は以下の3ステップで構成される。

  1. 変更検出frontend/backend/の差分有無を判定
  2. タグ解決 — 変更ありのコンポーネントはGARから最新のGit SHA短縮タグを取得、変更なしのコンポーネントはterraform stateの現在値を維持
  3. デプロイterraform applyでCloud Runを更新。tf stateと実態の整合性を保証

Workload Identity Federation — GitHub ActionsからGCPへの認証にはkeyless認証を採用している。サービスアカウントキーを生成せず、OIDCトークンで一時的な認証を行う。

SA 用途 主要なIAMロール
deploy-sa GAR push専用 roles/artifactregistry.writer
admin-sa Terraform plan/apply roles/run.admin, roles/compute.networkAdmin

pre-commitフック — コミット時に変更されたコンポーネントのCIチェックを自動実行するpre-commitフックを整備している。

変更対象 実行される検証
backend/ ruff check → ruff format --check → ty check → pytest
frontend/ ESLint → Next.js build → vitest
terraform/ terraform fmt -check → terraform validate
prototype/ Playwright E2Eテスト(121テスト)

3.6 基調カラーとデザインシステム

技術スタックの話題から少し外れるが、The Scouter 3のビジュアルアイデンティティについても触れておきたい。基調カラーとしてフォレストティール(#115e59、Tailwind teal-800を採用した。既存の野球データサイトとの差別化を意図した選択であると同時に、アスレチックスのチームカラー(グリーン&ゴールド)の色味も意識している。筆者はマイケル・ルイス著『マネー・ボール』をきっかけにセイバーメトリクスと出会い、そのままアスレチックス・ファンになった。データ分析の原点にあるチームの色がプロダクトに宿っているのは、個人的に気に入っているポイントである。

サイト 基調カラー
Fangraphs
Baseball Reference
Baseball Savant ネイビー
The Scouter 3 フォレストティール

ヘッダー、フッター、テーブルヘッダーなどの主要UI要素にフォレストティールを適用し、統一感のあるブランドカラーを形成している。投手を青(#2563eb)、打者を赤(#dc2626)で表現するカラーコードは、Tug-of-WarバーやAI対戦レポートなど、アプリケーション全体で一貫して使用している。

カラーパレットはCSS変数で一元管理し、prototypeのcss/style.cssをfrontendのTailwind設定に1:1で反映する運用としている。


4. PoCからプロダクトへの移行で得た知見

4.1 PoCのコードは捨てる、設計は活かす

PoCのMarimoアプリのコードを直接importすることは、プロジェクトルールとして禁止した。Marimoに固有のUIフレームワークの制約やモノリシックな構成をそのまま引きずることを避けるためである。一方で、PoCで検証した設計判断(SSEによるストリーミング配信、GCSキャッシュ、OAuth2 Proxyサイドカー構成など)はDesign Docを通じてプロダクトに引き継いだ。「コードは捨てる、設計は活かす」というアプローチが、クリーンな再実装につながった。

4.2 prototypeを「正」とするルールの有効性

HTML/CSS/JSの静的プロトタイプをデザインの「正」として位置づけ、Next.jsの実装はprototypeに準拠するルールを設けた。これにより、デザインの議論と実装の議論を分離でき、「UIの仕様はprototypeを見ればわかる」という単一の参照先が確立された。Playwrightテストによるprototypeの品質保証も、この仕組みを支える重要な要素であった。

4.3 Design Docの蓄積が開発速度を上げる

8本のDesign Docを作成した結果、「この設計判断はなぜこうなったのか」を後から参照できる資産が残った。特に、PoCとの差分を明示的に記録しておくことで、新しい実装が既存のPoC設計を意図的に踏襲しているのか、意図的に変更しているのかが一目瞭然になる。将来のメンテナンスや新規メンバーのオンボーディングにおいて、大きな価値を持つと考えている。


おわりに

The Scouter 3は、MLBのStatcastデータとLLMを組み合わせた野球解説AI Agentである。自然言語での選手検索、AIによるレポート生成、実績対戦の深掘り分析、そして未対戦カードの仮想シミュレーションという4つの分析体験を、日英バイリンガルで提供する。

技術的には、Next.js 15 + FastAPI + Vertex AI(Gemini 2.5 Flash)のスタック上に、OAuth2 Proxyサイドカー構成のCloud Run 2サービスアーキテクチャを構築した。TerraformによるInfrastructure as Code、GitHub Actions CI/CDパイプライン、pre-commitフックによる品質保証、Design Docによる設計管理など、プロダクション品質のWebアプリケーションに求められる周辺プラクティスも整備した。

PoCからプロダクトへの移行においては、「コードは捨てる、設計は活かす」「prototypeを正とするルール」「Design Docの蓄積」の3つが開発を円滑に進める鍵となった。

野球データとAIの組み合わせは、まだ多くの可能性を秘めている。今後の拡張として、以下のような方向性を考えている。

  • 自然言語クエリ検索 — 「去年30本以上打った左打者は?」のような問いかけに対し、データベースから該当選手を検索して回答する機能
  • リアルタイム試合データとの連携 — 進行中の試合のトラッキングデータを取り込み、リアルタイムで対戦分析やレポートを更新する仕組み
  • 予測モデルの高度化 — 現在は球速・打球速度・投球数と傾向を中心に分析しているが、ボールの変化量(横変化・縦変化)やピッチトンネル(異なる球種が打者の手元までどれだけ同じ軌道を通るか)といったより高度な概念を予測モデルに組み込むことで、「なぜこの球種が打ちにくいのか」をより精緻に解説できるようになる

そして、The Scouter 3が届けたい価値は野球ファンの楽しみだけに留まらない。AIによるデータドリブンな分析は、人間の判断を補強する第三者的な視点を提供できる。野球に関わるすべての人のバディとして、AI Agentが傍らにいる未来を実現したい。

  • 解説者 — 試合中継で「この対戦カードの過去の傾向は?」と問われたとき、裏付けデータとしてAIレポートを即座に参照する
  • 実況アナウンサー — 次の打席に向けて、対戦カードの背景や球種別の有利不利を瞬時に把握し、視聴者に伝える
  • コーチ — 選手に「この投手のSweeper、お前は打率.150だから初球は見逃せ」と伝える際の根拠資料として活用する
  • ファン — 球場で試合を見ながら、目の前の対戦をデータで読み解き、野球の楽しみをもう一段深くする

The Scouter 3は、その第一歩である。

この取り組みに共感していただける方、一緒に野球×AIの未来を作りたいという方がいれば、ぜひ気軽に声をかけてほしい。個人・企業を問わず、shinyorke(X (Twitter) / LinkedIn)へのご連絡をお待ちしている。

結び

ここまでお読みいただいた皆様、本当にありがとうございます。

この「The Scouter 3」、プロジェクト「Betts」は「ファンタジーベースボールに『テクノロジーとエンジニアリングを駆使して』勝ちたいんや」という思いで2012年からスタートした野球データ分析、野球エンジニア改め野球AIエンジニアとして一つの完成形ができたなと感じています。

じゃあこれでこの営みはおしまいか?と言われるとおしまいじゃない、むしろ新しいスタートだと思っています。

せっかく作った野球AI Agent、まずはアウトカムを出していくのとまだまだやりたいことがあるので「AIをバディにして進める」Bet AIをいい感じにやっていきです。

shinyorke先生の次回作にご期待ください、ではでは。


  1. Zobrist APIは筆者が個人開発しているMLBデータAPI基盤。Cloud Run + Cloud Pub/Subによるサーバレスなマイクロサービス構成で、Terraformで管理している。詳細は登壇資料「Cloud RunとCloud PubSubでサーバレスなデータ基盤2024 with Terraform」およびブログ記事「仕事も個人開発も周りがドン引きするまでガチでエンジニアをやっていきましょ - デブサミ2023登壇報告」を参照。
  2. いずれもBaseball SavantやFangraphsで用いられるセイバーメトリクス系の統計指標。詳細はFangraphs Glossary等を参照。試合前のスカウティング、選手評価、対戦傾向の考察、SNS投稿のネタ探しといったシーンでの利用を念頭に置いた。

*1:日本語タイトルをそのまま訳したもの、この文脈で「野球」は「Dugout」と訳すといいらしいです。へえーー。

「打者大谷は投手大谷からホームランを打てるか?」をAIはどう判断するか? - Geminiで作る野球解説AI Agent

菅野投手がまさかのロッキーズ行きで驚いている人です.

以前のブログで「野球の解説を生成AIにしてもらう時代がもうすぐやってくる」なんて紹介をしましたが, 実用できそうかつ, 2/26に「Baseball Play Study〜野球解説者AI Agent」という場所でお話させていただくものができたので紹介します.

ざっくりいうと,

「打者大谷は投手大谷からホームランを打てるか?」的な疑問をLLMとセイバーメトリクスでどうにかするアプリを作った.

というお話になります.

結論を待てない方はすぐ下の動画を見てください(倍速再生で良いので).

youtu.be

TL;DR

セイバーメトリクスの知識 × LLM(Gemini)で「AIと人間の役割分担」を実現した仮想対戦予測機能, めっちゃ使えそうな予感.

やったことは以下のとおりです.

  • 根拠となる数字はセイバーメトリクスを元にした数理モデル,対戦シナリオの解釈と分析はLLM(Gemini)に委ねるハイブリッドな設計と実装.
  • LLMに渡したデータを開示する「根拠データ表示」で透明性を担保.
  • 真剣分析/エンタメの2モードで,プロンプトのトーン指示だけで出力を切り替える.

これで「一定の根拠に基づく野球のAI解説」をどうにかできる所まで持ってきました的な話です.

こんな事ができた

論より証拠で例を見ましょう(≒いきなりオチです野球的には).

  • 投手オオタニサンVS打者オオタニサン
  • 山本由伸VSオオタニサン

打者大谷は投手大谷を打てるか?

AI曰く, 「投手オオタニサンが打者オオタニサンを打ち取る可能性が僅かに高い(打者が打つ確率が49.4%)」そうです.

AIが出してきた傾向

# AI分析レポート

## 総合予測(高)

1. 投手有利(確信度:中)

   投手大谷は高い奪三振能力と低い被長打率を誇り、特に打者大谷が苦手とする球種(Sweeper, Curveball)で高い効果を発揮するため、投手有利と予測します。

   ただし、打者大谷のSinker/Cutter攻略の可能性と一発の破壊力は無視できません。

## 球種別の攻略可能性

- (※データ記載なし)

## 推奨戦略

1. SinkerとCutterの積極的な狙い打ち  
   投手大谷のSinkerとCutterは被打率が非常に高く、奪三振率が低い傾向があります。特にCutterは打者大谷の得意球種でもあるため、甘く入った場合は積極的にスイングし、長打を狙うべきです。

2. Slider・Sweeper・Curveballの見極め  
   これらは非常に危険な球種であり、特にCurveballは打者大谷の最大の弱点です。ボール球は徹底して見送り、ストライクゾーンに来てもファウルで粘るなど、安易なスイングは避けるべきです。

3. カウントに応じたアプローチ  
   投手大谷は与四球率が低く、四球を選ぶことは容易ではありません。早いカウントでSinkerやCutterが来れば積極的に打ちに行き、追い込まれた場合はファストボール系で粘りつつ、変化球は捨てていくアプローチが有効です。

## 相手の脅威

圧倒的な奪三振能力が最大の脅威です。奪三振率35.4%は非常に高く、特にSlider(K%55.6%)、Sweeper(K%37.0%)、Curveball(K%69.2%)は打者大谷にとって大きな脅威となります。

また、与四球率4.3%と極めて低く、打者大谷が四球を選ぶ展開は期待しにくいでしょう。

さらに、被ISO 0.086という低い被長打率は、長打をほとんど許さないことを示しており、打者大谷の長打力を封じ込める可能性があります。

加えて、打者大谷が苦手とするSweeperやCurveballが投手大谷の得意球種と重なっている点も大きなリスク要因です。

完全に自分の感想(オピニオン)ですが, 「オオタニサンはオオタニサンのカーブとスイーパー打てないだろうな」ってのはわかります, スイーパーは投げないかもですが.

山本由伸は打者大谷と対戦するとどうなる?

投手オオタニサンより抑える可能性がありそう(山本由伸が53.4%の確率で打者オオタニサンを抑える予想).

Yoshinobu優秀(投手オオタニサンより)

評価はこんな感じです.

# AI分析レポート

## 総合予測(高)

1. 投手有利(確信度:中)

   山本投手の圧倒的な奪三振能力と被長打率の低さが、大谷選手の強力な打撃を上回る可能性が高いと判断します。

   ただし、大谷選手の一発の脅威は常に存在します。

## 球種別の攻略可能性

- 4-Seam Fastball(山本投手の主要球種)  
  山本投手は投球数991球と最も多く、被打率0.195、奪三振率31.6%と非常に効果的です。  
  大谷選手は打率0.265、三振率28.0%と極端に悪くはありませんが、山本投手の質の高い速球は打ち崩すのが容易ではなく、高めで空振りを奪われるリスクがあります。

- Split-Finger(山本投手の決め球)  
  山本投手は投球数709球、被打率0.132、奪三振率45.7%と最も強力な決め球です。  
  大谷選手は打率0.278、三振率16.7%と対一般的なSplitには悪くないものの、山本投手の落差と精度は別格で、三振リスクを大きく高めます。

- Curveball(山本投手の決め球)  
  山本投手は投球数491球、被打率0.196、奪三振率32.1%と高水準。  
  大谷選手は打率0.255ながら三振率48.9%と極端に高く、この球種は最大級の弱点となります。

- Cutter(攻略の糸口)  
  山本投手は投球数309球、被打率0.235、奪三振率11.8%と他球種より奪三振率が低め。  
  大谷選手は打率0.341、三振率19.5%と得意であり、カウント球は狙い目となります。

- Sinker(攻略の糸口)  
  山本投手は投球数209球、被打率0.244、奪三振率15.6%。  
  大谷選手は打率0.329と得意で、積極的に狙う価値があります。

- Slider(少球数だが高品質)  
  山本投手は投球数79球ながら被打率0.105、奪三振率21.1%と非常に効果的。  
  大谷選手は打率0.316と悪くありませんが、見極めが重要です。

## 推奨戦略

1. 狙い球の絞り込み  
   Split-FingerとCurveballは非常に危険なため無理に打ちにいかず、見極めを徹底します。特に低めのSplit-Fingerには手を出さない意識が重要です。

2. CutterとSinkerの積極的な狙い打ち  
   大谷選手が得意とし、山本投手の奪三振率が比較的低いCutterとSinkerを早いカウントから狙います。長打の可能性が高まります。

3. 高め4-Seam Fastballへの対応  
   山本投手は高め速球で目線を上げ、低め変化球で仕留める傾向があります。甘い高め速球は積極的に振りにいくべきです。

4. 四球の選択肢  
   ボール球には手を出さず、大谷選手の高いBB%を活かして四球で出塁することも重要な戦略です。

## 相手の脅威

山本投手の最大の脅威は圧倒的な奪三振能力です。奪三振率32.5%はリーグトップクラスであり、特にSplit-Fingerの奪三振率45.7%は驚異的です。大谷選手のK%(30.8%)を考慮すると、三振リスクは非常に高い状況です。

また、被ISO 0.101、被長打率0.282と長打をほとんど許さず、大谷選手の強打を封じる可能性があります。

さらに、4-Seam Fastball・Split-Finger・Curveballはいずれも被打率が低く、質の高い組み立てで打者を翻弄します。与四球率8.7%と制球力も優秀で、甘い球は限られます。

## 予想される対戦シナリオ

1. 投手有利(空振り三振)  
   高め4-Seam Fastballで目線を上げてカウントを整え、低めSplit-FingerやCurveballで空振り、または見逃し三振に仕留める展開が最も想定されます。

2. 打者有利(長打)  
   カウント球のCutterやSinker、甘く入った4-Seam Fastballを早いカウントで完璧に捉え、外野深くへの二塁打または本塁打を放つ展開。この場合、大谷選手の高いISOが発揮されます。

どっちの対戦シナリオも目に浮かびます.

おしながき

あらすじ

以前のブログで,Google GeminiとmarimoでMLB選手を検索・評価するAI Agentを作った話を書きました.

その後このAI Agentプロトタイプでは,

  • 選手個人のレポート出力に特化した機能を実装
  • 現実に対戦したことある選手同士の成績比較とレポート

といった機能を追加したりしました.

これらの機能の話をとあるタイミングで社内LTで話した後, こんな事を思いました.

対戦したことがない選手同士の対戦を予測できたりするんじゃね?

2026年はワールド・ベースボール・クラシック(WBC)の年でもありますし, このテーマいいんじゃないかなと.

パッと思いつくシナリオはいくつかあります.

シナリオ
違うリーグで対戦が少ない or 無い Paul Skenes (DET/AL) vs Shohei Ohtani (LAD/NL)
国際大会で同チーム 投手・山本由伸 vs 打者・大谷翔平(侍JAPAN)
ゲームじゃないとできない対決 投手・大谷翔平 vs 打者・大谷翔平

野球は太古のセイバーメトリクスの時代からデータがたくさんある, かつ今はトラッキングデータも大量にあるので予測はできそうですし, LLMでいい感じにできそうです.

という興味(と自分がこういう道具欲しい)というモチベーションでチャレンジしました.

ちなみにできることはBaseball Savantのデータとしてある範囲かつ自分が運用しているBigQueryにデータセットとしてある選手が対象(2023年以降のデータはある*1), NPBおよび昔のデータはないので「オオタニサンVS近藤健介」「山本由伸はイチローを抑えられるか」みたいなテーマはできません, あしからず.

技術スタック

技術スタックは以下のとおりです.

レイヤー 技術
デモアプリ Marimo
データ処理 Polars
可視化 Plotly
LLM Google Vertex AI (Gemini)
デプロイ Docker + Cloud Run

Marimoでのプロトタイプはこちらのブログ,Plotlyでいい感じに可視化した話はこちらのブログで詳しく解説しています.

本件はMarimoアプリを育てつつ, 昔作ったPlotly(というよりDash)のアプリを元に構築しています.

一応補足ですが, LLMはこんな感じで使っています.

  • AI AgentはすべてGemini. Gemini 2.5 Flash. これを選んだ根拠は「とりあえずシュッと使えそうだった」からです.
  • 企画・開発・デバッグはすべてClaude Codeに任せています, 恐ろしいことに自分でコード書いていません&全てSpec & Design Doc駆動開発です.

開発スタイルもLLMに全振りです.

やったこと

Claude Codeと壁打ちをし, 議論を繰り返した結果こんなアプローチになりました.

  • セイバーメトリクスの知識を元に「計算」と「LLMの解釈」を分離する設計を考える.
  • Design Docを書いてClaude Codeにひたすら実装してもらう.
  • 確率計算ロジック,LLMプロンプト,可視化,UIをVibe Codingで仕上げる.

それぞれ解説します.

設計の核心 - 計算と解釈を分離する

「オオタニサンVSオオタニサン」を実現する技術, 通称「仮想対戦モード」の肝は,

確率計算とLLM分析を明確に分離すること

でした, これは私の思想とAIとの壁打ちで決めました.

[予測を生成] ボタン押下
       │
       ├──→ 確率計算(同期・即時)
       │     ├─ 球種別有利度の計算
       │     ├─ 予想打率・三振確率の算出
       │     └─ 総合ゲージ位置の決定
       │
       └──→ LLM分析(非同期・スピナー表示)
              ├─ 5セクションのテキストレポート
              └─ 球種別の攻略シナリオ

なぜ分離したのか?ですが,

野球データの数理モデル,いわゆるセイバーメトリクスを私が把握していた

からです, LLMのトークンを消費して自由にやらせる以上に答えが求めやすいので.

出塁率,長打率,OPSといったよく知られた指標からFIP,wRAA(ちなみに今回は両方採用していない)といったマニアックな指標まで, 私は計算式と概念を理解しています.

これらの仕様をSpec/Desing Docで言語化, LLM(今回はGemini)には「このデータから対戦シナリオを描き出す」といった解釈と実装に注力してもらいました.

担当 処理内容 特徴
確率計算(Python) 球種別有利度,予想打率,三振確率,四球確率,長打確率 即座に結果が出る. 再現性がある.
LLM分析(Gemini) 総合予測,球種別攻略分析,戦略提案,対戦シナリオ 文脈を読み取った分析. 人間が読んで面白い.

「野球の知識」「セイバーメトリクスの見解」「エンジニアリングの経験」を元に, 「ここはコードで計算できる」「ここはLLMに任せたほうがいい」という線引きをしました.

以前のブログで書いた,

人間がやる解説とAIでいい感じに別けてやればいいじゃん

の思想を今回も貫きました.

確率計算ロジック

仮想対戦の確率計算では,投手と打者の球種別成績を突き合わせるアプローチを取りました, ちなみにこれはClaudeからの提案を自分がちょこっと手直ししたものになります(おそらく今後もチューニングが続くでしょう).

たとえば投手Aの「4シームの被打率」が.220で,打者Bの「4シームに対する打率」が.280だった場合,この対戦における4シームの予想打率は両者の平均.250あたりになるだろう,という考え方です.

各球種の投手有利度(0-100)を算出するコア関数はこうなります.

def _calc_pitch_advantage(pitcher_avg, batter_avg) -> float:
    """球種別の投手有利度(0-100)を計算する"""
    combined_avg = (pitcher_avg + batter_avg) / 2
    advantage = (0.250 - combined_avg) / 0.250 * 50 + 50
    return max(0, min(100, advantage))

ポイントは以下の通り.

  • 基準値.250: MLB平均打率相当*2の値を「五分五分」の基準として使う
  • 0-100スケール: 0が完全に打者有利,50が五分,100が完全に投手有利
  • 加重平均: 各球種の投球数で重み付けして総合ゲージを算出

初心者向けに具体例で説明するとこんな感じ.

[例] 4-Seam Fastball の場合

  投手の被打率: .220(投手優秀 → 打たれにくい)
  打者の打率:   .280(打者優秀 → よく打てる)

  combined_avg = (.220 + .280) / 2 = .250(ちょうどMLB平均)
  advantage = (0.250 - 0.250) / 0.250 * 50 + 50 = 50(五分五分)

[例] Slider の場合

  投手の被打率: .180(決め球として優秀)
  打者の打率:   .200(苦手球種)

  combined_avg = (.180 + .200) / 2 = .190
  advantage = (0.250 - 0.190) / 0.250 * 50 + 50 = 62(やや投手有利)

打率以外の指標も,同様に投手側と打者側の値を平均して算出します.

指標 計算方法 信頼区間
予想打率 (投手被打率 + 打者打率) / 2 ±.015
三振確率 (投手K% + 打者K%) / 2 ±3%
四球確率 (投手BB% + 打者BB%) / 2 ±2%
長打確率 (投手被ISO + 打者ISO) / 2 × 2 ±3%

たとえば,投手の被打率が.230,打者の打率が.270なら,予想打率は(.230 + .270) / 2 = .250となり,±.015を加えた.235 - .265がエラーバー付きの横棒グラフとして可視化されます.

「予想打率.235-.265」のように幅を持たせることで,「これは精密な予測ではなく,幅を持った見積もりです」という意図を視覚的に伝えています.

確率計算をいい感じに可視化したやつ.

エラーバーで信頼区間を表現. 球種別有利度は50%基準線で投手有利/打者有利を色分け , これはめちゃくちゃ見やすくていいなと思いました.

なお,正直に書いておくとこのモデルにはいくつかの限界があります.

  • 投打の左右の相性を考慮していない: 左投手vs左打者のような組み合わせの有利不利は未反映. 実装の複雑さと得られる精度向上のバランスを考慮し,現段階では見送りました
  • 球種別K%/BB%は総合ゲージに含まれない: 球種別の三振率や四球率はサンプルサイズが小さくなりがちで信頼性が低いため,現状は球種別打率のみで重み付けしています
  • サンプルサイズの問題: 投球数が極端に少ない球種(シーズン中に数球しか投げていない球種など)の成績は信頼性が低い
  • 投球のコース・打球のコースへの配慮なし: Baseball Savantのデータで十分できるのですが今回は含めていません
  • ボールの変化量, 速度変化: これもBaseball Savantのデータで十分できます, 「落差3cmのスプリットは打てないが, 2cmなら打てるかもしれない」みたいな分析は頑張ればできると思います

と言っても,現時点で球種レベルで投手と打者を突き合わせるアプローチは直感的にわかりやすく,「なぜこの予測になったのか」を説明しやすいメリットがあります.

完璧なモデルよりも解釈しやすいモデル(とプロダクトとしてのAI Agent開発)を優先した 意思決定は間違ってないなと思います, 我ながら.

加えて説明的なブログも書けているので完璧です.

LLM分析

LLMには計算で使ったのと同じ統計データをテキスト形式で渡します. プロンプトの構造はこんな感じです.

【投手データ(2025シーズン)】
## 通常統計
投手名: Gerrit Cole
被打数: 580, 被安打: 138, 被HR: 18, 奪三振: 195, ...

## セイバーメトリクス指標
被打率: .238, 被OBP: .295, K%: 33.6%, BB%: 6.2%, ...

## 球種別成績
4-Seam Fastball: 投球割合 35.2%, 被打率 .220, K% 28.5%
Sweeper: 投球割合 22.1%, 被打率 .185, K% 42.3%
...

【打者データ(2025シーズン)】
(同様の構造)

LLMには以下の5セクションでレポートを出力させます.

  1. 総合予測 - 投手有利/五分/打者有利 + 確信度(高/中/低) + 根拠
  2. 球種別の攻略可能性 - 投手の各球種が打者にどう効くか
  3. 推奨戦略 - メイン選手側(視点)へのアドバイス
  4. 相手の脅威 - 警戒すべきポイント
  5. 予想される対戦シナリオ - 典型的な打席展開

「視点」は選択した選手のポジションから自動決定されます. 投手を主役にしているなら投手視点のアドバイス,打者を主役にしているなら打者視点のアドバイスが生成されます.

確信度は「高/中/低」の3段階で,LLM自身がデータの十分性(サンプルサイズ,球種の多様性など)を判断して付与します. 確信度が低い場合は,ユーザーが結果を鵜呑みにしないための目安になります.

真剣分析 / エンタメモード

同じデータに対して,プロンプトのトーン指示だけを変えることで2つのモードを実現しています.

真剣分析モードの指示:

あなたはMLBのプロスカウトです.
客観的かつ冷静にプロフェッショナルとして分析してください.

エンタメモードの指示:

あなたはMLBの名物解説者です.
野球ファンを楽しませるように,ドラマチックでワクワクする表現で分析してください.
「夢の対決」「因縁の対決」のような盛り上げ方を意識してください.
ただし,分析の根拠はデータに基づいてください.

エンタメモードでも「分析の根拠はデータに基づいてください」という一文が入っています. 盛り上げつつもデタラメは言わないようにする,というバランスが大事です.

先程冒頭で菅野智之投手がロッキーズに移籍したという話題をしましたが, 元のオリオールズには入れ替わりで強打者ピート・アロンソ(メッツ)が加入しました.

この出会うこと無い(かもしれない)2人の対戦*3を「エンタメモード」で解説するとこうなりました(補足・架空の設定ですがデータでの根拠に基づいた推測です).

菅野VSピート・アロンソ

結果はお察しですが, エンタメモードの解説者はどうでしょう.

ちゃんと解説してくれてる

ちゃんと球種別に丁寧解説してますが, なんかエンタメ感あります.

命令口調なのは気の所為でしょうか

感想がもはやファンですw

北米の怪獣って呼んでましたっけ?w

アロンソは伊達に「北米の怪獣」と呼ばれているわけではありません。スガノが警戒すべき、アロンソの恐るべき強みを見ていきましょう!

LLMなりにあだ名を付けて盛り上げてくれたことは理解しました, エンタメ感すごい.

今回は極端な組み合わせでしたが, もっと競った相手だとそれなりに面白い結果になるかもしれません.

なお,LLMなので何らかの理由で失敗した場合(タイムアウト,レスポンス不正など)においても,

  • 確率計算によるゲージや棒グラフは正常に表示
  • 前述の通り「LLM頼り」だけでもないので分析アプリとして成立

というところでいい感じにしています.

透明性の担保

LLMが生成するレポートには必ず疑問がつきます.

本当にデータに基づいているのか?

この問題に対して,仮想対戦モードではLLMに渡したデータをそのまま開示する仕組みを入れました.

ネストされたアコーディオンの中に,投手データと打者データが格納されています. 普段は折りたたまれていますが,展開すればLLMが分析に使ったデータをすべて確認できます.

Geminiも適当に盛り上げているわけではないという証拠

菅野さんだけでなく, アロンソもちゃんとデータみてます.

こっちもちゃんと入っています

AI分析の透明性を担保することで,ユーザーが「この分析は信頼できそうだ」「ここの解釈はちょっと違うのでは」と自分で判断できるようになります.

とくに野球ファンはデータリテラシーが高い人が多いので,元データを見せることの価値は大きいです.

課題

いい感じに仮想対戦モードができたものの,実は課題は山積みです.

あえて2つに絞ると以下ですかね.

  • 計算モデルの精度
  • LLMの出力安定性

計算モデルの精度

前述の通り,左右の相性やBaseball Savantデータの活用,サンプルサイズの問題は未対応です.

現在は「解釈しやすさ」を優先していますが,

  • 左右別成績(プラトーンスプリット)の導入
  • Baseball Savantデータの活用(変化量とか)
  • サンプルサイズに応じた信頼区間の動的調整

個人的には左右別とデータ活用を優先して対応するつもりです, どっちも結果に影響するので.

LLMの出力安定性

前回のブログでも課題として挙げました.

今回もやはりLLMの出力が安定しない問題はあります, といっても前回よりマシですが...

特に仮想対戦モードでは,投手と打者の両方のデータを入力するのでプロンプトが長くなりがちで,レスポンスの品質にムラが出ることがあります.

ただし,確率計算とLLM分析を分離しているので,LLMがコケても計算結果(ゲージ・グラフ)は表示されるのでこの辺はプロダクト・PoCとしてどう設計するか?の思想でどうにか頑張ってます.

結び&今後

「打者大谷は投手大谷からホームランを打てるか?」をAIはどう判断するか? お楽しみいただけましたでしょうか?

セイバーメトリクスの知識 × LLM(Gemini)で「AIと人間の役割分担」を実現した仮想対戦予測機能, めっちゃ使えそうな予感.

これは自分でも可能性あると思いましたし, このまま本当にAIで解説やらせる未来は実現できそうと思いました.

技術ブログ的なまとめとしては,

  • 何でもかんでもLLMにやらせるんじゃなくて, 敢えてルールベースで縛ってハイブリッドでやるのはアリ(セイバーメトリクスとLLMの役割分け)
  • LLMの出力不安定はあるものだと思って設計する

これらはエンジニアリングの経験・スキルとドメイン知識(今回は野球)の総力戦でどうにかしました&きっと再現性ある学びなのでAI Agent開発の参考になれば幸いです.

ちなみに今後についてですが, 今はmarimoで作ってる野球解説AI Agentは「SPA + APIの王道なWebアプリケーションに作り変え」をします.

  • フロントエンドをTypeScript + Next.js
  • バックエンドをPython(FastAPI)
  • 引き続きCloud RunなどGoogle Cloud上でホスト

みたいな感じでお届けできればと思います, 一般公開できるかは怪しいですが...

Marimoで検証したハイブリッドアーキテクチャや可視化の設計思想はそのまま引き継ぎます.

とはいえできればMLB開幕前にはなんとかできたらと思いますので乞うご期待ください.

最後までお読みいただきありがとうございました!

関連エントリー

*1:もっと細かい話をするとレギュラーシーズンのみ. プレーオフのデータは集めていません.

*2:ちなみにこれは適当です, .250が基準値はなんとなく収まりがいいので採用.

*3:MLBファンの方は「結果は明確じゃね?」と突っ込みそうですが, これは技術ブログなので何卒お付き合いください🙏

GoとCloud Pub/SubをつかってBaseball Savantのクローラーを作ってみた

最初に言っておきます, 「野球」っていうワード以上に「エンジニアリング」なエントリーです*1.

野球方面の人は最初の雰囲気を読んで最後まで読むか判断してください, 「オオタニサン」っていうワードは出てこず「Go」「Pub/Sub」「コンテナ」とかそんなのばっかり出てきます🙏

という前置きはさておき, 最近はGoでモノを作るのが好きになってきており, 今はというと,

  • PyCon JP 2022およびデブサミでお披露目した「野球データ基盤」の刷新をGoで少しずつやる.
  • 野球データ基盤を構成するマイクロサービスのうち, Goでやったほうが効率良さそうなサービスをGoに書き換え.
  • クラウドサービスは引き続きGoogle Cloud(これはマスト)だが, マイクロサービスのワークロードはCloud Runに統一*2, 可能なら「マルチコンテナ*3」を採用してサービスを一つに統一したい.

といった事をしております, なお完成するとこんな感じになりそうな気がしています.

野球データ基盤第二弾の全体像(構想段階)

思ったよりGoで置き換えが可能なマイクロサービス多めで有ることに気が付きました.

その中でも特に「Baseball Savant(Statcast Search)からデータを収集してBigQueryに保存する」というデータ基盤らしい主要機能は現状すべて「Python + Cloud Functions」で処理しているのですが, これを「Go + Cloud Run」で丸っと書き換えを行っています.

Goで書き換え中のデータ基盤機能

作ってみた結果, GoとCloud Pub/Subの実践例として面白そうな気がしてきたのでコードを公開すると同時にブログとしてここに残そうと思います.

github.com

そんな当エントリーのお品書きはこちらとなります.

対象読者と前提知識

GoからGoogle Cloudのサービスを使ったことがある程度のリテラシーが必要となります.

cloud.google.com

pkg.go.dev

この辺の内容が理解できればこの先の話も伝わるかと思います.

なお, 基本的なGoとGoogle Cloudの説明は文脈と文章量の都合上, 必要最低限に抑えます.

やりたい事は何か

まずやりたいことですが,

  1. 毎朝9:00(JST)にクローラーを起動
  2. 前日のStatcastデータを「投手」「打者」ごとに取得
  3. 取得したデータを/YYYY-MM-DD/batter.csv/YYYY-MM-DD/pitcher.csv として保存
  4. 取得したCSVを読み込みBigQueryに保存 ※このエントリーでは扱いません

以上となります.

なおデータはこんな感じで使われます.

データの利用例

シンプルな構成で考えると「1〜4の処理を一つのプログラムとして作ってcrontabで順番に動かす」なのですが(いわゆる「モノリス」なパターン, 別にこの方法でも全然構わない), コードのメンテナンスがキツイのと「CSVだけほしい」みたいな時に自由に使えないので,

  1. 毎朝9:00(JST)にクローラーを起動 → 「trigger」マイクロサービスとして, Cloud Run Jobs*4等の定期実行*5を使って起動, 2以降の処理を起動(下図「処理イメージ」の①).
  2. 前日のStatcastデータを「投手」「打者」ごとに取得 → マイクロサービス「exporter」内でStatcast SearchのURLを組み立て, CSVとして取得(下図「処理イメージ」の②).
  3. 取得したデータを/YYYY-MM-DD/batter.csv/YYYY-MM-DD/pitcher.csv として保存 → マイクロサービス「exporter」内で取得結果をCloud Storageに保存(下図「処理イメージ」の②).
  4. 取得したCSVを読み込みBigQueryに保存 → マイクロサービス「importer」内でCSVを読み込みBigQueryにloading ※このエントリーでは扱いません&Go版は未実装.

という感じにマイクロサービスとして分けて考え, 構築することにしました*6.

処理イメージ

①のtriggerが以下のようなメッセージをPub/Sub TopicにPublishを行い,

// Pub/Subに投げるメッセージのスキーマ(打者成績)
{
  "season": 2023,
  "player_type": "batter",
  "game_date": "2023-08-10T00:00:00Z"
}


// 投手はこれ
{
  "season": 2023,
  "player_type": "pitcher",
  "game_date": "2023-08-10T00:00:00Z"
}

②のexporterが上記のメッセージを受け取り(Pub/Sub的にはSubscribeされたメッセージをPullして)処理する.

といった流れになります.

cloud.google.com

基本的には上記Pub/Subクイックスタート「クライアント ライブラリを使用して Pub/Sub でメッセージをパブリッシュおよび受信する」のちょっとした応用でコード自体は開発できちゃいます.

ちなみにPub/Subを使う理由ですが,

  • 使い慣れているから(最大の理由).
  • サービスの性質上, 外部公開(publicに外に出す)ものではなく, すべてinternalなネットワークに置きたかった.
  • 「内部限定のWeb APIにしてアクセスする」方法もあるが, Pub/Subでのピタゴラスイッチの方が利点が多い*7.

といった所が採用した理由となります.

GoとPub/Subを使ったクローラーの実装

説明で結構な文字数を使った気がします(小声)が, 実装自体は実は大したことはしていません.

  • Trigger(Pub/Sub的にはPublisher)がPub/Subにメッセージを投げる
  • Exporter(Pub/Sub的にはSubscriber)がメッセージを受け取り,
    • パラメーターからURLを組み立てる
    • http getしてCSVを取得
    • 取得したCSVをCloud Storageに保存

という流れとなります.

幸いにも, Baseball Savant(Statcast Search)は「日付(試合日)」「ポジション」をクエリにしてCSVを一発で引けるURLを用意してくれています.

# 2023-08-12時点の投手成績をCSVで取得
curl 'https://baseballsavant.mlb.com/statcast_search/csv?all=true&hfPT=&hfAB=&hfGT=R%7C&hfPR=&hfZ=&stadium=&hfBBL=&hfNewZones=&hfPull=&hfC=&hfSit=&hfOuts=&opponent=&pitcher_throws=&batter_stands=&hfSA=&hfInfield=&team=&position=&hfOutfield=&hfRO=&home_road=&hfFlag=&hfBBT=&metric_1=&hfInn=&min_pitches=0&min_results=0&group_by=name&sort_col=pitches&player_event_sort=api_p_release_speed&sort_order=desc&min_pas=0&type=details&&player_type=pitcher&game_date_gt=2023-08-12&game_date_lt=2023-08-12&hfSea=2023%7C' > pitcher.csv

上記をうまくExporter内で実装してあげることでいい感じにできそうです&実際にできました.

Trigger

Pub/Sub的にはPublisherの役割です.

おさらい的な話を書くと, 以下のSchemaで定義されたメッセージをpublishできたらOKです.

// Pub/Subに投げるメッセージのスキーマ(打者成績)
{
  "season": 2023,
  "player_type": "batter",
  "game_date": "2023-08-10T00:00:00Z"
}


// 投手はこれ
{
  "season": 2023,
  "player_type": "pitcher",
  "game_date": "2023-08-10T00:00:00Z"
}

このコードは「クライアント ライブラリを使用して Pub/Sub でメッセージをパブリッシュおよび受信する」のちょっとした応用で実装できちゃいます.

README.mdのサンプルから抜粋.

package main

import (
    "cloud.google.com/go/pubsub"
    "context"
    "encoding/json"
    "log"
    "time"
)


// 選手タイプ(投手 or 打者)の定義
type PlayerType string

const (
    PITCHER PlayerType = "pitcher"
    BATTER  PlayerType = "batter"
)


// メッセージのSchema
type Form struct {
    Season     int        `validate:"required, min=2015,max=2999" json:"season"`
    PlayerType PlayerType `validate:"required" json:"player_type"`
    GameDate   time.Time  `validate:"required" json:"game_date"`
}


// Pub/Sub TopicにPubrishするための関数
func Publish(ctx context.Context, topic *pubsub.Topic, form Form) {
    value, err := json.Marshal(form)
    if err != nil {
        log.Printf("Request Error: %s", err)
    }
    result := topic.Publish(ctx, &pubsub.Message{
        Data: value,
    })
    id, err := result.Get(ctx)
    if err != nil {
        log.Printf("Publish Error: %s", err)
    }
    log.Printf("Published a message; msg ID: %v\n", id)
}

// Pub/Sub Client
func NewPubSubClient(ctx context.Context, projectId string) *pubsub.Client {
    client, err := pubsub.NewClient(ctx, projectId)
    if err != nil {
        log.Fatalf("pubsub.NewClient: %v", err)
    }
    return client
}

// Pub/Sub Topicの作成
func Topic(client *pubsub.Client, topicID string) *pubsub.Topic {
    topic := client.Topic(topicID)
    return topic
}

func main() {
    // 試合日の取得, UTCで取得して2日引く(当日のデータは取れないため)
    t := time.Now().UTC()
    gameDate := t.Add(-2 * time.Hour * 24)
    // 必要なモノを作る
    ctx := context.Background()
    client := NewPubSubClient(ctx, GoogleCloudProjectID)
    topicExporter := Topic(client, PubTopicIDExporter)

    // 打者データ取得のためのメッセージ送信
    formBatter := Form{Season: 2023, GameDate: gameDate, PlayerType: BATTER}
    log.Printf("export batter game_date: %s", formBatter.GameDate)
    Publish(ctx, topicExporter, formBatter)
    log.Print("export batter end")

    // 投手データ取得のためのメッセージ送信
    formPitcher := Form{Season: 2023, GameDate: gameDate, PlayerType: PITCHER}
    log.Printf("export pitcher game_date: %s", formPitcher.GameDate)
    Publish(ctx, topicExporter, formPitcher)
    log.Print("export pitcher end")
}

全体で100行もいかない程度で書けちゃいました.

Exporter

Triggerは単なるメッセージを投げる役割なのでシュッとしたコードになりましたが, 受け手のExporterはやることが多いので分けて紹介します.

main

main.go自体はシンプルです.

「Pub/SubをSubscribeしてからの処理」をサラッと書いています.

package main

import (
    "context"
    "fmt"
    "log"

    "cloud.google.com/go/pubsub"
    "github.com/Shinichi-Nakagawa/baseball-savant-crawler-go/gcp"
    "github.com/Shinichi-Nakagawa/baseball-savant-crawler-go/savant"
)

func main() {
    // 必要なオブジェクトの作成, いくつかの変数は環境変数.
    ctx := context.Background()
    gcs := gcp.NewStorageClient(ctx)
    bucket := gcp.GetBucket(gcs, GcsBucketName)
    client := gcp.NewPubSubClient(ctx, GoogleCloudProjectID)

    // Subscriptionの取得&設定.
    sub := gcp.Subscription(client, PubSubSubscriptionID)
    sub.ReceiveSettings.MaxOutstandingMessages = 10

    // メッセージ受信後の処理
    err := sub.Receive(ctx, func(_ context.Context, msg *pubsub.Message) {
        // パラメーターの取得
        form, err2 := savant.CreateForm(string(msg.Data))
        if err2 != nil {
            log.Print(err2)
            return
        }

        // query(URL)と保存先のパス生成
        query := savant.Query(form)
        filename := savant.Filename(form)

        // ファイル取得&保存
        body, _ := savant.FetchAndAsString(query)
        gcp.WriteObject(bucket, fmt.Sprintf("%s/%s", GcsPathName, filename), body, ctx)
        log.Print(fmt.Sprintf("saved: %s", filename))
        msg.Ack()
    })
    if err != nil {
        log.Fatalf("sub.Receive: %s", err)
    }
}

baseball savant

Baseball Savant(Statcast Search)周りの処理.

といってもやることは,

  • URLを作る
  • 保存先のファイル名・パスを決める

以上となります.

/savant/main.goに書いた主要な処理はこちらです.

package savant

import (
    "fmt"
    "io"
    "net/http"
)

// 必要な定数定義
const (
    BASE_URL       = "https://baseballsavant.mlb.com/statcast_search/csv"
    PARAMETERS     = "all=true&hfPT=&hfAB=&hfGT=R%7C&hfPR=&hfZ=&stadium=&hfBBL=&hfNewZones=&hfPull=&hfC=&hfSit=&hfOuts=&opponent=&pitcher_throws=&batter_stands=&hfSA=&hfInfield=&team=&position=&hfOutfield=&hfRO=&home_road=&hfFlag=&hfBBT=&metric_1=&hfInn=&min_pitches=0&min_results=0&group_by=name&sort_col=pitches&player_event_sort=api_p_release_speed&sort_order=desc&min_pas=0&type=details&"
    QUERY_FORMAT   = "player_type=%s&game_date_gt=%s&game_date_lt=%s&hfSea=%d"
    FILE_FORMAT    = "%s/%s.csv"
    GAME_DT_FORMAT = "2006-01-02"
)

// URLクエリの作成
func Query(form Form) string {
    params := fmt.Sprintf(QUERY_FORMAT,
        form.PlayerType,
        form.GameDate.Format(GAME_DT_FORMAT),
        form.GameDate.Format(GAME_DT_FORMAT),
        form.Season,
    )
    return fmt.Sprintf("%s?%s&%s%s", BASE_URL, PARAMETERS, params, "%7C")
}

// 保存先ファイル名
func Filename(form Form) string {
    filename := fmt.Sprintf(FILE_FORMAT,
        form.GameDate.Format(GAME_DT_FORMAT),
        form.PlayerType,
    )
    return filename
}

// データ取得
func FetchAndAsString(query string) (string, error) {
    // HTTP GETリクエストを発行
    resp, err := http.Get(query)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    // レスポンスボディを読み込む
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }

    return string(body), nil
}

/savant/form.goにFormなどのI/Fを定義.

package savant

import (
    "encoding/json"
    "time"
)

// 選手タイプ(投手 or 打者)の定義
type PlayerType string

const (
    PITCHER PlayerType = "pitcher"
    BATTER  PlayerType = "batter"
)

// メッセージからSchemaを生成
func CreateForm(value string) (Form, error) {
    var form Form
    if err := json.Unmarshal([]byte(value), &form); err != nil {
        return form, err

    }
    return form, nil
}

// メッセージのSchema
type Form struct {
    Season     int        `validate:"required, min=2015,max=2999" json:"season"`
    PlayerType PlayerType `validate:"required" json:"player_type"`
    GameDate   time.Time  `validate:"required" json:"game_date"`
}

Google Cloud周りの処理

以下を参考に写経して試した後, 整理しました.

cloud.google.com

pkg.go.dev

整理して作った結果がこちら.

/gcp/pubsub.goにPub/Sub処理を固めて,

package gcp

import (
    "cloud.google.com/go/pubsub"
    "context"
    "log"
)

// Pub/Sub Client
func NewPubSubClient(ctx context.Context, projectId string) *pubsub.Client {
    client, err := pubsub.NewClient(ctx, projectId)
    if err != nil {
        log.Fatalf("pubsub.NewClient: %v", err)
    }
    return client
}

// Pub/Sub Subscriptionの取得
func Subscription(client *pubsub.Client, subID string) *pubsub.Subscription {
    sub := client.Subscription(subID)
    return sub
}

/gcp/gcs.goにCloud Storageに保存する処理を実装.

package gcp

import (
    "cloud.google.com/go/storage"
    "context"
    "fmt"
    "log"
)

// Cloud Storage Client
func NewStorageClient(ctx context.Context) *storage.Client {
    client, err := storage.NewClient(ctx)
    if err != nil {
        log.Fatalf("storage.NewClient: %v", err)
    }
    return client
}

// Cloud Storage Bucket取得
func GetBucket(client *storage.Client, name string) *storage.BucketHandle {
    bucket := client.Bucket(name)
    return bucket

}

// Cloud Storageにテキストとして保存
func WriteObject(bkt *storage.BucketHandle, name string, value string, ctx context.Context) {
    obj := bkt.Object(name)
    w := obj.NewWriter(ctx)
    if _, err := fmt.Fprintf(w, value); err != nil {
        log.Fatalf("fmt.Fprintf: %v", err)
    }
    if err := w.Close(); err != nil {
        log.Fatalf("w.Close: %v", err)
    }
}

毎回思うのですが, Google Cloudのクイックスタートは写経で動くので楽です, その後のアレンジに集中できるのは強みかもしれません.

結び

結果として手元で意図通り動いたので,

  • Cloud Runにホスト
  • Trigger部分をCloud Run Jobsで動かしてバッチ処理化

を引き続き進めつつ, BigQueryに保存する処理もどうにかしたいと思っています.

今回は元々Pythonで作っていたものを元にGoで作り直すというアプローチで開発しましたが, Goは本当に使いやすいなと思いました.

しばらくこのまま開発をして, 何かしらの続編を年内に出せたらと思っています.

そしてそろそろ野球の新ネタも...乞うご期待ください, 最後までお読みいただきありがとうございました.

*1:そろそろ分析の新ネタも出したいのですがそれはまた後日...

*2:過去バージョンはCloud FunctionsとCloud Runが混在していますが, Cloud Functionsも裏はCloud Runであること, コンテナ化前提の方が取り回しが良いので全部Cloud Runにしたいと思っている次第です.

*3:一つのCloud Runサービスで複数のコンテナを動かす仕組みのこと. 2023/8/13時点ではpre-GAなので正式提供ではない(いずれGAになると思いますが). これを使うと何が嬉しいかと言うと, K8sを使う方にはお馴染みである「サイドカー」パターンをCloud Runで使えるようになります.

*4:Cloud Runをバックグラウンドのジョブ, いわゆるバッチとして動かす事ができる機能のこと. こちらは既にGA済みのものです&近いうちにこのブログでも紹介するお気持ちはあります(いつ書けるかはわからない).

*5:Cloud Runにアプリがある前提だと, Cloud SchedulerやCloud Tasks等で外から動かすか, 前述のCloud Run Jobsを使うことになります.

*6:お気付きの方も多いと思いますがいわゆるETLってやつです. 余談ですが今回はデータ変換をBigQuery側でやる想定なので厳密にはELTパターンとなります(データ基盤内でデータ変換はほぼしていません).

*7:Pub/SubはいわゆるQueとしての使い方になるので, 「イベント駆動」でいい感じにサービス同士が非同期になるという所が主たる利点となります(Webだとどこかで同期になるので).

Baseball Mapping Talks+ #1 - 「頭を使わない野球を考える一日」のレポート

f:id:shinyorke:20190421154553j:plain
考えさせられますね

おそらく最速レポートかも?

本日はこちらのイベントのお手伝いをさせていただきつつ、数々のお話を聞かせていただきました.

connpass.com

なお、こちらのエントリー(に限らずこのブログそのもの)は所属する(株)ネクストベースの見解および事業とは関係なく、あくまでも私自身の感想であり、見解です.

ひと言で言うと

野球をもっと色んな視点で、多角的に考えて見てみよう!

どれが正解で間違いか...等、極端に切らずに「Mapping」して色んな軸で考えると良いのかなと思いました.

全体の流れ

LTおよびトークショーの様子はこちらにまとめています.

togetter.com

LT(ライトニングトーク)

スライド公開あるかな?あれば後日追加します.

基本的には自分のツイートからそのまま引用&コメント追加しています.

フライボール革命と“頭を使わない野球”を考える - 森本崚太さん

@ryota_mrmt

f:id:shinyorke:20190421155528j:plain

ネクストベース森本さんのお話は、「頭を使わない野球」を現在のトレンドを元にした話をされていました.

このタイミングで菊池雄星投手メジャー初勝利はめでたい.

Baseball Geeksほかでおなじみ、バレルゾーンの話はいい感じの可視化でネット上の反応も良かった感ありました.

なお、ランキング内にM・トランボがいたことは見落としませんでした(MLBヲタ的に)*1

途中、この意見に「むむっ」ってなったので思わずメンションしました、こういう未来線を考えるのはいい感じですね.

野球女子が考えるこれからのSNSと集客 - しがかえでさん

@mkmapleee

f:id:shinyorke:20190421155838j:plain

「しがちゃん、と呼んでください」というのんびりとした立ち上がりからトーク開始

数々のインターン(侍ジャパンやマイナーリーグなど)を積み重ねて現在BCリーグとはかなり強い経歴(すごい)

個人的に刺さったのはSNS運用・コンテンツマーケティングという文脈でのUGC!

集客やブランディングなどで戦略的に攻めてるのもやはり動員数とか印象づけという所が大きそうです.

なお、BCリーグさんでは人を募集中とのことなので興味ある方はぜひアプローチしてみてはいかがでしょうか?

全体的な話が、Baseball Play Studyにおける最所さんの話と似ている(というか、しがちゃんは仕事なのでガチ)のでそこの対比が面白かったです.

note.mu

最高のコーチは、計測する - 林卓史さん

f:id:shinyorke:20190421160148j:plain

続いては現場コーチ(代表を含めた大学野球)で数々の経験と実績を積み重ねられている林さん!

ひと言で言うと、色んなインプット・引用から「計測」の意味と意義を語る濃厚なトークでした.

自己紹介含め、途中の雑談・話がいちいち面白かった笑

数々の事例(過去事例から、慶応大学野球部時代の施策まで)を交えた話はホント頷くし濃くて最高でした.

続きを楽しみたい方は著書「スピンレート革命」を読むとよいかもしれません.

スピンレート革命 回転数を上げればピッチングが変わる

スピンレート革命 回転数を上げればピッチングが変わる

考えない野球を分解する - やきうのおじさん

@yakiunoojisan

f:id:shinyorke:20190421160214j:plain

LTのラストを飾ったのは「やきうのおじさん」さん.

個人的にはTweetをいつも楽しみにしていたので話を聞くのが超楽しみでした.

やきうのおじさん、の話はタイトル通り「野球」の要素を少しずつ切り取って断片にして整理した結果を一つずつ解説し、最後に大きな絵を作る、コンサルタントやエンジニアみたいな話でした.

スポーツのKaggle化、というワードは強いなあ笑

ラストを締めくくる提言もキレイで、まさに「Mapping」を意識した素晴らしい話でした!

私は壇上にいないんですけどね苦笑*2

パネルディスカッション

f:id:shinyorke:20190421160430j:plain

パネルディスカッションは色んなトークがでてきて聞いてて面白かったです.

トークへの感想から、普段思ってるような内容から切り口が様々、参加者の方も積極的に参加していて良い空間でした.

総評(&私は何をしていたのか)

ぜひ二回目もやりたいですし、今度はなにか話せるとよいなと.

なお、今日はあくまで「用務員」「SNS担当」おじさんだったのでおとなしくしていました.

というわけで、また次会お会いしましょう〜

*1:アダム・ダン率が高い扇風機と思ってもらえれば笑

*2:突然呼ばれてファッ!?ってなった

メジャーリーガーの「撮れ高」を可視化する - 得点期待値で遊ぼう

f:id:shinyorke:20190331142600p:plain
誰のグラフかは、最後に登場しますお見逃し無く!

本当はこのイベントでLTする予定の内容でした*1.

bpstudy.connpass.com

結果的に「有言不実行」となってしまい悔しいのと、何気に分析とストーリーはできてたのでちゃんと供養したいと思います.

一応の免責事項としてこの分析・解析内容は所属組織・企業の分析・解析とは無関係です.

おしながき

解決したい課題と解決方法

一言で言うと、

「得点圏で打つ・打たない」がある程度正確にわかれば普段の生活に支障なく野球を334倍楽しめるのでは?

というテーマの元、「セイバーメトリクス」を使って何かをやる、そういう話です.*2

なお、データのライセンス等の事情により今回は、

これらの公開データを用いて行っています.*3

【課題】「やきう」を自宅でみるときにあるやつ

f:id:shinyorke:20190331144307p:plain
風呂に入ってる時、試合は動く(真顔)

自分が野球を好きになって見始めたのは高校だか中学の時でかれこれ20年以上前の話なのですが、その時によくあったのが

自分が風呂に入ってる時に限って試合が動く!

ということでした.

ワイ「お、下位打線やし風呂に入るやで!」

(30分ほど風呂に入る)

ワイ「ファーーwww試合が動いているンゴwww」

人によって対象は風呂かもしれないしご飯かもしれないのですが*4、野球ファンだったらこういう「辛い」経験を少なくとも25回以上はしていると思います.*5

これを、

  • セイバーメトリクス
  • ちょっとした技術の無駄遣い

でどうにか解決できるモデルはないか?と思いたち、ちょっとだけやってみました.

【解決方法】「得点圏で仕事をする・しない(≒撮れ高)」を「得点期待値」で定量化する

「風呂に入ってる間に試合が動く」問題を解決するアプローチですが、

  • 試合を動かす可能性が高い打者が打席に入るときは風呂に入らない
  • 打席に立っても何も起きない打者(≒自動アウト)が連続している打順・回のときに風呂に入るなり家事をするなりする

という非常にシンプルな予測(というか過去の傾向)ができれば行けそうじゃね?ということで、

  • 打者が1打席あたり、どれだけ得点に貢献(≒撮れ高)を出したか、「得点期待値」を算出&打者ごとにサマリーする
  • サマリーする時に、「得点圏(走者が誰かしら2塁以上にいる、6通り)」および「not得点圏(走者なしもしくは走者一塁)」毎の集計値を出す

これで、「得点圏に強い(弱い)」「not得点圏に強い(弱い)」がわかれば、試合展開をある程度予測した上で風呂に入れそうです.*6

「得点期待値」をサマリーして集計する

方法の基礎的な考え方だけ(実際どうやってやったかは秘密).

今回使っている「得点期待値」という指標はセイバーメトリクスの世界では昔からある指標で、

「プレーの前後で変化した期待値の差分がプラスかマイナスかで定量評価する」

時に用います.

※考え方・概念は「Linear Weights - Wikipedia」をご覧頂ければと思います.

今回は以下のような、「アウトと走者状況(24通り)ごとに期待値を分類した「得点期待値成分表(RE24と呼ぶらしい)」を独自に用意しました.

f:id:shinyorke:20190331151045j:plain
【図】MLB得点期待値2018

この表を以下の公式にはめて使うことにより、「撮れ高」を算出できます.

得点期待値(撮れ高) = 「打席結果後の状況」 - 「打席に立ったときの状況」 + 獲得した得点(1〜4点)*7

【例1】ノーアウト一塁からタイムリー二塁打で1点

打席に立ったときの「ノーアウト一塁」が0.87、「打席結果」が「ノーアウト二塁」1.13の、1得点なので、

1.13 - 0.87 + 1(得点) = 1.26(撮れ高)

意味合いとしては、

「ノーアウト一塁からの二塁打(タイムリー)」は1.26点分の価値がある

ということです.

【例2】ノーアウト1塁から犠牲バントでワンアウト二塁

さっきの成分表から同じことをします.ちなみに「ワンアウト二塁」の期待値は0.68です.

0.68 - 0.87 + 0= -0.19(撮れ高)

おわかりいただけましたでしょうか?

打者ごとに積み重ねた散布図

...というような状況を全打者の全打席分で積み重ねて算出します.

なお、打者一人ににフォーカスして、「縦・期待値」「横・ランナー状況」で可視化するとこうなります.

※点一つ一つが「打席」です

f:id:shinyorke:20190331142600p:plain
誰のグラフかは、最後に登場しますお(大切なので二度言う)

これを愚直に集計してランキングを算出します.

メジャーリーガーの「得点期待値」ランキング

打者ごとに得点期待値をサマリーし、ランキングしたものです.

総合ランキング

純粋に、「得点期待値を合計した場合」のランキング.

  1. J.D.マルチネス(レッドソックスの主砲、2018年ア・リーグ打点王、本塁打ランキング2位
  2. ムーキー・ベッツ(レッドソックスの5ツールズ外野手、2018年ア・リーグ首位打者
  3. マイク・トラウト(エンジェルス不動の主力、いつでもトリプルスリーをやれそうな大谷翔平と仲良しの万能外野手
  4. クリスチャン・イエリッチ(ブリュワーズの万能外野手かつ2018年ナ・リーグ首位打者でマーリンズ時代のイチローと同僚)
  5. アレックス・ブレグマン(アストロズの若き主砲、OPS .926、ア・リーグ二塁打王

ふつーに「オールスター級」の選手ランキングになりました(こなみ)

強いて言うなら外野手多すぎ.

この人達が打席に立つときは風呂に入っちゃいけません!といいたいところですが、どの人も千両役者でスーパースターなのでそんなの数字で語るまでもなさそうです(震え

ちなみに、2位のムーキー・ベッツの散布図はこんなかんじです.

f:id:shinyorke:20190331161446p:plain
いやー強いね(こなみ)

得点圏に強いランキング

計算方法としては邪道かもですが、

「得点圏のときの得点期待値の合計」 - 「not得点圏の時の得点期待値の合計」

を引き算し、プラスが多い順に並べました.

言い換えると、

「得点圏以外だと頑張らない選手ランキング」

となります(≒この選手が打席に立ってるかつランナー二塁以上のときは席を離れちゃいけない)

  1. ジョー・マウアー(ツインズ一筋の巧打者でイチローのライバルだった、2018年引退)
  2. ザンダー・ボガーツ(レッドソックス強打のショート、菊池雄星投手からホームラン打った)
  3. ユリエスキ・グリエル(アストロズのユーティリティ内野手、、、というより元キューバ・横浜の強打者)
  4. ジェイ・ブルース(昨年まで通算286本塁打の強打者でマリナーズ所属、先日来日&イチローの後にライト守った)
  5. ニック・アーメド(ダイヤモンドバックスのショート、打つよりも守備がウリの人)

一人、日本の野球でおなじみの方が居ますね.

ためしに散布図で状況を示すと,

f:id:shinyorke:20190331162909p:plain
得点圏で頑張ってるパターンだ!

確かに、グリエルは半分控えなのに85打点(13本塁打・31二塁打)と打ってる&打率.291とそこそこ高いので、

グリエルが打席に立つときは風呂に入っちゃいけない!

ということは言えそうな気がします*8.

not得点圏に強いランキング

先ほどの「風呂に入っちゃいけない」とは逆ベクトルで、

「not得点圏のときの得点期待値の合計」 - 「得点圏の時の得点期待値の合計」

を引き算し、プラスが多い順に並べました.

言い換えると、

「得点圏以外で力を発揮する選手ランキング」

になるはずです(某ゲーム風に言うと「チャンス☓」的な).

なお、馴染み薄い選手が多いので昨年の打率・本塁打・打点を併記してます.

  1. C.J.クロン(レイズの一塁手、打率.253・30本塁打・74打点)
  2. トレイ・マンシーニ(オリオールズのリードオフマン、打率.252・24本塁打・58打点)
  3. ジャスティン・アップトン(エンジェルスのレフトで強打者、打率.257・30本塁打・85打点)
  4. マイク・ズニーノ(レイズの捕手、昨年までマリナーズ、打率.201・20本塁打・44打点)
  5. ヘイメル・キャンデラリオ(タイガース三塁手、打率.224・19本塁打・54打点)

打率・本塁打・打点の成績バランスが薄気味悪い選手が揃いました(こなみ)

アップトンが入ったのがちょっと意外でしたが...

なお、1位のクロンの散布図はこうなります.

f:id:shinyorke:20190331164932p:plain
あっ(察し

not得点圏、もうちょっと「アヘ単*9」が並ぶと思ったら案外そうでも無かったです、これはもうちょっと集計とか数え方を工夫する必要がありそうです.

総評

強打者が打席に立つ時、風呂に入っちゃいけない!(こなみ)

というのと、得点圏を「定量化」して評価はなんとなくできそうです.

ただ、今回試した方法は「ランナーを返した回数」にある程度依存するので、強打のチームの選手が上位に来やすい(逆もしかり)という欠点があるため完璧ではないです.*10

とはいえ、「出たり出なかったりする」グリエルがランクに入るなどしてるので、もうちょっと磨けばもっと面白くなりそうです.

【おまけ】気になるあの選手の期待値は?

冒頭のサムネイルに登場した得点期待値グラフの答えは、「大谷翔平選手」でした.

f:id:shinyorke:20190331165633p:plain
まんべんなく打ってます、さすが

パッと見だとランナーいるときの成績が良さげですね、なお得点圏・not得点圏共に期待値はプラスでした.

個人的には復帰したらどんなバッティングを見せてくれるか楽しみです!

以上、久々の野球ネタでした.

*1:年度末の業務がかなり逼迫してて泣く泣く回避というかスライド作る暇さえなし...申し訳なかった :bow:

*2:NHK.

*3:二つともこのブログで何度か登場しているデータセットです、詳しく知りたい方はリンク先をたどって使ってみるか、このブログから探してください(雑)

*4:親御さんだったら普通に子育てとかもあるかもしれない、ワイは独身なので縁がないけど()

*5:特定の選手のことではありません

*6:単純にOPSとか長打率を出して並び替えてもいけそうな気がしますが、OPSや長打率は「状況」を考慮してないので、今回は「状況」を考慮した得点期待値を使っています.

*7:野球にてイチ打席で入る得点の最大値は4です(こなみ)

*8:なお、リーグ最多の22併殺を記録しており、これが得点圏なのか得点圏じゃないのかは気になる所(今回は面倒なので調べてない)

*9:アヘアヘ単打マンの略.単打狙いのバッティングをするような打者のこと

*10:総合ランク1,2位のマルチネスとベッツ、得点圏ランキング2位のボガーツは同じレッドソックスの選手で、特にマルチネスとボガーツは前を打ってるベッツがたくさん出塁したことにより数字を稼いだ可能性があります