Skip to content

Commit ef31b2e

Browse files
committed
feat(training): add executable masked PPO training and evaluation
1 parent 8cae626 commit ef31b2e

10 files changed

Lines changed: 273 additions & 0 deletions

File tree

‎.github/workflows/training.yml‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Training CI
2+
on:
3+
pull_request:
4+
push:
5+
branches: [main]
6+
permissions:
7+
contents: read
8+
concurrency:
9+
group: training-ci-${{ github.ref }}
10+
cancel-in-progress: true
11+
jobs:
12+
masked-ppo:
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 30
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.12"
20+
cache: pip
21+
cache-dependency-path: pyproject.toml
22+
- uses: dtolnay/rust-toolchain@stable
23+
- uses: Swatinem/rust-cache@v2
24+
- run: python -m pip install "torch>=2.3,<3" --index-url https://download.pytorch.org/whl/cpu
25+
- run: python -m pip install "maturin>=1.9,<2" "pytest>=8,<9" "numpy>=1.24,<3"
26+
- run: maturin build --release --locked --out dist
27+
- name: Install wheel with training dependencies
28+
run: |
29+
python - <<'PY'
30+
import glob
31+
import subprocess
32+
import sys
33+
subprocess.check_call([sys.executable, "-m", "pip", "install", glob.glob("dist/*.whl")[0] + "[training]"])
34+
PY
35+
- run: python -m pytest python/tests/test_ppo_example.py python/tests/test_training_mask.py -q
36+
- run: python examples/train_ppo.py --steps 4096 --envs 2 --rollout 64 --epochs 2 --eval-games 8 --output training-output
37+
- uses: actions/upload-artifact@v4
38+
with:
39+
name: masked-ppo-smoke
40+
path: training-output/

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ __pycache__/
1212

1313
/playwright-report/
1414
/test-results/
15+
16+
training-output/

‎crates/apps/gfa-py/src/lib.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ impl NativeEnv {
111111
.map_err(error)
112112
}
113113

114+
fn action_mask<'py>(&self, py: Python<'py>, seat: u8) -> PyResult<Bound<'py, PyArray1<bool>>> {
115+
let mask = py.detach(|| self.inner.action_mask(seat)).map_err(error)?;
116+
Ok(PyArray1::from_vec(py, mask))
117+
}
118+
114119
fn spec_json(&self) -> PyResult<String> {
115120
serde_json::to_string(&self.inner.spec()).map_err(error)
116121
}

