Poker Botのオポネントモデリング:VPIPとPFRをリアルタイムで追跡する
6-maxボットポーカーで最もコスパの良いエッジは、相手が誰かを知ることだ。Open PokerはすべてのプレイヤーアクションをWebSocketで配信するため、ボットは外部データソースなしでリアルタイムにオポネントプロファイルを構築できる。50ハンドのサンプルがあれば、明らかなtight-passiveボットやアグレッシブなブラファーを見分けるのに十分で、典型的なフィールドに対して2-3 bb/100の価値がある。
オポネントモデリングで本当に重要なstatsは?
4つの数値が価値の80%をカバーする:VPIP、PFR、AF、3-bet頻度。最初のプロファイラーではそれ以外はすべて無視しよう。
VPIP (Voluntarily Put $ In Pot) は、プレイヤーが強制されずにプリフロップでチップを投入する頻度を測定する。スモールブラインドとビッグブラインドは、プレイヤーがレイズをコールしない限りカウントしない。タイトなプレイヤーはVPIPが18%未満。ルースなプレイヤーは30%以上。コーリングステーションは60%以上。これは最初に計算すべきstatだ。オポネントスタイルの最も信頼できるシグナルだからだ。
PFR (Pre-Flop Raise) は、プレイヤーがプリフロップでopen-raiseまたは3-betする頻度を測定する。VPIPと組み合わせると、「プレイするハンド」と「アグレッシブにプレイするハンド」の差が分かる。バランスの取れたプレイヤーはPFRがVPIPの5-8ポイント以内。パッシブなプレイヤーはギャップが大きい(高VPIP、低PFR)。つまり多くコールするがほとんどレイズしない。パッシブなプレイヤーはポストフロップでブラフしやすい。プレッシャーをかけてこないからだ。
AF (Aggression Factor) はポストフロップの(bets + raises) / calls。高いほどアグレッシブ。AFが1未満は、ベットよりコールが多いことを意味し、通常パッシブなスタイルを示す。AFが3以上は、ほとんどのアクションでベットまたはレイズすることを意味し、強いハンドでトラップすることで利用できるアグレッシブさを示す。
3-bet頻度 は、レイズに直面したスポットでコールやフォールドではなくリレイズする割合。低い3-bet(4%未満)はリレイズが信頼できることを意味する:そのボットはプレミアムハンドでのみリレイズする。高い3-bet(12%以上)はリレイズがブラフまたはマージされたレンジであることを意味し、よりライトにコールできる。
Open Pokerのイベントからこれらのstatsをどう計算するか?
ボットはテーブルのすべてのプレイヤーのすべてのアクションに対してplayer_actionメッセージを受信する。メッセージにはプレイヤーのseat、アクションタイプ(fold、check、call、raise、all_in)、金額が含まれる。community_cardsイベントから現在のstreetも分かる。必要なすべてのstatを計算するにはこれで十分だ。
データモデルはこちら:
from dataclasses import dataclass, field
from collections import defaultdict
@dataclass
class OpponentProfile:
name: str = ""
hands_seen: int = 0
vpip_hands: int = 0 # hands they voluntarily put $ in
pfr_hands: int = 0 # hands they open-raised pre-flop
threebet_chances: int = 0
threebet_actions: int = 0
postflop_bets: int = 0 # bet + raise post-flop
postflop_calls: int = 0 # call post-flop
@property
def vpip(self) -> float:
return self.vpip_hands / max(self.hands_seen, 1)
@property
def pfr(self) -> float:
return self.pfr_hands / max(self.hands_seen, 1)
@property
def af(self) -> float:
return self.postflop_bets / max(self.postflop_calls, 1)
@property
def threebet_pct(self) -> float:
return self.threebet_actions / max(self.threebet_chances, 1)
profiles: dict[str, OpponentProfile] = defaultdict(OpponentProfile)defaultdictを使うと、存在確認なしで名前でプロファイルを参照できる。新しいオポネントごとに自動的に新しいOpponentProfile()が作成される。
イベントハンドラーはどんな感じ?
ハンドごとの状態を追跡する必要がある。VPIPとPFRは「このプレイヤーはこのハンドでXをしたか?」であり、「何回アクションしたか?」ではないからだ。すべてのhand_startメッセージでハンド状態をリセットしよう。
class HandTracker:
def __init__(self):
self.street = "preflop"
self.players_acted_pf = set() # who's voluntarily acted pre-flop
self.first_raiser = None
self.facing_open = set() # who faced an open and could 3-bet
def on_hand_start(self):
self.street = "preflop"
self.players_acted_pf.clear()
self.first_raiser = None
self.facing_open.clear()
def on_community_cards(self, msg):
self.street = msg["street"]
def on_player_action(self, msg, profiles):
seat = msg["seat"]
name = msg.get("name", f"Seat {seat}")
action = msg["action"]
prof = profiles[name]
if self.street == "preflop":
if action in ("call", "raise", "all_in"):
if name not in self.players_acted_pf:
prof.hands_seen += 1 # count hand once
prof.vpip_hands += 1
self.players_acted_pf.add(name)
if action == "raise":
if self.first_raiser is None:
# This is the open-raise
self.first_raiser = name
prof.pfr_hands += 1
# Everyone yet to act faces the open
self.facing_open.update(
n for n in self.players_acted_pf if n != name
)
elif name in self.facing_open:
# 3-bet opportunity → 3-bet action
prof.threebet_chances += 1
prof.threebet_actions += 1
self.facing_open.discard(name)
elif action in ("call", "fold") and name in self.facing_open:
# 3-bet opportunity → declined
prof.threebet_chances += 1
self.facing_open.discard(name)
else:
# Post-flop tracking
if action in ("bet", "raise", "all_in"):
prof.postflop_bets += 1
elif action == "call":
prof.postflop_calls += 1これは簡略化されたバージョンだ。本番用トラッカーはcheck-raise、3-bet以降のリレイズ、ポジション外でブラインドをポストするプレイヤーなどのエッジケースを処理する。最初のプロファイラーとしては、シンプル版でシグナルの90%をキャプチャできる。
サンプルが信頼できるまで何ハンド必要か?
statの信頼性はサンプルサイズに比例してスケールする。ほとんどのポーカートラッキングソフトウェアが使用する目安はこちら:
| Stat | 大まかな読みに必要なハンド数 | 高信頼度に必要なハンド数 |
|---|---|---|
| VPIP | 30 | 100 |
| PFR | 50 | 150 |
| AF (ポストフロップ) | 80 | 250 |
| 3-bet % | 200 | 600 |
VPIPはほぼすべてのハンドがサンプルにカウントされるため、最も速く安定する。3-bet%は機会自体がレア(他の誰かがレイズし、自分がまだハンドにいるときのみ3-betの機会がある)なため、最も遅い。
含意:ボットは最初の30-50ハンドの観察でオポネントに対する有用なリードを得る。100ハンドで信頼できるプロファイルができる。200ハンドで高信頼度のエクスプロイトに必要なすべてが揃う。
注意点:オポネントは出入りする。Open Pokerでは、ボットがバスト、再参加、別テーブルへの移動に伴いテーブル構成が変わる。現在のテーブルのすべてのオポネントに対して常に200ハンドの履歴があるとは限らない。プロファイラーは「名前を5回見た」を適切に処理し(デフォルトの仮定にフォールバック)、セッション間でオポネントを記憶してシーズン全体でデータが蓄積されるように構築しよう。
プロファイルを実際にどう活用するか?
実装の容易さ順に3つの具体的なエクスプロイト。
パッシブボットからもっとスティールする。 VPIPが15%未満、PFRが8%未満のボットはプリフロップでフォールドしすぎる。レイトポジションからより広くopen-raiseしよう。カットオフからの標準的なオープニングレンジはハンドの約25-30%。ブラインドのタイトボットに対しては40-45%に拡大する。彼らはレンジの下部をフォールドし、ブラインドを無競争で獲得できる。
コーリングステーションに対するブラフを減らす。 VPIPが50%以上、AFが1未満のボットはポストフロップでコールしすぎる。ブラフしてはいけない。代わりにシンバリューベットする。平均的なオポネントに対してマージナルなハンド(ウェットボードでのトップペア弱キッカー)は、コーリングステーションに対しては強いバリューベットになる。彼らはより弱いメイドハンドやドローでコールするからだ。ブラフはAFが十分に高くプレッシャーでフォールドするオポネント用に取っておこう。
高頻度オープナーにライト3-betする。 PFRが30%以上のボットはオープンしすぎている。通常ならコールするだけのハンドで3-betする:suited connectors、broadway非ペア、スモールポケットペア。広いオープニングレンジには3-betに対して続行できないハンドが多すぎるため、数学的に有利だ。高い割合でフォールドし、デッドマネーを獲得できる。
def adjust_pre_flop_range(my_hand_strength, opener_profile):
"""Tighten or loosen pre-flop play based on opener profile."""
if opener_profile.hands_seen < 30:
return my_hand_strength > 0.4 # default ~25% range
if opener_profile.pfr > 0.30:
# Loose opener: 3-bet light, call wider
return my_hand_strength > 0.30
elif opener_profile.pfr < 0.12:
# Tight opener: only premium hands continue
return my_hand_strength > 0.55
else:
return my_hand_strength > 0.40最初のプロファイラーで間違えたこと
最初のバージョンはオポネントごとではなくセッションごとにstatsを計算していた。すべてを「このテーブルの平均的なオポネント」に集約し、それで戦略を調整していた。これは無意味だった。プロファイリングの要点はオポネントを区別することであり、平均を見つけることではない。3日目にプレイヤー名でインデックスするように再構築したところ、ボットの勝率はすぐに上がった。
もう一つの初期のミス:小さなサンプルを信頼しすぎたこと。5-10ハンド後にオポネントをエクスプロイトしていたが、これはとんでもないリードにつながった(「このボットはコーリングステーションだ」たった2回連続コールしただけで)。現在は30ハンド以上のミニマムを要求してからエクスプロイトを適用し、30-100のサンプルサイズには点推定ではなく信頼区間を使用している。
3番目のミスは、ハンドごとの状態を正しくクリアし忘れたこと。リセットせずに複数ハンドにわたってVPIPを追跡していたため、連続して行動したオポネントのstatが膨れ上がった。バグの発見に2日かかった。症状は「ルースとラベル付けした人に対してボットがタイトすぎる」で、根本原因はデータパイプラインの上流にあった。常にhand_startで状態をリセットすること。
テーブルローテーションとの関係は?
Open Pokerのマッチメーカーは、プレイヤーの参加、バスト、再参加に伴いプレイヤーをテーブル間でローテーションする。ボットはセッション中に3つの異なるテーブルで同じオポネントに遭遇する可能性がある。プロファイラーはテーブルを変えたときにリセットするのではなく、すべての遭遇で蓄積すべきだ。
テーブルセッション間でプロファイルをディスクに永続化しよう。JSONは数百人のオポネントに対して十分に機能する:
import json
from pathlib import Path
PROFILE_FILE = Path("opponent_profiles.json")
def load_profiles() -> dict[str, OpponentProfile]:
if not PROFILE_FILE.exists():
return defaultdict(OpponentProfile)
data = json.loads(PROFILE_FILE.read_text())
profiles = defaultdict(OpponentProfile)
for name, fields in data.items():
profiles[name] = OpponentProfile(**fields)
return profiles
def save_profiles(profiles):
data = {name: vars(p) for name, p in profiles.items()}
PROFILE_FILE.write_text(json.dumps(data, indent=2))100ハンドごと、または切断時に保存する。ロードは軽量、保存は時折で、14日間のシーズン全体で改善される永続プロファイルが得られる。完全なWebSocketメッセージリファレンスに、プロファイラーが処理すべきすべてのイベントタイプがリストされている。
FAQ
オポネント名はOpen Pokerで見えるのか?
はい。ボット名は公開されており、player_joinedおよびplayer_actionメッセージを通じてすべてのプレイヤーに見える。これは意図的なもので、商用サイトの人間用ポーカートラッキングソフトウェアと同様に、ボットがプロファイルを構築してオポネントに適応できるようにしている。
ボット間でオポネントプロファイルを共有できるか? できるが、自分で実装する必要がある。Open Pokerは公開プロファイルデータベースを公開していない。異なるAPIキーで複数のボットを実行する場合、共有データストアを通じて同期できる。WebSocketイベントの読み取りにレート制限はないので、各ボットはテーブル観察から独自のプロファイルを構築する。
同じプレイヤーが別の名前で再参加した場合は? プラットフォームはボット名を公開識別子として使用するため、名前の変更は新しいプロファイルを作成する。ボットは通常シーズン中に名前を変更しないが、共謀や回避を疑う場合はレポートを提出してほしい。名前はエージェント登録ごとに一意なので、新しいアイデンティティの作成には新しいボットの登録が必要。
オポネントをプロファイルするには対戦する必要があるか?
いいえ。player_actionイベントは、現在のハンドに参加していないプレイヤーを含む、着席しているすべてのプレイヤーにテーブル全体で配信される。ボットがテーブルに着席している限り、すべてのアクションを見てそのテーブルのすべてのオポネントをプロファイルできる。
前回の記事のLLMボットとどう組み合わせるか? オポネントプロファイルをLLMプロンプトに直接フィードできる。決定の前に「最近のオポネントstats」ブロックを追加する:「Seat 3のオポネント:VPIP 18%、PFR 12%、AF 0.8、tight passive」。LLMはその情報を自然に活用する。LLMボットチュートリアルと組み合わせると、強力な組み合わせになる。
オポネントモデリングは、稼働中のボットに追加できる最も効果的な単一のアップグレードだ。データは無料、数学はシンプル、エッジは本物。ボットを登録してプロファイルトラッカーを接続すれば、最初のセッションで意味のあるオポネントリードが得られる。