Measure exploitability: ours is harder to farm than league

Winning the head-to-head says a policy is strong on average, not that it is hard
to beat. So freeze each policy, train a fresh one from scratch whose only job is
to beat that policy, and see how far it gets.

Same exploiter budget (250 updates x batch 1024, 65.5M learner actions):

  ours (match stack, 131M)   exploiter reaches 0.2278 [0.219, 0.237]
  league (web-deployed)      exploiter reaches 0.3213 [0.311, 0.331]

League gives up 9.4 more points to a dedicated attacker. Both sit far from 0.5, so
neither is a pushover -- but the caveat left open by the head-to-head is now closed
in our favour on both axes: stronger on average (0.6094) and harder to exploit.

Worth noting against expectation: league was trained *with* an exploiter structure
and we ran pure self-play, and we still come out less exploitable. Whatever the
league machinery buys, it did not buy that here.

The number is a lower bound -- a bigger exploiter would find more -- so it only
means anything as a like-for-like comparison, which is how it is used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
This commit is contained in:
2026-07-15 05:37:55 +09:00
co-authored by Claude Opus 4.8
parent cc12b058e5
commit e51d14d0fb
3 changed files with 400 additions and 0 deletions
+39
View File
@@ -539,3 +539,42 @@ Fable은 이것을 "가장 큰 누락 아이디어"로 꼽았다. 실측은 **
엉터리 채점을 한다.
**Fable은 critic을 "가장 큰 누락 아이디어" 1순위로, 양쪽 좌석을 4순위로 꼽았다. 정확히 거꾸로였다.**
---
## Exploitability: 우리가 league보다 **덜 착취당한다** (2026-07-15)
**남겨뒀던 단서를 해소했다.** 평균 실력에서 이기는 것과 "파먹을 약점이 없는 것"은 다른
속성이다. 가위바위보에서 바위를 60% 내는 놈은 아무한테나 이길 수도 있지만, 그 습관을
알아챈 놈에게는 매번 진다.
**측정 방법:** 정책을 얼려놓고, **오직 그놈만 이기도록 특화된 새 정책을 처음부터 학습**시킨다
(`src/lost_cities_jax/exploit.py`). 착취자가 도달한 승률이 곧 그 정책이 못 막아낸 습관의 크기다.
**동일 착취자 예산 (250 업데이트 × batch 1024 = 65.5M learner 액션):**
| 얼려놓은 정책 | 착취자 승률 | 95% CI |
|---|---|---|
| **우리 매치 스택 (131M)** | **0.2278** | [0.219, 0.237] |
| league (웹 배포판, 122.6M) | **0.3213** | [0.311, 0.331] |
**league가 9.4%p 더 털린다.** 그리고 둘 다 0.5에서 한참 멀다 — 어느 쪽도 만만한 상대는 아니다.
### 최종 정리: 두 축 모두에서 이긴다
| 축 | 결과 |
|---|---|
| **평균 실력** (정면 대결) | 0.6094, +22.0점 ✅ |
| **약점 적음** (exploitability) | 0.2278 vs 0.3213 ✅ |
### 단서
1. **하한선이다.** 더 세거나 더 오래 학습한 착취자는 더 찾아낼 수 있다. **절대값이 아니라
동일 예산에서의 비교로만 의미가 있다.**
2. 착취자 예산이 목표의 절반이다(65M vs 131M). 키우면 두 수치 다 오른다.
3. league는 단판 정책이라 3라운드 게임에선 다소 제 물이 아니다 — 다만 carry가 무의미하다는
것이 이미 증명됐으므로 큰 불리함은 아니다.
**흥미로운 점:** 우리는 **순수 셀프플레이**이고 league는 **착취자 구조를 학습에 넣은** 런인데,
그런데도 우리가 덜 착취당한다. 착취자 구조가 exploitability를 낮춰줄 것이라는 기대가
이 게임에서는 확인되지 않았다.
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Freeze each policy, train an exploiter against it on the same budget, compare.
Winning the head-to-head says a policy is strong on average. It does not say the
policy is hard to beat. This does: whatever the exploiter reaches is a habit the
frozen policy could not defend.
"""
from __future__ import annotations
import json
from pathlib import Path
import jax
import numpy as np
from lost_cities_jax.exploit import (
match_frozen_policy,
single_round_frozen_policy,
train_exploiter,
)
from lost_cities_jax.match import match_reset_from, match_score
from lost_cities_jax.match_eval import MATCH_SCAN_STEPS, _wilson, match_bank
from lost_cities_jax.match_ppo import Ablation, create_match_train_state
from lost_cities_jax.ppo import create_train_state, load_config, restore_checkpoint
OURS = Path("runs/jax-ppo-match/2026-07-15_031529_match-scaled/latest")
LEAGUE = Path(
"/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/latest"
)
MATCHES = 4096
def _final_score(cfg, exploiter_params, frozen, matches: int) -> dict:
"""Play the trained exploiter against the frozen policy, both seats."""
import jax.numpy as jnp
from lost_cities_jax.exploit import match_frozen_policy as _mk
from lost_cities_jax.match import match_step
attacker = _mk(cfg, exploiter_params, Ablation())
decks, coins = match_bank(20260722, matches)
@jax.jit
def run(env, a_seat):
def body(carry, _):
env, _u = carry
action = jnp.where(
env.round.to_move.astype(jnp.int32) == a_seat,
attacker(env, a_seat),
frozen(env, 1 - a_seat),
)
env, _, _ = jax.vmap(match_step, in_axes=(0, 0))(env, action)
return (env, _u), None
(env, _), _ = jax.lax.scan(body, (env, jnp.int32(0)), xs=None, length=MATCH_SCAN_STEPS)
return env
leads = []
for seat in (0, 1):
env = jax.vmap(match_reset_from)(decks, coins)
final = run(env, jnp.full((matches,), seat, dtype=jnp.int32))
totals = np.asarray(jax.vmap(match_score)(final))
leads.append(totals[:, seat] - totals[:, 1 - seat])
lead = np.concatenate(leads)
games = float(lead.size)
wins = float((lead > 0).sum())
low, high = _wilson(wins, games)
return {
"exploiter_win_rate": wins / games,
"wilson_low": low,
"wilson_high": high,
"exploiter_mean_lead": float(lead.mean()),
"matches": games,
}
def main() -> None:
match_cfg = load_config("configs/jax_ppo/match-selfplay.yaml")
old_cfg = load_config("configs/jax_ppo/balanced.yaml")
ours = restore_checkpoint(
OURS, create_match_train_state(match_cfg, jax.random.PRNGKey(0), Ablation())
).params
league = restore_checkpoint(LEAGUE, create_train_state(old_cfg, jax.random.PRNGKey(0))).params
targets = {
"ours (match stack, 131M)": match_frozen_policy(match_cfg, ours, Ablation()),
"league (web-deployed)": single_round_frozen_policy(old_cfg, league),
}
rows = []
for name, frozen in targets.items():
print(f"\n===== training an exploiter against: {name} =====", flush=True)
cfg = load_config("configs/jax_ppo/match-selfplay.yaml")
cfg.ppo.batch_games = 1024
cfg.run.total_updates = 250
cfg.run.log_every = 25
slug = name.split()[0]
state = train_exploiter(cfg, frozen, Path(f"runs/jax-ppo-match/exploit-{slug}"))
result = _final_score(cfg, state.params, frozen, MATCHES)
rows.append({"target": name, **result})
print(f" exploiter reached {result['exploiter_win_rate']:.4f} vs {name}", flush=True)
print("\n\n============ exploitability (same exploiter budget) ============")
print(f"{'frozen policy':<28}{'exploiter win rate':>20}{'95% CI':>22}")
print("-" * 72)
for row in rows:
ci = f"[{row['wilson_low']:.3f}, {row['wilson_high']:.3f}]"
print(f"{row['target']:<28}{row['exploiter_win_rate']:>20.4f}{ci:>22}")
print("\nhigher = the frozen policy had more to farm. 0.5 = nothing found.")
Path("runs/jax-ppo-match/exploitability.json").write_text(json.dumps(rows, indent=2))
if __name__ == "__main__":
main()
+244
View File
@@ -0,0 +1,244 @@
"""How exploitable is a frozen policy?
Beating an opponent on average and being hard to beat are different properties.
A policy can win the head-to-head and still have a habit a dedicated opponent can
farm -- rock-paper-scissors throwing rock 60% of the time beats a lot of people
and loses every time to anyone who noticed.
So: freeze the policy, train a fresh one from scratch whose only job is to beat
*that* policy, and see how far it gets. Near 0.5 means there was nothing to find.
Well above it means there was.
PPO self-play carries no Nash guarantee, so this is not a formality. The number it
produces is a lower bound on exploitability -- a stronger exploiter might find
more -- so it is only meaningful compared against the same exploiter budget spent
on another policy.
"""
from __future__ import annotations
import json
import time
from collections.abc import Callable
from pathlib import Path
import jax
import jax.numpy as jnp
from lost_cities_jax.match import MatchState, match_legal_action_mask, match_reset, match_step
from lost_cities_jax.match_ppo import (
FULL,
N_SEATS,
Ablation,
MatchActorCritic,
MatchTransition,
_seat_views,
create_match_train_state,
match_lead,
match_ppo_update,
)
from lost_cities_jax.obs import observation
from lost_cities_jax.ppo import (
ActorCritic,
JaxPPOConfig,
TrainState,
_append_jsonl,
_broadcast_done,
action_log_prob,
categorical_entropy,
compute_gae,
mask_logits,
masked_mean,
)
from lost_cities_jax.types import PLAY
FrozenPolicy = Callable[[MatchState, jax.Array], jax.Array]
"""(env batch, seat batch) -> greedy actions. Whatever architecture, hidden here."""
def match_frozen_policy(cfg: JaxPPOConfig, params, ablation: Ablation = FULL) -> FrozenPolicy:
model = MatchActorCritic(cfg.network.hidden_size, cfg.network.num_layers)
seats = jnp.arange(N_SEATS, dtype=jnp.int32)
def policy(env: MatchState, seat: jax.Array) -> jax.Array:
obs, critic = _seat_views(env, seats, ablation)
idx = seat[None, :, None]
logits, _ = model.apply(
params,
jnp.take_along_axis(obs, idx, axis=0)[0],
jnp.take_along_axis(critic, idx, axis=0)[0],
)
mask = jax.vmap(match_legal_action_mask)(env)
return jnp.argmax(mask_logits(logits, mask), axis=-1).astype(jnp.int32)
return policy
def single_round_frozen_policy(cfg: JaxPPOConfig, params) -> FrozenPolicy:
"""The old stack: it sees one round at a time and has never heard of carry."""
model = ActorCritic(cfg.network.hidden_size, cfg.network.num_layers)
def policy(env: MatchState, seat: jax.Array) -> jax.Array:
logits, _ = model.apply(params, jax.vmap(observation)(env.round, seat))
mask = jax.vmap(match_legal_action_mask)(env)
return jnp.argmax(mask_logits(logits, mask), axis=-1).astype(jnp.int32)
return policy
def _reset_done(env: MatchState, seat: jax.Array, rng: jax.Array, batch: int):
env_key, seat_key = jax.random.split(rng)
fresh = jax.vmap(match_reset)(jax.random.split(env_key, batch))
next_env = jax.tree_util.tree_map(
lambda old, new: jnp.where(_broadcast_done(env.done, old), new, old), env, fresh
)
# Redraw the exploiter's seat too, so it learns to beat the frozen policy from
# either side rather than only the one it started in.
fresh_seat = jax.random.bernoulli(seat_key, 0.5, shape=(batch,)).astype(jnp.int32)
return next_env, jnp.where(env.done, fresh_seat, seat)
def make_exploiter_rollout_fn(cfg: JaxPPOConfig, frozen: FrozenPolicy):
batch = cfg.ppo.batch_games
seats = jnp.arange(N_SEATS, dtype=jnp.int32)
ablation = FULL
@jax.jit
def rollout_fn(state: TrainState, env: MatchState, seat: jax.Array, rng: jax.Array):
def body(carry, _):
env, seat, key = carry
key, act_key, reset_key = jax.random.split(key, 3)
obs, critic_obs = _seat_views(env, seats, ablation)
idx = seat[None, :, None]
my_obs = jnp.take_along_axis(obs, idx, axis=0)[0]
my_critic = jnp.take_along_axis(critic_obs, idx, axis=0)[0]
legal = jax.vmap(match_legal_action_mask)(env)
logits, value = state.apply_fn(state.params, my_obs, my_critic)
masked = mask_logits(logits, legal)
mine = jax.random.categorical(act_key, masked, axis=-1).astype(jnp.int32)
theirs = jax.lax.stop_gradient(frozen(env, 1 - seat))
to_move = env.round.to_move.astype(jnp.int32)
active = ~env.done
my_turn = to_move == seat
actions = jnp.where(my_turn, mine, theirs)
before = jax.vmap(match_lead)(env)
next_env, _, _ = jax.vmap(match_step, in_axes=(0, 0))(env, actions)
after = jax.vmap(match_lead)(next_env)
# match_lead is seat 0's view; flip it when the exploiter sits in 1.
sign = jnp.where(seat == 0, 1.0, -1.0)
reward = jnp.where(active, sign * (after - before) / cfg.reward.terminal_scale, 0.0)
actor_mask = active & my_turn
transition = MatchTransition(
obs=my_obs,
critic_obs=my_critic,
legal_mask=legal,
action=actions,
log_prob=action_log_prob(masked, mine),
value=value,
reward=reward,
done=next_env.done,
active=active,
actor_mask=actor_mask,
entropy=categorical_entropy(masked),
play_action=((actions % 12) // 6 == PLAY) & actor_mask,
)
done = next_env.done
final_lead = sign * jax.vmap(match_lead)(next_env)
won = (done & active & (final_lead > 0)).astype(jnp.float32)
finished = (done & active).astype(jnp.float32)
next_env, seat = _reset_done(next_env, seat, reset_key, batch)
return (next_env, seat, key), (transition, won, finished)
(next_env, seat, rng), (transitions, won, finished) = jax.lax.scan(
body, (env, seat, rng), xs=None, length=cfg.ppo.rollout_steps
)
obs, critic_obs = _seat_views(next_env, seats, ablation)
idx = seat[None, :, None]
_, last_value = state.apply_fn(
state.params,
jnp.take_along_axis(obs, idx, axis=0)[0],
jnp.take_along_axis(critic_obs, idx, axis=0)[0],
)
played = jnp.sum(finished)
metrics = {
"exploiter_win_rate": jnp.sum(won) / jnp.maximum(played, 1.0),
"matches_completed": played,
"learner_actions": jnp.sum(transitions.actor_mask),
"entropy_mean": masked_mean(
transitions.entropy, transitions.actor_mask.astype(jnp.float32)
),
}
return next_env, seat, transitions, last_value, metrics
return rollout_fn
def train_exploiter(cfg: JaxPPOConfig, frozen: FrozenPolicy, run_dir: Path) -> TrainState:
"""Train a fresh policy whose only goal is to beat ``frozen``."""
rng = jax.random.PRNGKey(cfg.run.seed)
rng, init_key, reset_key, seat_key = jax.random.split(rng, 4)
state = create_match_train_state(cfg, init_key, Ablation())
env = jax.jit(jax.vmap(match_reset))(jax.random.split(reset_key, cfg.ppo.batch_games))
seat = jax.random.bernoulli(seat_key, 0.5, shape=(cfg.ppo.batch_games,)).astype(jnp.int32)
rollout_fn = make_exploiter_rollout_fn(cfg, frozen)
@jax.jit
def iteration(state, env, seat, rng):
rng, roll_key, update_key = jax.random.split(rng, 3)
env, seat, transitions, last_value, metrics = rollout_fn(state, env, seat, roll_key)
advantages, returns = compute_gae(
transitions.reward,
transitions.value,
transitions.done,
cfg.ppo.gamma,
cfg.ppo.gae_lambda,
last_value,
)
state, update_metrics = match_ppo_update(
state, transitions, advantages, returns, update_key, cfg
)
return state, env, seat, rng, {**metrics, **update_metrics}
run_dir.mkdir(parents=True, exist_ok=True)
metrics_path = run_dir / "metrics.jsonl"
start = time.perf_counter()
for update in range(cfg.run.total_updates):
state, env, seat, rng, metrics = iteration(state, env, seat, rng)
jax.tree_util.tree_leaves(metrics)[0].block_until_ready()
row = {k: float(v) for k, v in metrics.items()}
row["update"] = update
row["elapsed_seconds"] = time.perf_counter() - start
_append_jsonl(metrics_path, row)
if update % cfg.run.log_every == 0:
print(
json.dumps(
{
"update": update,
"exploiter_win_rate": row["exploiter_win_rate"],
"matches": row["matches_completed"],
},
sort_keys=True,
),
flush=True,
)
return state
__all__ = [
"make_exploiter_rollout_fn",
"match_frozen_policy",
"single_round_frozen_policy",
"train_exploiter",
]