‎examples/README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Training examples
2+
3+
Install a wheel with optional training dependencies, or run `python -m pip install '.[training]'` from the repository root. The environment itself does not require a training framework or host model weights.
4+
5+
```sh
6+
python examples/train_ppo.py --game connect4 --steps 250000 --envs 8 --output training-output
7+
```
8+
9+
The example uses [SB3-Contrib MaskablePPO](https://sb3-contrib.readthedocs.io/en/master/modules/ppo_mask.html) with a small CPU policy and GamesForAI's Gymnasium action masks. It trains both seats against the configured opponent. Replace the policy architecture or use the same environment in your own trainer; model training stays in your process.
10+
11+
`--opponent minimax:3` trains against that native search baseline. `--eval-opponent minimax:3` selects the comparison baseline; evaluation also runs against random play. Fixed held-out seeds, balanced seats, and two random opening plies by default create repeatable evaluation positions. `--opening-plies 0` evaluates only standard starts. Reports include sample size, outcomes, win-rate intervals, raw mean returns, package versions, seeds, and training configuration. Small smoke-run estimates do not establish model strength.
12+
13+
`policy.zip` is saved and loaded before evaluation. `report.json` records changed parameters, exact checkpoint verification, and a repeated evaluation check. CI runs actual training with a small budget and uploads those artifacts; the larger PRD acceptance run against level 3 is separate from this integration test. The example uses SB3's environment batching; native VectorEnv is available for custom batched self-play trainers.
14+
15+
```python
16+
from sb3_contrib import MaskablePPO
17+
from gamesforai import make
18+
19+
model = MaskablePPO.load("training-output/policy.zip", device="cpu")
20+
env = make("connect4", opponent="minimax:3")
21+
obs, info = env.reset(seed=123)
22+
action, _ = model.predict(obs, action_masks=env.action_masks(), deterministic=True)
23+
obs, reward, terminated, truncated, info = env.step(int(action))
24+
```

‎examples/train_ppo.py‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Train and evaluate a masked policy using an installed gamesforai wheel."""
2+
from __future__ import annotations
3+
4+
import argparse
5+
import hashlib
6+
import json
7+
import math
8+
from importlib.metadata import version
9+
from pathlib import Path
10+
11+
import numpy as np
12+
import torch
13+
from sb3_contrib import MaskablePPO
14+
from stable_baselines3.common.vec_env import DummyVecEnv
15+
16+
from gamesforai import NativeEnv, make
17+
18+
19+
def digest(policy):
20+
result = hashlib.sha256()
21+
for name, tensor in sorted(policy.state_dict().items()):
22+
result.update(name.encode())
23+
result.update(tensor.detach().cpu().contiguous().numpy().tobytes())
24+
return result.hexdigest()
25+
26+
27+
def opening(game, seed, plies):
28+
env = NativeEnv(game, seed=seed)
29+
rng = np.random.default_rng(seed)
30+
for _ in range(plies):
31+
actors = env.current_players()
32+
if not actors:
33+
raise ValueError("opening unexpectedly ended the game")
34+
env.step(actors[0], int(rng.choice(np.flatnonzero(env.action_mask(actors[0])))))
35+
return json.dumps(json.loads(env.get_state())["state"])
36+
37+
38+
def evaluate(model, game, opponent, episodes, seed, opening_plies):
39+
results = []
40+
for episode in range(episodes):
41+
env = make(game, seat=episode % 2, opponent=opponent)
42+
obs, info = env.reset(seed=seed + episode, options={
43+
"position": opening(game, seed + episode, opening_plies)
44+
})
45+
total = 0.0
46+
for _ in range(42):
47+
action, _ = model.predict(obs, action_masks=env.action_masks(), deterministic=True)
48+
obs, reward, terminated, truncated, info = env.step(int(action))
49+
total += reward
50+
if terminated or truncated:
51+
break
52+
else:
53+
raise RuntimeError("evaluation episode exceeded game bounds")
54+
env.close()
55+
results.append(total)
56+
wins = sum(reward > 0 for reward in results)
57+
draws = sum(reward == 0 for reward in results)
58+
probability, z = wins / episodes, 1.959963984540054
59+
denominator = 1 + z * z / episodes
60+
center = (probability + z * z / (2 * episodes)) / denominator
61+
radius = z * math.sqrt(probability * (1 - probability) / episodes + z * z / (4 * episodes**2)) / denominator
62+
return {
63+
"opponent": opponent, "games": episodes, "wins": wins, "draws": draws,
64+
"losses": episodes - wins - draws, "win_rate": probability,
65+
"win_rate_wilson95": [max(0.0, center - radius), min(1.0, center + radius)],
66+
"score": (wins + 0.5 * draws) / episodes, "mean_return": float(np.mean(results)),
67+
"evaluation_seed": seed, "random_opening_plies": opening_plies,
68+
}
69+
70+
71+
def run(args):
72+
if args.game not in ("tictactoe", "connect4"):
73+
raise ValueError("example supports Tic-Tac-Toe and Connect Four")
74+
if not 1 <= args.envs <= 64 or not 1 <= args.threads <= 64:
75+
raise ValueError("envs and threads must be 1..64")
76+
if args.steps < 1 or args.eval_games < 2 or not 0 <= args.opening_plies <= 2:
77+
raise ValueError("require positive steps, >=2 evaluation games, and 0..2 opening plies")
78+
if args.rollout < 2 or args.epochs < 1 or args.batch_size < 2:
79+
raise ValueError("invalid PPO rollout, batch, or epoch sizes")
80+
if args.batch_size > args.rollout * args.envs:
81+
raise ValueError("batch size exceeds rollout size")
82+
output = Path(args.output)
83+
output.mkdir(parents=True, exist_ok=True)
84+
torch.set_num_threads(args.threads)
85+
torch.use_deterministic_algorithms(True)
86+
environments = DummyVecEnv([
87+
(lambda seat=i % 2: make(args.game, seat=seat, opponent=args.opponent))
88+
for i in range(args.envs)
89+
])
90+
environments.seed(args.seed)
91+
model = MaskablePPO(
92+
"MlpPolicy", environments, n_steps=args.rollout, batch_size=args.batch_size,
93+
n_epochs=args.epochs, learning_rate=3e-4, gamma=0.99, ent_coef=0.01,
94+
policy_kwargs={"net_arch": [64, 64]}, seed=args.seed, device="cpu", verbose=1,
95+
)
96+
evaluation_seed = args.seed + 1_000_000
97+
opponents = list(dict.fromkeys(["random", args.eval_opponent]))
98+
initial_digest = digest(model.policy)
99+
before = [
100+
evaluate(model, args.game, opponent, args.eval_games, evaluation_seed, args.opening_plies)
101+
for opponent in opponents
102+
]
103+
model.learn(total_timesteps=args.steps)
104+
trained_digest = digest(model.policy)
105+
if trained_digest == initial_digest:
106+
raise RuntimeError("training did not change policy parameters")
107+
model_path = output / "policy.zip"
108+
model.save(model_path)
109+
restored = MaskablePPO.load(model_path, device="cpu")
110+
if digest(restored.policy) != trained_digest:
111+
raise RuntimeError("saved policy parameters differ after loading")
112+
after = [
113+
evaluate(restored, args.game, opponent, args.eval_games, evaluation_seed, args.opening_plies)
114+
for opponent in opponents
115+
]
116+
repeat = evaluate(restored, args.game, opponents[-1], args.eval_games, evaluation_seed, args.opening_plies)
117+
if repeat != after[-1]:
118+
raise RuntimeError("fixed evaluation did not reproduce")
119+
report = {
120+
"game": args.game, "training_seed": args.seed, "requested_steps": args.steps,
121+
"actual_steps": model.num_timesteps, "training_opponent": args.opponent,
122+
"parameters_changed": True, "checkpoint_verified": True,
123+
"evaluation_reproduced": True, "policy_sha256": trained_digest,
124+
"initial_policy_sha256": initial_digest, "before": before, "after": after,
125+
"versions": {name: version(name) for name in ("gamesforai", "sb3-contrib", "stable-baselines3", "torch", "numpy")},
126+
"configuration": vars(args),
127+
}
128+
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
129+
environments.close()
130+
print(json.dumps(report, indent=2))
131+
return report
132+
133+
134+
def parser():
135+
result = argparse.ArgumentParser(description=__doc__)
136+
result.add_argument("--game", choices=["tictactoe", "connect4"], default="connect4")
137+
result.add_argument("--steps", type=int, default=250_000)
138+
result.add_argument("--envs", type=int, default=8)
139+
result.add_argument("--rollout", type=int, default=128)
140+
result.add_argument("--batch-size", type=int, default=64)
141+
result.add_argument("--epochs", type=int, default=4)
142+
result.add_argument("--threads", type=int, default=1)
143+
result.add_argument("--seed", type=int, default=42)
144+
result.add_argument("--opponent", default="random")
145+
result.add_argument("--eval-opponent", default="minimax:3")
146+
result.add_argument("--eval-games", type=int, default=100)
147+
result.add_argument("--opening-plies", type=int, default=2)
148+
result.add_argument("--output", default="training-output")
149+
return result
150+
151+
152+
if __name__ == "__main__":
153+
run(parser().parse_args())

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies = ["numpy>=1.24,<3", "gymnasium>=1.2,<2", "pettingzoo>=1.25,<2"]
1212

1313
[project.optional-dependencies]
1414
test = ["pytest>=8,<9"]
15+
training = ["sb3-contrib>=2.7,<3", "torch>=2.3,<3"]
1516

1617
[tool.maturin]
1718
manifest-path = "crates/apps/gfa-py/Cargo.toml"

‎python/gamesforai/_native.pyi‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class NativeEnv:
1111
def step(self, seat: int, action: int) -> tuple[list[float], bool, bool]: ...
1212
def step_string(self, seat: int, action: str) -> tuple[list[float], bool, bool]: ...
1313
def builtin_action(self, seat: int, algorithm: str, level: int, seed: int) -> int: ...
14+
def action_mask(self, seat: int) -> Any: ...
1415
def spec_json(self) -> str: ...
1516
def current_players(self) -> list[int]: ...
1617
def flags(self) -> tuple[bool, bool]: ...

‎python/gamesforai/gym_env.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ def step(self, action: int) -> tuple[np.ndarray, float, bool, bool, dict[str, An
146146
observation, info = self._frame(self.seat)
147147
return observation, float(reward), terminated, truncated, info
148148

149+
def action_masks(self) -> np.ndarray:
150+
"""Boolean legal-action mask used by MaskablePPO and other trainers."""
151+
if self._needs_reset:
152+
raise ResetNeeded("Call reset before requesting an active policy mask")
153+
return self.native.action_mask(self.seat)
154+
149155
def render(self) -> str:
150156
return self.native.frame(self.seat)["text"]
151157

‎python/tests/test_ppo_example.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from pathlib import Path
2+
import runpy
3+
4+
import pytest
5+
6+
pytest.importorskip("sb3_contrib")
7+
8+
9+
def test_real_ppo_updates_saves_loads_and_repeats_evaluation(tmp_path):
10+
example = runpy.run_path(str(Path(__file__).parents[2] / "examples/train_ppo.py"))
11+
args = example["parser"]().parse_args([
12+
"--game", "tictactoe", "--steps", "512", "--envs", "2", "--rollout", "32",
13+
"--batch-size", "32", "--epochs", "2", "--eval-games", "4",
14+
"--eval-opponent", "minimax:1", "--output", str(tmp_path),
15+
])
16+
report = example["run"](args)
17+
assert report["parameters_changed"] and report["checkpoint_verified"]
18+
assert report["evaluation_reproduced"]
19+
assert report["actual_steps"] >= 512
20+
assert (tmp_path / "policy.zip").is_file()
21+
assert (tmp_path / "report.json").is_file()
22+
assert all(row["games"] == row["wins"] + row["draws"] + row["losses"] for row in report["after"])

‎python/tests/test_training_mask.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import numpy as np
2+
import pytest
3+
from gymnasium.error import ResetNeeded
4+
5+
from gamesforai import make
6+
7+
8+
def test_maskable_training_api_is_boolean_owned_and_requires_active_episode():
9+
env = make("connect4")
10+
with pytest.raises(ResetNeeded):
11+
env.action_masks()
12+
_, info = env.reset(seed=1)
13+
mask = env.action_masks()
14+
assert mask.dtype == np.bool_
15+
np.testing.assert_array_equal(mask, info["action_mask"])
16+
mask[:] = False
17+
assert env.action_masks().all()
18+
_, _, _, _, info = env.step(3)
19+
np.testing.assert_array_equal(env.action_masks(), info["action_mask"])

0 commit comments

Comments
 (0)